diff --git a/.changeset/light-buttons-shake.md b/.changeset/light-buttons-shake.md new file mode 100644 index 000000000..c2745dfcd --- /dev/null +++ b/.changeset/light-buttons-shake.md @@ -0,0 +1,5 @@ +--- +"@rnx-kit/tools-performance": minor +--- + +Initial implementation of tools-performance package diff --git a/incubator/tools-performance/README.md b/incubator/tools-performance/README.md new file mode 100644 index 000000000..b44cdaeac --- /dev/null +++ b/incubator/tools-performance/README.md @@ -0,0 +1,322 @@ +# @rnx-kit/tools-performance + +[![Build](https://github.com/microsoft/rnx-kit/actions/workflows/build.yml/badge.svg)](https://github.com/microsoft/rnx-kit/actions/workflows/build.yml) +[![npm version](https://img.shields.io/npm/v/@rnx-kit/tools-performance)](https://www.npmjs.com/package/@rnx-kit/tools-performance) + +Lightweight performance tracing and reporting for Node.js tooling. Provides a +simple API for measuring the duration of synchronous and asynchronous operations, +categorizing them by domain with frequency-based filtering, and printing a +summary table on process exit. + +## Motivation + +Build tools like Metro bundlers, dependency resolvers, and transformers benefit +from visibility into where time is spent. This package provides a low-overhead +way to instrument code, collect timing data across domains, and produce a +human-readable report — without adding heavy dependencies. + +The API is split into two roles: + +- **Instrumenting** — library and tool authors add trace points to their code. + Instrumentation is inert until tracking is enabled. +- **Enabling and reporting** — the application entry point turns on tracking and + controls how results are displayed. + +## Installation + +```sh +yarn add @rnx-kit/tools-performance --dev +``` + +or if you're using npm + +```sh +npm add --save-dev @rnx-kit/tools-performance +``` + +## Instrumenting Code + +Instrumentation adds trace points to functions you want to measure. When +tracking is not enabled, trace calls are zero-cost passthroughs via `nullTrace`. + +### Using the module-level API + +The simplest approach uses `getTrace`, which returns a trace function scoped to a +domain. If the domain is not enabled, it returns `nullTrace` — a passthrough +that calls the function directly with no recording overhead. + +```typescript +import { getTrace } from "@rnx-kit/tools-performance"; + +const trace = getTrace("metro"); + +// Trace a sync function +const config = trace("parse", () => parseConfig(configPath)); + +// Trace an async function +const bundle = await trace("bundle", async () => { + return await buildBundle(entryPoint); +}); + +// Pass arguments directly — types are checked against the function signature +const resolved = trace("resolve", resolveModule, specifier, context); +``` + +### Using a domain directly + +For more control, use `getDomain` to access the `PerfDomain` object. This lets +you check frequency levels and conditionally set up extra instrumentation. + +```typescript +import { getDomain } from "@rnx-kit/tools-performance"; + +const domain = getDomain("resolve"); + +if (domain?.enabled("high")) { + // set up extra high-frequency instrumentation +} + +const trace = domain?.getTrace("high") ?? nullTrace; +trace("lookup", () => resolveModule(specifier)); +``` + +### Frequency levels + +Three hierarchical levels control tracing granularity: + +- **`"low"`** — Always recorded when tracing is enabled +- **`"medium"`** — Recorded when frequency is `"medium"` or `"high"` (default) +- **`"high"`** — Only recorded when frequency is `"high"` + +```typescript +// This trace only records if the domain's frequency is "high" +const trace = getTrace("resolve", "high"); +``` + +### Null implementations + +`nullTrace` is a no-op that calls the wrapped function directly. It is useful as +a default when tracing may not be enabled: + +```typescript +import { getTrace, nullTrace } from "@rnx-kit/tools-performance"; + +// getTrace returns nullTrace when the domain is not enabled +const trace = getTrace("metro"); + +// Or use it explicitly as a fallback +const myTrace = someCondition ? customTrace : nullTrace; +``` + +### Custom trace functions + +Use `createTrace` with a `TraceRecorder` to build trace functions backed by +custom recording logic. The recorder is called twice per event — once before +(returning a handoff value) and once after (receiving it back): + +```typescript +import { createTrace } from "@rnx-kit/tools-performance"; +import type { TraceRecorder } from "@rnx-kit/tools-performance"; + +const recorder: TraceRecorder = (tag, handoff?) => { + if (handoff !== undefined) { + console.log(`${tag} took ${performance.now() - handoff}ms`); + } + return performance.now(); +}; + +const trace = createTrace(recorder); +trace("work", () => doExpensiveWork()); +``` + +## Enabling and Reporting + +The application entry point controls which domains are tracked and how results +are reported. Instrumented code is inert until `trackPerformance` is called. + +### Quick start + +```typescript +import { trackPerformance, reportPerfData } from "@rnx-kit/tools-performance"; + +// Enable all domains with in-memory timing +trackPerformance({ strategy: "timing" }); + +// ... run instrumented code ... + +// Print the report (also prints automatically on process exit) +reportPerfData(); +``` + +Output: + +``` +┌──────────────┬───────┬───────┬───────┐ +│ operation │ calls │ total │ avg │ +├──────────────┼───────┼───────┼───────┤ +│ metro: parse │ 1 │ 12 │ 12 │ +│ metro: bundl │ 1 │ 450 │ 450 │ +└──────────────┴───────┴───────┴───────┘ +``` + +### Controlling what is tracked + +```typescript +// Enable all domains +trackPerformance({ strategy: "timing" }); + +// Enable specific domains +trackPerformance({ enable: "metro" }); +trackPerformance({ enable: ["resolve", "transform"] }); + +// Calls are additive — all three domains above are now enabled +``` + +### Checking if tracing is enabled + +`isTraceEnabled` checks domain and frequency without creating a domain as a +side effect: + +```typescript +import { isTraceEnabled } from "@rnx-kit/tools-performance"; + +if (isTraceEnabled("metro")) { + // domain is enabled +} + +if (isTraceEnabled("metro", "high")) { + // domain is enabled at "high" frequency +} +``` + +### Using PerfTracker directly + +For more control over lifecycle, use `PerfTracker` directly instead of the +module-level API. Each tracker manages its own set of domains and registers a +process exit handler automatically. + +```typescript +import { PerfTracker } from "@rnx-kit/tools-performance"; + +const tracker = new PerfTracker({ + enable: "metro", + strategy: "timing", + reportColumns: ["name", "calls", "total", "avg"], + reportSort: ["total"], + maxNameWidth: 40, +}); + +const domain = tracker.domain("metro"); +const trace = domain.getTrace(); +await trace("bundle", buildBundle, entryPoint); + +// Stop tracking and print the report +tracker.finish(); +``` + +### Tracing strategies + +| Strategy | Description | +| ---------- | --------------------------------------------------------------------------------------------------------------------------- | +| `"timing"` | Records times in memory. Lower overhead, suitable for high-frequency events. Reports to console on process exit by default. | +| `"node"` | Uses `performance.mark` and `performance.measure`. Higher overhead, but integrates with Node.js performance tooling. | + +### Table formatting + +The `formatAsTable` utility can format any 2D data array into a bordered table: + +```typescript +import { formatAsTable } from "@rnx-kit/tools-performance"; + +const table = formatAsTable( + [ + ["parse", 12, 1], + ["bundle", 450, 1], + ], + { + columns: [ + { label: "operation", align: "left" }, + { label: "total (ms)", align: "right", digits: 0, localeFmt: true }, + { label: "calls", align: "right" }, + ], + sort: [1], + } +); +console.log(table); +``` + +## API Reference + +### Module-Level Functions + +| Function | Description | +| ------------------------------- | ----------------------------------------------------------------------- | +| `trackPerformance(config?)` | Enable tracking. Config controls domains, strategy, and report options. | +| `getTrace(domain, frequency?)` | Get a trace function for a domain. Returns `nullTrace` if not enabled. | +| `getDomain(name)` | Get the `PerfDomain` for a domain, or `undefined` if not enabled. | +| `isTraceEnabled(domain, freq?)` | Check if tracing is enabled for a domain and optional frequency. | +| `reportPerfData()` | Finish tracking and print the performance report. | + +### PerfTracker + +| Member | Description | +| -------------------------- | ---------------------------------------------------------------------- | +| `new PerfTracker(config?)` | Create a new tracker. Auto-registers a process exit handler. | +| `enable(domain)` | Enable tracking for `true` (all), a string, or string array. | +| `isEnabled(domain, freq?)` | Check if a domain is enabled, optionally at a given frequency. | +| `domain(name)` | Get or create a `PerfDomain` for an enabled domain. | +| `finish(processExit?)` | Stop all domains, print the report, and unregister. Only reports once. | +| `updateConfig(config)` | Merge new configuration values. | + +### PerfDomain + +| Member | Description | +| ---------------------- | ---------------------------------------------------------------------- | +| `name` | Domain name (readonly). | +| `strategy` | Tracing strategy: `"timing"` or `"node"` (readonly). | +| `frequency` | Current frequency level (mutable). | +| `start()` | Begin domain-level timing (called automatically unless `waitOnStart`). | +| `stop(processExit?)` | End domain-level timing and clean up marks. | +| `enabled(frequency?)` | Check if a frequency level is active for this domain. | +| `getTrace(frequency?)` | Get a trace function, or `nullTrace` if frequency is not active. | + +### Trace Primitives + +| Function | Description | +| -------------------------- | ---------------------------------------------------- | +| `createTrace(recorder)` | Create a trace function backed by a `TraceRecorder`. | +| `nullTrace(tag, fn, args)` | No-op trace — calls `fn(...args)` directly. | + +### PerformanceOptions + +| Field | Type | Default | Description | +| --------------- | ----------------------------- | -------------------------------- | ----------------------------------------------------- | +| `enable` | `true \| string \| string[]` | `true` | Domains to enable tracking for. | +| `strategy` | `"timing" \| "node"` | `"node"` | Tracing strategy. | +| `frequency` | `"low" \| "medium" \| "high"` | `"medium"` | Default event frequency level. | +| `waitOnStart` | `boolean` | `false` | Don't auto-start domain timing on creation. | +| `reportColumns` | `PerfReportColumn[]` | `["name","calls","total","avg"]` | Columns to display in the report. | +| `reportSort` | `PerfReportColumn[]` | insertion order | Columns to sort by, in precedence order. | +| `showIndex` | `boolean` | `false` | Show row index in the report. | +| `maxNameWidth` | `number` | `50` | Max width for operation names (truncated with `...`). | +| `reportHandler` | `(report: string) => void` | `console.log` | Function that receives the formatted report. | + +### TableOptions + +| Field | Type | Default | Description | +| ----------- | ----------------------------- | ------- | ----------------------------------------------- | +| `columns` | `(string \| ColumnOptions)[]` | auto | Column labels or configuration objects. | +| `sort` | `number[]` | none | Column indices to sort by, in precedence order. | +| `showIndex` | `boolean` | `false` | Show a row index column. | +| `noColors` | `boolean` | `false` | Strip ANSI styling from output. | + +### ColumnOptions + +| Field | Type | Default | Description | +| ----------- | --------------------------- | -------- | -------------------------------------------- | +| `label` | `string` | auto | Column header label. | +| `digits` | `number` | -- | Fixed decimal places for numeric values. | +| `localeFmt` | `boolean` | `false` | Use locale number formatting. | +| `align` | `"left"\|"right"\|"center"` | `"left"` | Cell text alignment. | +| `maxWidth` | `number` | -- | Maximum column width (truncates with `...`). | +| `style` | `StyleValue \| function` | -- | ANSI style or custom formatter. | diff --git a/incubator/tools-performance/package.json b/incubator/tools-performance/package.json new file mode 100644 index 000000000..524a5fe1c --- /dev/null +++ b/incubator/tools-performance/package.json @@ -0,0 +1,44 @@ +{ + "name": "@rnx-kit/tools-performance", + "version": "0.0.1", + "description": "EXPERIMENTAL - USE WITH CAUTION - tools-performance", + "homepage": "https://github.com/microsoft/rnx-kit/tree/main/incubator/tools-performance#readme", + "license": "MIT", + "author": { + "name": "Microsoft Open Source", + "email": "microsoftopensource@users.noreply.github.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rnx-kit", + "directory": "incubator/tools-performance" + }, + "files": [ + "lib/**/*.d.ts", + "lib/**/*.js" + ], + "type": "module", + "sideEffects": false, + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + } + }, + "scripts": { + "build": "rnx-kit-scripts build", + "format": "rnx-kit-scripts format", + "lint": "rnx-kit-scripts lint", + "test": "rnx-kit-scripts test" + }, + "devDependencies": { + "@rnx-kit/scripts": "*", + "@rnx-kit/tsconfig": "*" + }, + "engines": { + "node": ">=22.11" + }, + "experimental": true +} diff --git a/incubator/tools-performance/src/domain.ts b/incubator/tools-performance/src/domain.ts new file mode 100644 index 000000000..10a7f86aa --- /dev/null +++ b/incubator/tools-performance/src/domain.ts @@ -0,0 +1,171 @@ +import { createTrace, nullTrace, nullRecordTime } from "./trace.ts"; +import type { TraceRecorder } from "./trace.ts"; +import type { + EventFrequency, + PerfDomainOptions, + TraceFunction, + TraceStrategy, +} from "./types.ts"; + +/** + * A class that tracks a set of perf options from a single domain. The domain can run in either timing mode + * or node performance mark mode. The mode is determined by the presence of a recordTime function in the options. + */ +export class PerfDomain { + private static FREQUENCY_RANK: Record = { + low: 0, + medium: 1, + high: 2, + }; + private static DEFAULT_FREQUENCY: EventFrequency = "medium"; + static frequencyEnabled(test?: EventFrequency, setting?: EventFrequency) { + const testFreq = test ?? PerfDomain.DEFAULT_FREQUENCY; + const settingFreq = setting ?? PerfDomain.DEFAULT_FREQUENCY; + return ( + PerfDomain.FREQUENCY_RANK[testFreq] <= + PerfDomain.FREQUENCY_RANK[settingFreq] + ); + } + + /** The name of the performance domain */ + readonly name: string; + /** The tracing strategy used by this domain */ + readonly strategy: TraceStrategy; + /** The frequency level of events for this domain, can be changed on the fly */ + frequency: EventFrequency; + + /** + * Is this frequency of event enabled. Used for conditional setup of extra instrumentation. + * @param frequency the event level to check frequency for + */ + enabled(requested?: EventFrequency) { + return PerfDomain.frequencyEnabled(requested, this.frequency); + } + + /** + * Get a trace function for this namespace and frequency. If this level of tracing is not enabled this will return + * a non-tracking passthrough function. + * @param frequency the frequency of the trace events. Defaults to "medium" + * @returns a trace function that will record events for this namespace and frequency. + */ + getTrace(requested?: EventFrequency): TraceFunction { + return this.enabled(requested) ? this.trace : nullTrace; + } + + /** + * Start performance tracking for this namespace. This will be called automatically on creation unless + * the waitOnStart option is set. Trace events will still work without this but you won't get a boundary + * around the events. + */ + start() { + this.startVal ??= this.record(""); + } + + /** + * End event for the performance namespace. This will finish the start events and do some cleanup. If running + * with the node strategy the marks for this namespace will be cleared. + * @param processExit is this happening as part of process exit. + */ + stop(processExit = false) { + // first attempt to close the initial start event if it exists + if (this.startVal != null) { + const lastOp = this.lastTime; + if (this.strategy === "timing" && processExit && lastOp !== undefined) { + // in this case use the last recorded time as the end time for the domain + const duration = lastOp - (this.startVal as number); + this.recordTime(this.coerceTag(""), duration); + } else { + // otherwise just record the domain event with the start value as the handoff + this.record("", this.startVal); + } + this.startVal = undefined; + } + + // clear any orphaned marks, generally coming from exceptions that prevented normal cleanup + if (!processExit) { + this.cleanupMarks(); + } + } + + /** + * Constructor for a performance domain. If no options are provided this will be: + * - medium frequency + * - using node performance marks + * - not wait on start (start will be called immediately) + * @param name The name of the performance domain + * @param options Options for configuring the performance domain + */ + constructor(name: string, options: PerfDomainOptions = {}) { + this.name = name; + const { + frequency = PerfDomain.DEFAULT_FREQUENCY, + waitOnStart, + recordTime, + } = options; + this.frequency = frequency; + this.strategy = recordTime ? "timing" : "node"; + this.recordTime = recordTime ?? nullRecordTime; + this.record = recordTime + ? this.timingRecorder.bind(this) + : this.markingRecorder.bind(this); + this.trace = createTrace(this.record); + if (!waitOnStart) { + this.start(); + } + } + + private tagMap: Record = {}; + private firstTime?: number; + private lastTime?: number; + private sequence = 0; + private startVal: number | string | undefined = undefined; + private recordTime: (tag: string, duration?: number) => void; + private record: TraceRecorder; + private trace: TraceFunction; + + private coerceTag(tag: string) { + return (this.tagMap[tag] ??= `${this.name}:${tag}`); + } + + private timingRecorder(tag: string, startTime?: number) { + const timeNow = performance.now(); + tag = this.coerceTag(tag); + if (startTime == null) { + this.recordTime(tag); + this.firstTime ??= timeNow; + } else { + this.recordTime(tag, timeNow - startTime); + this.lastTime = timeNow; + } + return timeNow; + } + + private markingRecorder(tag: string, startMark?: string) { + tag = this.coerceTag(tag); + if (startMark == null) { + const markerName = `${tag}:mark:${this.sequence++}`; + performance.mark(markerName); + return markerName; + } else { + const endMarkerName = `${startMark}:end`; + performance.mark(endMarkerName); + performance.measure(tag, startMark, endMarkerName); + performance.clearMarks(startMark); + performance.clearMarks(endMarkerName); + performance.clearMeasures(tag); + return tag; + } + } + + private cleanupMarks() { + if (this.strategy === "node") { + const prefix = `${this.name}:`; + // clear marks for this namespace with the assumption there has been enough time to capture the measures. + for (const entry of performance.getEntriesByType("mark")) { + if (entry.name.startsWith(prefix)) { + performance.clearMarks(entry.name); + } + } + } + } +} diff --git a/incubator/tools-performance/src/index.ts b/incubator/tools-performance/src/index.ts new file mode 100644 index 000000000..f9c25be6f --- /dev/null +++ b/incubator/tools-performance/src/index.ts @@ -0,0 +1,26 @@ +export { PerfDomain } from "./domain.ts"; + +export { + getDomain, + getTrace, + isTrackingEnabled, + reportPerfData, + trackPerformance, +} from "./perf.ts"; + +export type { TableOptions, ColumnOptions } from "./table.ts"; +export { formatAsTable } from "./table.ts"; + +export type { TraceRecorder } from "./trace.ts"; +export { createTrace, nullTrace } from "./trace.ts"; + +export { PerfTracker } from "./tracker.ts"; +export type { + EventFrequency, + PerfArea, + PerfDomainOptions, + PerfReportColumn, + PerformanceOptions, + TraceFunction, + TraceStrategy, +} from "./types.ts"; diff --git a/incubator/tools-performance/src/perf.ts b/incubator/tools-performance/src/perf.ts new file mode 100644 index 000000000..9ced4bb2d --- /dev/null +++ b/incubator/tools-performance/src/perf.ts @@ -0,0 +1,63 @@ +import { nullTrace } from "./trace.ts"; +import { PerfTracker } from "./tracker.ts"; +import type { EventFrequency, PerformanceOptions } from "./types.ts"; + +let defaultManager: PerfTracker | undefined = undefined; + +/** + * Start tracking performance for the specified mode and configuration. Mode can be: + * - true to enable all tracking + * - a specific area to track (e.g. "metro", "resolve", "transform", "serialize") + * - a list of areas to track + * - undefined which will default to true and enable all tracking + * Calling multiple times will be additive in terms of areas tracked and will overwrite the configuration + * + * @param mode tracking modes to enable + * @param config performance configuration + */ +export function trackPerformance(config: PerformanceOptions = {}) { + if (!defaultManager) { + defaultManager = new PerfTracker(config); + } else { + defaultManager.updateConfig(config); + } +} + +/** + * Finish tracking (rather than waiting for process exit) and print the report to the console. + */ +export function reportPerfData() { + defaultManager?.finish(); +} + +/** + * Check if tracking is enabled for a specific category. If category is undefined, checks if all tracking is enabled. + */ +export function isTrackingEnabled(domain: string, frequency?: EventFrequency) { + return Boolean(defaultManager?.isEnabled(domain, frequency)); +} + +/** + * Get a trace function for the specified category. If not enabled it will be a non-tracking passthrough + * @param category category or undefined for unscoped + */ +export function getTrace(domain: string, frequency?: EventFrequency) { + return defaultManager?.domain(domain)?.getTrace(frequency) ?? nullTrace; +} + +/** + * Get the specific perf domain if it is enabled, otherwise undefined + */ +export function getDomain(name: string) { + return defaultManager?.domain(name); +} + +/** + * Reset the module-level performance tracker, releasing all domains and state. + * Intended for test isolation — not part of the public API. + * @internal + */ +export function resetPerfData() { + defaultManager?.finish(); + defaultManager = undefined; +} diff --git a/incubator/tools-performance/src/table.ts b/incubator/tools-performance/src/table.ts new file mode 100644 index 000000000..38d8300c6 --- /dev/null +++ b/incubator/tools-performance/src/table.ts @@ -0,0 +1,290 @@ +import { stripVTControlCharacters, styleText } from "node:util"; + +export type StyleValue = Parameters[0]; +export type ValueFormatter = (value: T) => string; + +export type TableOptions = { + /** + * Configuration for each column, can be a string for the column label or an object with additional formatting + * options. If not provided, columns will be labeled Column1, Column2, etc. + */ + columns?: (string | ColumnOptions)[]; + + /** + * Optional array of column indices to sort by, in order of precedence. If not provided, no sorting will be applied. + */ + sort?: number[]; + + /** + * Show an index column at the start of the table with the row number. Defaults to false. + */ + showIndex?: boolean; + + /** + * No color styling will be applied to the table output if this is set to true. Defaults to false. + */ + noColors?: boolean; +}; + +/** + * Options for configuring column output + */ +export type ColumnOptions = { + /** label to use for the column header. Defaults to "ColumnX" where X is the column index + 1 */ + label?: string; + /** function to convert the column value to a string, defaults to String(value) */ + format?: ValueFormatter; + /** digits for a numeric value, for use with toFixed */ + digits?: number; + /** use locale format for numeric values. e.g. turn 1000 into 1,000 */ + localeFmt?: boolean; + /** style to apply to the column value, either a style from styleText or a custom formatter */ + style?: StyleValue | ValueFormatter; + /** alignment of the column value, defaults to "left" */ + align?: "left" | "right" | "center"; + /** maximum width of the column */ + maxWidth?: number; +}; + +export function formatAsTable( + values: unknown[][], + { columns, sort, showIndex, noColors }: TableOptions = {} +): string { + if (values.length === 0) { + return ""; + } + // setup the column configs and calculate initial widths based on header labels + const colData = values[0].map((_col, index) => { + const inputValue = columns?.[index]; + const config = + typeof inputValue === "string" + ? { label: inputValue } + : (inputValue ?? {}); + return toColumnData(config, index); + }); + + // create the cell data rows, applying styling data and updating colData with widths + const rows = values.map((row) => toRowData(row, colData, noColors)); + + // sort the rows in place if requested based on one or more keys + if (sort && sort.length > 0) { + rows.sort((a, b) => { + for (const index of sort) { + const cmp = compareValues(a[index].key, b[index].key); + if (cmp !== 0) { + return cmp; + } + } + return 0; + }); + } + + // now render the table based on the prepared row and column data + return drawTable(rows, colData, showIndex, noColors); +} + +type ResolvedColumnOptions = Omit & { + label: string; + width: number; + intlFormatter?: Intl.NumberFormat; +}; + +type CellEntry = { + /** text to display */ + text: string; + /** width of the text, not including control characters */ + width: number; + /** sortable value */ + key: string | number; +}; + +function toColumnData( + config: ColumnOptions, + index: number +): ResolvedColumnOptions { + const label = config.label ?? `Column${index + 1}`; + const width = stripVTControlCharacters(label).length; + const { digits, localeFmt } = config; + const intlFormatter = + localeFmt && digits !== undefined + ? new Intl.NumberFormat(undefined, { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }) + : undefined; + return { ...config, label, width, intlFormatter }; +} + +function toCellData( + value: unknown, + config: ResolvedColumnOptions, + noColors = false +): CellEntry { + const { format, style, maxWidth } = config; + let text = format ? format(value) : undefined; + let key: string | number; + if (text === undefined && typeof value === "number") { + const { digits, intlFormatter } = config; + if (intlFormatter) { + text = intlFormatter.format(value); + } else { + text = digits != null ? value.toFixed(digits) : String(value); + } + key = value; + } else { + text ??= String(value); + key = text; + } + if (maxWidth != null && text.length > maxWidth) { + text = text.slice(0, maxWidth - 3) + "..."; + } + const width = text.length; + if (width > config.width) { + config.width = width; + } + if (style) { + text = typeof style === "function" ? style(text) : styleText(style, text); + } + if (noColors) { + text = stripVTControlCharacters(text); + } + return { text, width, key }; +} + +function toRowData( + values: unknown[], + columns: ResolvedColumnOptions[], + noColors = false +): CellEntry[] { + return values.map((value, index) => + toCellData(value, columns[index], noColors) + ); +} + +/** + * Draw the set of table rows with the given column data + * @param rows processed rows to render + * @param columns processed column data with widths and styling information + * @param addIndex whether to add an index column at the start of the table + * @returns a string representing the drawn table to be printed to the console + */ +function drawTable( + rows: CellEntry[][], + columns: ResolvedColumnOptions[], + addIndex = false, + noColors = false +): string { + const indexWidth = addIndex ? Math.max(String(rows.length + 1).length, 5) : 0; + let output = drawLine(columns, "top", indexWidth); + const headerRow = columns.map((col) => { + const rawText = stripVTControlCharacters(col.label); + return { + text: noColors ? rawText : col.label, + width: rawText.length, + } as CellEntry; + }); + output += drawRow(headerRow, columns, "index", indexWidth); + output += drawLine(columns, "mid", indexWidth); + for (let index = 0; index < rows.length; index++) { + output += drawRow(rows[index], columns, index + 1, indexWidth); + } + output += drawLine(columns, "bottom", indexWidth); + return output; +} + +const segments = { + top: ["┌", "┬", "┐"], + mid: ["├", "┼", "┤"], + bottom: ["└", "┴", "┘"], +}; + +/** + * Draw table lines based on column widths and the type of line (top, mid, bottom) + * @param colData set of column data with widths to determine how long to draw each segment + * @param type one of "top", "mid", or "bottom" to determine which line segments to use + * @param indexWidth width of the index column, if present. Column will be skipped if width is 0. + * @returns the drawn line as a string + */ +function drawLine( + colData: ResolvedColumnOptions[], + type: keyof typeof segments, + indexWidth = 0 +) { + const seg = segments[type]; + let line = seg[0]; + if (indexWidth > 0) { + line += "─".repeat(indexWidth + 2) + seg[1]; + } + for (let i = 0; i < colData.length; i++) { + line += "─".repeat(colData[i].width + 2); + line += i === colData.length - 1 ? seg[2] : seg[1]; + } + return line + "\n"; +} + +/** + * Pad a string with spaces to reach the desired width. If width is negative or zero, no padding is added. + */ +function pad(width: number): string { + return width > 0 ? " ".repeat(width) : ""; +} + +/** + * Draw text aligned within the given cell area + */ +function alignedText( + text: string, + width: number, + desiredWidth: number, + align: "left" | "right" | "center" +) { + const delta = desiredWidth - width; + let leftPad = 1; + let rightPad = 1; + if (align === "center") { + leftPad += Math.floor(delta / 2); + rightPad += Math.ceil(delta / 2); + } else if (align === "right") { + leftPad += delta; + } else { + rightPad += delta; + } + return pad(leftPad) + text + pad(rightPad); +} + +/** + * Draw a single row of the table + * @param cells the cell entries for this row, including text and width information + * @param columns the column data for this table, used to determine column widths and styling + * @param index the index of this row, used for display in the index column if present + * @param indexWidth the width of the index column, if present + * @returns the drawn row as a string + */ +function drawRow( + cells: CellEntry[], + columns: ResolvedColumnOptions[], + index: number | string, + indexWidth = 0 +) { + let row = "│"; + if (indexWidth > 0) { + const indexText = String(index); + row += alignedText(indexText, indexText.length, indexWidth, "right") + "│"; + } + + for (let i = 0; i < cells.length; i++) { + const col = columns[i]; + const { text, width } = cells[i]; + row += alignedText(text, width, col.width, col.align ?? "left") + "│"; + } + return row + "\n"; +} + +/** sort compare a string or number values, both values should be of the same type */ +function compareValues(a: T, b: T): number { + if (typeof a === "number") { + return (b as number) - a; + } else { + return a.localeCompare(b as string); + } +} diff --git a/incubator/tools-performance/src/trace.ts b/incubator/tools-performance/src/trace.ts new file mode 100644 index 000000000..442adebde --- /dev/null +++ b/incubator/tools-performance/src/trace.ts @@ -0,0 +1,114 @@ +import type { AcceptAnyFn, TraceFunction } from "./types.ts"; + +/** + * Signature for a recorder of trace information. Trace functions will call the recorder twice for each trace event. + * - before: const handoff = record(tag) - called with no time information + * - after: record(tag, handoff) - record function determines what to do with it + */ +// oxlint-disable-next-line @typescript-eslint/no-explicit-any +export type TraceRecorder = ( + operation: string, + handoff?: THandoff +) => THandoff; + +/** + * Check if the provided value is a Promise-like object by checking if it is an object and has a "then" method that + * is a function. Slightly more robust than just checking for instanceof Promise, as it can handle cases where the promise is + * from a different realm. + * @param value The value to be checked + * @returns True if the value is Promise-like, false otherwise + */ +function isPromiseLike(value: unknown): value is Promise { + return ( + typeof value === "object" && + value !== null && + typeof (value as Promise).then === "function" + ); +} + +/** simple identity function */ +export function nullPassthrough(value: T): T { + return value; +} + +/** no-op function matching the recordTime signature */ +export function nullRecordTime(_tag: string, _duration?: number): void { + // intentionally empty +} + +/** + * Empty trace implementation that just calls the functions with the specified arguments and returns the result. + * @param _tag The tag for the trace event (ignored in this implementation) + * @param fn The function to be called + * @param args The arguments to be passed to the function + * @returns The result of the function call + */ +export function nullTrace( + _tag: unknown, + fn: TFunc, + ...args: Parameters +): ReturnType { + return callFunction(fn, args); +} + +/** + * Create a trace function that will call the provided recorder with the tag and duration of each trace event. The + * recorder will be called twice for each trace event: + * - once before the function is called (with no duration) + * - once after (with the duration in milliseconds). + * + * The trace function can handle both synchronous and asynchronous functions, and will measure the duration accordingly. It + * will not incur promise overhead for synchronous functions and operate based on the value returned. + * + * NOTE: + * To avoid exception overhead this implementation does not catch exceptions thrown by the traced function. On process end + * the error count can be inferred by the difference between start and end usage. + * + * @param record The recorder function to be called with trace information + * @returns A trace function that can be used to wrap any function and record its execution time + */ +export function createTrace(record: TraceRecorder): TraceFunction { + return ( + tag: string, + fn: TFunc, + ...args: Parameters + ): ReturnType => { + const handoff = record(tag); + const result = callFunction(fn, args); + if (isPromiseLike(result)) { + return result.then((res: Awaited>) => { + record(tag, handoff); + return res; + }); + } + record(tag, handoff); + return result; + }; +} + +/** + * Helper to call a function with variable arguments avoiding the overhead of apply for common cases where + * there are a low number of arguments. Generally the collection of arguments in parameters is highly optimized + * in modern JS engines. The fn(...args) is the more expensive operation. + * @param fn The function to be called + * @param args The arguments to be passed to the function + * @returns The result of the function call + */ +function callFunction( + fn: TFunc, + args: Parameters +): ReturnType { + // avoid the apply/iteration overhead for common cases of low number of arguments + switch (args.length) { + case 0: + return fn(); + case 1: + return fn(args[0]); + case 2: + return fn(args[0], args[1]); + case 3: + return fn(args[0], args[1], args[2]); + default: + return fn(...args); + } +} diff --git a/incubator/tools-performance/src/tracker.ts b/incubator/tools-performance/src/tracker.ts new file mode 100644 index 000000000..e45ecb229 --- /dev/null +++ b/incubator/tools-performance/src/tracker.ts @@ -0,0 +1,221 @@ +import { styleText } from "node:util"; +import { PerfDomain } from "./domain.ts"; +import { type ColumnOptions, formatAsTable } from "./table.ts"; +import type { + EventFrequency, + PerfDomainOptions, + PerformanceOptions, + PerfReportColumn, +} from "./types.ts"; + +type OperationData = { + name: string; + session: number; + calls: number; + completions: number; + total: number; +}; + +const ENABLE_ALL = Symbol("enabled"); + +/** + * Performance manager that tracks the duration of operations across one or more categories. + * Categories must be enabled before tracking begins. Provides trace and record functions + * per category, and reports aggregated results at process exit or on demand. + */ +export class PerfTracker { + static startTime = performance.now(); + static exitHandlers?: Set<() => void> = undefined; + + static addExitHandler(callback: () => void) { + if (!this.exitHandlers) { + const exitHandlers = (this.exitHandlers = new Set<() => void>()); + process.on("exit", () => { + for (const cb of exitHandlers) { + cb(); + } + }); + } + this.exitHandlers.add(callback); + } + + static removeExitHandler(callback: () => void) { + this.exitHandlers?.delete(callback); + } + + private timings = new Map(); + private enabled = new Set(); + private config: PerformanceOptions; + private domains: Record = {}; + private onExit: (() => void) | undefined; + + constructor(config: PerformanceOptions = {}) { + this.config = { ...config }; + this.config.enable ??= true; + this.enable(this.config.enable); + + this.onExit = () => this.finish(true); + PerfTracker.addExitHandler(this.onExit); + } + + updateConfig(newConfig: PerformanceOptions) { + Object.assign(this.config, newConfig); + if (newConfig.enable) { + this.enable(newConfig.enable); + } + } + + enable(domain: true | string | string[]) { + if (domain === true) { + this.enabled.add(ENABLE_ALL); + } else if (typeof domain === "string") { + this.enabled.add(domain); + } else if (Array.isArray(domain)) { + for (const cat of domain) { + this.enabled.add(cat); + } + } else { + throw new Error(`invalid domain: ${domain}`); + } + } + + isEnabled(domain: string, frequency?: EventFrequency): boolean { + if (this.enabled.has(ENABLE_ALL) || this.enabled.has(domain)) { + const existing = this.domains[domain]; + return existing + ? existing.enabled(frequency) + : PerfDomain.frequencyEnabled(frequency, this.config.frequency); + } + return false; + } + + private recordTime = (tag: string, duration?: number) => { + const timings = this.timings; + const current = timings.get(tag); + const entry = current ?? { + name: tag, + session: performance.now() - PerfTracker.startTime, + calls: 0, + completions: 0, + total: 0, + }; + if (duration != null) { + entry.completions++; + entry.total += duration; + } else { + entry.calls++; + } + if (!current) { + timings.set(tag, entry); + } + }; + + private getDomainOptions(): PerfDomainOptions { + const { strategy, frequency, waitOnStart } = this.config; + return strategy === "timing" + ? { recordTime: this.recordTime, frequency, waitOnStart } + : { frequency, waitOnStart }; + } + + domain(name: string): PerfDomain | undefined { + if (this.enabled.has(ENABLE_ALL) || this.enabled.has(name)) { + return (this.domains[name] ??= new PerfDomain( + name, + this.getDomainOptions() + )); + } + return undefined; + } + + finish(processExit = false) { + if (this.onExit) { + for (const domain of Object.values(this.domains)) { + domain.stop(processExit); + } + if (this.timings.size > 0) { + this.report(); + } + if (!processExit) { + PerfTracker.removeExitHandler(this.onExit); + } + this.onExit = undefined; + } + } + + private report() { + const config = this.config; + const { reportColumns, reportSort, showIndex, maxNameWidth } = config; + const cols = reportColumns ?? ["name", "calls", "total", "avg"]; + // configure the column configs + const columnConfigs = cols.map((col) => ({ + ...(COL_OPTIONS[col] ?? { label: col }), + })); + if (maxNameWidth && cols.includes("name")) { + columnConfigs[cols.indexOf("name")].maxWidth = maxNameWidth; + } + // filter the sort to include columns in the report + const sort: number[] = []; + if (reportSort && reportSort.length > 0) { + for (const sortCol of reportSort) { + const index = cols.indexOf(sortCol); + if (index >= 0) { + sort.push(index); + } + } + } + // maps the column keys to entries + const rows = Array.from(this.timings.values()).map( + (entry: OperationData) => { + return cols.map((col: PerfReportColumn) => getCellValue(entry, col)); + } + ); + const reportTo = config.reportHandler ?? console.log; + reportTo(formatAsTable(rows, { columns: columnConfigs, sort, showIndex })); + } +} + +type SyntheticColumns = Exclude; +type GetColumnValue = (entry: OperationData) => T; + +const SYNTHETIC_VALUES: Record> = { + avg: (entry) => (entry.completions > 0 ? entry.total / entry.completions : 0), + errors: (entry) => entry.calls - entry.completions, +}; + +function getCellValue( + entry: OperationData, + column: PerfReportColumn +): string | number { + if (column in entry) { + return entry[column as keyof OperationData]; + } else if (column in SYNTHETIC_VALUES) { + return SYNTHETIC_VALUES[column as SyntheticColumns](entry); + } + return ""; +} + +const NUM_COL_OPTIONS: ColumnOptions = { + digits: 0, + style: "green", + localeFmt: true, + align: "right", +}; + +function styleName(name: string): string { + const firstColon = name.indexOf(":"); + if (firstColon === -1) { + return styleText("cyan", name); + } + const prefix = name.substring(0, firstColon); + const op = name.substring(firstColon + 1); + return `${styleText("blue", prefix)}:${styleText("cyan", op)}`; +} + +const COL_OPTIONS: Record = { + name: { label: "operation", align: "left", style: styleName, maxWidth: 50 }, + session: { label: "session", ...NUM_COL_OPTIONS }, + calls: { label: "calls", ...NUM_COL_OPTIONS }, + total: { label: "total", ...NUM_COL_OPTIONS }, + avg: { label: "avg", ...NUM_COL_OPTIONS }, + errors: { label: "errors", ...NUM_COL_OPTIONS }, +}; diff --git a/incubator/tools-performance/src/types.ts b/incubator/tools-performance/src/types.ts new file mode 100644 index 000000000..2fb045f4e --- /dev/null +++ b/incubator/tools-performance/src/types.ts @@ -0,0 +1,98 @@ +/** Call frequency level for categorizing operations */ +export type EventFrequency = "low" | "medium" | "high"; + +/** Tracing strategy for recording performance events, either in memory timings or node performance marks */ +export type TraceStrategy = "timing" | "node"; + +/** Report columns available for performance reporting */ +export type PerfReportColumn = + | "session" // session time for the first call of this operation, default sort + | "name" // the name of the operation, in the format of "domain: operation" + | "calls" // how many times this operation was started + | "total" // total time spent in this operation across all calls + | "avg" // average time per call for this operation + | "errors"; // inferred error count based on how many calls failed to complete + +/** + * Areas of performance tracking. This is not an exhaustive list, but a starting point for categorizing different types of operations. + * Custom areas can be added as needed by using arbitrary strings. + */ +export type PerfArea = + | "metro" + | "resolve" + | "transform" + | "serialize" + | (string & {}); + +/** Acceptance signature for functions, use of any is required here for voids and empty functions */ +// oxlint-disable-next-line @typescript-eslint/no-explicit-any +export type AcceptAnyFn = (...args: any[]) => any; + +/** + * Signature for a trace function that will record information about a call and its duration. This can trace both sync + * and async functions and behave according to the function passed in. Can be used in various ways: + * - with closure, return type of trace is the return type of the closure + * trace("myFunction", () => myFunction(arg1, arg2)); + * + * - without closure, return type is from myFunction, parameters types will be enforced based on myFunction's signature + * trace("myFunction", myFunction, arg1, arg2); + * + * @template TFunc the type of function being traced, used to infer parameter and return types + * @param tag a string identifier for this trace event + * @param fn the function being traced, can be passed with or without a closure + * @returns the result of the function call, with the correct type inferred from the function being traced + */ +export type TraceFunction = ( + tag: string, + fn: TFunc, + ...args: Parameters +) => ReturnType; + +/** + * Options for configuration a performance domain, which is a logical grouping of performance events. + */ +export type PerfDomainOptions = { + frequency?: EventFrequency; + waitOnStart?: boolean; + recordTime?: (tag: string, durationMs?: number) => void; +}; + +/** + * Options for performance tracking on a broader level + */ +export type PerformanceOptions = Omit & { + /** + * What performance areas to enable tracking for. This defaults to true, which will enable all tracking. + */ + enable?: true | PerfArea | PerfArea[]; + + /** + * Tracing strategy to use for recording performance events. Either in-memory timings or node performance marks. + * + * "timing" + * - records times in memory. Lower overhead and suitable for high frequency events. + * - will output to the console on process exit by default + * "node" + * - uses node's performance.mark and performance.measure APIs to record events. + * - Higher overhead, but allows for integration with node's performance tools. + */ + strategy?: TraceStrategy; + + /** + * Set of columns to include in the performance report and their order. + * Defaults to ["name", "calls", "total", "avg", "errors"]. + */ + reportColumns?: PerfReportColumn[]; + + /** Columns to sort on, must be part of reportColumns */ + reportSort?: PerfReportColumn[]; + + /** Show the index column in the report */ + showIndex?: boolean; + + /** Max width of the name column to keep things readable */ + maxNameWidth?: number; + + /** Optional function that receives the report string. Defaults to console.log */ + reportHandler?: (report: string) => void; +}; diff --git a/incubator/tools-performance/test/perf.test.ts b/incubator/tools-performance/test/perf.test.ts new file mode 100644 index 000000000..1ac87c8b1 --- /dev/null +++ b/incubator/tools-performance/test/perf.test.ts @@ -0,0 +1,70 @@ +import { equal, ok } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + getDomain, + getTrace, + isTrackingEnabled, + reportPerfData, + trackPerformance, +} from "../src/perf.ts"; +import { nullTrace } from "../src/trace.ts"; + +// The module-level API uses a singleton, so we need to be aware that +// state carries across tests within the same module. Tests here are +// ordered to account for the additive nature of trackPerformance. + +describe("module-level perf API", () => { + it("getTrace returns nullTrace before enabling", () => { + // Before any trackPerformance call, there is no manager + // Note: if a prior test already called trackPerformance, this test + // will see the singleton. We test the cold-start path here. + const trace = getTrace("unregistered-domain"); + equal(trace, nullTrace); + }); + + it("trackPerformance enables tracking", () => { + trackPerformance({ enable: true, strategy: "timing" }); + equal(isTrackingEnabled("metro"), true); + }); + + it("getTrace returns a working trace function after enabling", () => { + trackPerformance({ enable: true, strategy: "timing" }); + const trace = getTrace("metro"); + ok(trace !== nullTrace); + const result = trace("add", (a: number, b: number) => a + b, 1, 2); + equal(result, 3); + }); + + it("trackPerformance enables a specific category", () => { + trackPerformance({ enable: "resolve", strategy: "timing" }); + equal(isTrackingEnabled("resolve"), true); + }); + + it("getDomain returns a domain when enabled", () => { + trackPerformance({ enable: "transform", strategy: "timing" }); + const domain = getDomain("transform"); + ok(domain !== undefined); + equal(domain!.name, "transform"); + }); + + it("getDomain returns a domain for any name when globally enabled", () => { + // trackPerformance with enable:true was called above, so all domains are accessible + const domain = getDomain("any-domain-name"); + ok(domain !== undefined); + }); + + it("isTraceEnabled checks frequency", () => { + trackPerformance({ enable: "freq-test", strategy: "timing" }); + const domain = getDomain("freq-test"); + ok(domain !== undefined); + // default frequency is "medium" + equal(isTrackingEnabled("freq-test", "low"), true); + equal(isTrackingEnabled("freq-test", "medium"), true); + equal(isTrackingEnabled("freq-test", "high"), false); + }); + + it("reportPerfData does not throw", () => { + // Just verify it doesn't throw — the singleton may or may not have data + reportPerfData(); + }); +}); diff --git a/incubator/tools-performance/test/scripts.mjs b/incubator/tools-performance/test/scripts.mjs new file mode 100644 index 000000000..b548e7320 --- /dev/null +++ b/incubator/tools-performance/test/scripts.mjs @@ -0,0 +1,42 @@ +import { + trackPerformance, + getTrace, + getDomain, + reportPerfData, +} from "../lib/index.js"; + +trackPerformance({ enable: true, strategy: "timing" }); + +async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const trace = getTrace("cat1"); +const trace2 = getTrace("category2"); + +async function main() { + console.log("Starting performance test..."); + const throwError = false; + + // Use a domain's trace for direct recording + const domain = getDomain("cat1"); + const domainTrace = domain.getTrace(); + domainTrace("direct-op", () => 42); + + await trace("sleep well", sleep, 15); + await trace2("sleep well too", sleep, 20); + await trace("sleep well again", sleep, 10); + await trace2("sleep well too", sleep, 5); + try { + trace("maybe throw error", () => { + if (throwError) { + throw new Error("oops"); + } + }); + } catch { + // do nothing + } + reportPerfData(); +} + +await main(); diff --git a/incubator/tools-performance/test/table.test.ts b/incubator/tools-performance/test/table.test.ts new file mode 100644 index 000000000..4c5b1b3b0 --- /dev/null +++ b/incubator/tools-performance/test/table.test.ts @@ -0,0 +1,180 @@ +import { equal, ok } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { formatAsTable } from "../src/table.ts"; + +describe("formatAsTable", () => { + it("returns empty string for empty data", () => { + const result = formatAsTable([]); + equal(result, ""); + }); + + it("renders a table with header and data rows", () => { + const data = [ + ["a", "b"], + ["cc", "dd"], + ]; + const result = formatAsTable(data, { + columns: ["col1", "col2"], + noColors: true, + }); + const lines = result.split("\n").filter(Boolean); + + // top border, header, mid border, 2 data rows, bottom border + equal(lines.length, 6); + ok(lines[0]!.startsWith("┌")); + ok(lines[1]!.includes("col1")); + ok(lines[1]!.includes("col2")); + ok(lines[2]!.startsWith("├")); + ok(lines[3]!.includes("a")); + ok(lines[4]!.includes("cc")); + ok(lines[5]!.startsWith("└")); + }); + + it("pads columns to the widest value", () => { + const data = [ + ["short", "1"], + ["very long name", "2"], + ]; + const result = formatAsTable(data, { + columns: ["name", "value"], + noColors: true, + }); + const lines = result.split("\n").filter(Boolean); + + // all row-separator lines should have the same length + const topLen = lines[0]!.length; + const midLen = lines[2]!.length; + const botLen = lines[5]!.length; + equal(topLen, midLen); + equal(midLen, botLen); + + // all data lines should also have the same length + equal(lines[1]!.length, lines[3]!.length); + equal(lines[3]!.length, lines[4]!.length); + }); + + it("respects column alignment options", () => { + const data = [["op", 1]]; + const result = formatAsTable(data, { + columns: [ + { label: "name", align: "left" }, + { label: "count", align: "right" }, + ], + noColors: true, + }); + const lines = result.split("\n").filter(Boolean); + // first data row should have "op" left-aligned + ok(lines[3]!.includes("│ op")); + }); + + it("left-aligns columns by default", () => { + const data = [["op", "val"]]; + const result = formatAsTable(data, { + columns: ["name", "value"], + noColors: true, + }); + const lines = result.split("\n").filter(Boolean); + ok(lines[3]!.includes("│ op")); + }); + + it("sorts by a single column index", () => { + const data = [ + ["beta", 200], + ["alpha", 100], + ]; + const result = formatAsTable(data, { + columns: ["name", "total"], + sort: [0], + noColors: true, + }); + const alphaIndex = result.indexOf("alpha"); + const betaIndex = result.indexOf("beta"); + ok(alphaIndex < betaIndex, "alpha should appear before beta"); + }); + + it("sorts by multiple column indices in precedence order", () => { + const data = [ + ["xx", 200], + ["xx", 100], + ["aa", 300], + ]; + const result = formatAsTable(data, { + columns: ["area", "total"], + sort: [0, 1], + noColors: true, + }); + const lines = result + .split("\n") + .filter((l) => l.startsWith("│") && !l.includes("area")); + ok(lines[0]!.includes("aa"), "area 'aa' should sort first"); + }); + + it("does not mutate the input array when sorting", () => { + const data = [ + ["beta", 2], + ["alpha", 1], + ]; + const originalFirst = data[0]!; + formatAsTable(data, { + columns: ["name", "val"], + sort: [0], + noColors: true, + }); + equal(data[0], originalFirst, "input array should not be reordered"); + }); + + it("supports showIndex option", () => { + const data = [ + ["a", "b"], + ["c", "d"], + ]; + const result = formatAsTable(data, { + columns: ["col1", "col2"], + showIndex: true, + noColors: true, + }); + ok(result.includes("1"), "should show row index 1"); + ok(result.includes("2"), "should show row index 2"); + }); + + it("formats numbers with digits option", () => { + const data = [[1.23456]]; + const result = formatAsTable(data, { + columns: [{ label: "value", digits: 2 }], + noColors: true, + }); + ok(result.includes("1.23")); + }); + + it("formats numbers with locale formatting", () => { + const data = [[1234567]]; + const result = formatAsTable(data, { + columns: [{ label: "value", digits: 0, localeFmt: true }], + noColors: true, + }); + // locale formatted number should have some separator (e.g. comma) + ok( + result.includes("1,234,567") || result.includes("1.234.567"), + "should have locale separator" + ); + }); + + it("truncates values exceeding maxWidth", () => { + const data = [["this-is-a-very-long-value-that-should-be-truncated"]]; + const result = formatAsTable(data, { + columns: [{ label: "name", maxWidth: 15 }], + noColors: true, + }); + ok(result.includes("..."), "should truncate with ellipsis"); + }); + + it("correctly measures width of ANSI-styled text", () => { + const data = [["\x1b[92mhello\x1b[39m"]]; // "hello" in green + const result = formatAsTable(data, { columns: ["name"] }); + // Column width should be based on visible "hello" (5 chars), not the full ANSI string + // "name" is 4 chars, "hello" is 5 chars, so max width = 5 + const lines = result.split("\n").filter(Boolean); + const topLine = lines[0]!; + ok(topLine.includes("───────"), "width should be based on visible text"); + }); +}); diff --git a/incubator/tools-performance/test/trace.test.ts b/incubator/tools-performance/test/trace.test.ts new file mode 100644 index 000000000..493530c2c --- /dev/null +++ b/incubator/tools-performance/test/trace.test.ts @@ -0,0 +1,109 @@ +import { equal, rejects } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createTrace, nullTrace } from "../src/trace.ts"; + +describe("nullTrace", () => { + it("calls the function and returns its result", () => { + const result = nullTrace("tag", (a: number, b: number) => a + b, 2, 3); + equal(result, 5); + }); + + it("passes through async results", async () => { + const result = await nullTrace( + "tag", + (x: string) => Promise.resolve(x.toUpperCase()), + "hello" + ); + equal(result, "HELLO"); + }); +}); + +describe("createTrace", () => { + it("records a before call and an after call", () => { + const calls: { op: string; handoff?: number }[] = []; + const record = (op: string, handoff?: number) => { + calls.push({ op, handoff }); + return calls.length; + }; + const trace = createTrace(record); + + trace("op", () => 42); + + equal(calls.length, 2); + equal(calls[0]!.op, "op"); + equal(calls[0]!.handoff, undefined); + equal(calls[1]!.op, "op"); + equal(calls[1]!.handoff, 1); // handoff from the first call + }); + + it("returns the sync function result", () => { + const trace = createTrace(() => undefined); + const result = trace("op", (a: number, b: number) => a * b, 6, 7); + equal(result, 42); + }); + + it("passes arguments to the traced function", () => { + const trace = createTrace(() => undefined); + const result = trace( + "concat", + (a: string, b: string) => `${a}-${b}`, + "foo", + "bar" + ); + equal(result, "foo-bar"); + }); + + it("handles async functions and records after resolution", async () => { + const calls: { op: string; handoff?: string }[] = []; + const record = (op: string, handoff?: string) => { + calls.push({ op, handoff }); + return `mark-${calls.length}`; + }; + const trace = createTrace(record); + + const result = await trace( + "async-op", + () => + new Promise((resolve) => setTimeout(() => resolve("done"), 10)) + ); + + equal(result, "done"); + equal(calls.length, 2); + equal(calls[0]!.handoff, undefined); + equal(calls[1]!.handoff, "mark-1"); + }); + + it("does not record end when sync function throws", () => { + const calls: string[] = []; + const trace = createTrace((op: string) => { + calls.push(op); + return undefined; + }); + + let threw = false; + try { + trace("fail", () => { + throw new Error("boom"); + }); + } catch { + threw = true; + } + + equal(threw, true); + equal(calls.length, 1, "only the before-call should be recorded"); + }); + + it("does not record end when async function rejects", async () => { + const calls: string[] = []; + const trace = createTrace((op: string) => { + calls.push(op); + return undefined; + }); + + await rejects( + trace("fail-async", () => Promise.reject(new Error("async boom"))) + ); + + equal(calls.length, 1, "only the before-call should be recorded"); + }); +}); diff --git a/incubator/tools-performance/test/tracker.test.ts b/incubator/tools-performance/test/tracker.test.ts new file mode 100644 index 000000000..0e8ec8225 --- /dev/null +++ b/incubator/tools-performance/test/tracker.test.ts @@ -0,0 +1,275 @@ +import { equal, ok } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { PerfTracker } from "../src/tracker.ts"; + +function noopHandler() { + // intentionally empty — suppresses report output for tests +} + +// Helper: suppress report output for tests that don't care about it +const quiet = { reportHandler: noopHandler, strategy: "timing" as const }; + +describe("PerfTracker", () => { + describe("enable / isEnabled", () => { + it("enables all tracking with true via constructor", () => { + const tracker = new PerfTracker(quiet); + equal(tracker.isEnabled("metro"), true); + tracker.finish(); + }); + + it("enables a single category", () => { + const tracker = new PerfTracker({ ...quiet, enable: "metro" }); + equal(tracker.isEnabled("metro"), true); + equal(tracker.isEnabled("resolve"), false); + tracker.finish(); + }); + + it("enables multiple categories from an array", () => { + const tracker = new PerfTracker({ + ...quiet, + enable: ["metro", "resolve"], + }); + equal(tracker.isEnabled("metro"), true); + equal(tracker.isEnabled("resolve"), true); + equal(tracker.isEnabled("transform"), false); + tracker.finish(); + }); + + it("is additive across multiple enable calls", () => { + const tracker = new PerfTracker({ ...quiet, enable: "metro" }); + tracker.enable("resolve"); + equal(tracker.isEnabled("metro"), true); + equal(tracker.isEnabled("resolve"), true); + tracker.finish(); + }); + + it("global enable makes all specific domains enabled", () => { + const tracker = new PerfTracker({ ...quiet, enable: true }); + equal(tracker.isEnabled("metro"), true); + equal(tracker.isEnabled("anything"), true); + tracker.finish(); + }); + }); + + describe("domain", () => { + it("returns a domain when the category is enabled", () => { + const tracker = new PerfTracker({ ...quiet, enable: "metro" }); + const domain = tracker.domain("metro"); + ok(domain !== undefined); + equal(domain!.name, "metro"); + tracker.finish(); + }); + + it("returns undefined when the category is not enabled", () => { + const tracker = new PerfTracker({ ...quiet, enable: "metro" }); + const domain = tracker.domain("resolve"); + equal(domain, undefined); + tracker.finish(); + }); + + it("returns the same domain on repeated calls", () => { + const tracker = new PerfTracker({ ...quiet, enable: "metro" }); + const d1 = tracker.domain("metro"); + const d2 = tracker.domain("metro"); + equal(d1, d2); + tracker.finish(); + }); + + it("returns a domain for any name when globally enabled", () => { + const tracker = new PerfTracker({ ...quiet, enable: true }); + ok(tracker.domain("anything") !== undefined); + tracker.finish(); + }); + }); + + describe("recording via timing strategy", () => { + it("tracks calls and completions through trace", () => { + let report = ""; + const tracker = new PerfTracker({ + ...quiet, + enable: true, + reportHandler: (r) => (report = r), + }); + const domain = tracker.domain("test")!; + const trace = domain.getTrace(); + + trace("op", () => 42); + + tracker.finish(); + ok(report.includes("op")); + }); + + it("traces async functions correctly", async () => { + let report = ""; + const tracker = new PerfTracker({ + ...quiet, + enable: true, + reportHandler: (r) => (report = r), + }); + const domain = tracker.domain("test")!; + const trace = domain.getTrace(); + + const result = await trace( + "delayed", + () => + new Promise((resolve) => setTimeout(() => resolve(42), 10)) + ); + + equal(result, 42); + tracker.finish(); + ok(report.includes("delayed")); + }); + + it("counts errors when traced function throws", () => { + let report = ""; + const tracker = new PerfTracker({ + ...quiet, + enable: true, + reportColumns: ["name", "calls", "errors"], + reportHandler: (r) => (report = r), + }); + const domain = tracker.domain("test")!; + const trace = domain.getTrace(); + + try { + trace("fail", () => { + throw new Error("boom"); + }); + } catch { + // expected + } + + tracker.finish(); + ok(report.includes("fail")); + }); + }); + + describe("finish", () => { + it("reports when there is data", () => { + let report = ""; + const tracker = new PerfTracker({ + ...quiet, + enable: true, + reportHandler: (r) => (report = r), + }); + const domain = tracker.domain("test")!; + domain.getTrace()("op", () => 42); + + tracker.finish(); + ok(report.length > 0); + }); + + it("does not report when there is no data", () => { + let reportCalled = false; + const tracker = new PerfTracker({ + ...quiet, + enable: true, + reportHandler: () => (reportCalled = true), + }); + + tracker.finish(); + equal(reportCalled, false); + }); + + it("only reports once even if called multiple times", () => { + let callCount = 0; + const tracker = new PerfTracker({ + ...quiet, + enable: true, + reportHandler: () => callCount++, + }); + const domain = tracker.domain("test")!; + domain.getTrace()("op", () => 1); + + tracker.finish(); + tracker.finish(); + tracker.finish(); + + equal(callCount, 1); + }); + }); + + describe("updateConfig", () => { + it("merges new config values", () => { + let report = ""; + const tracker = new PerfTracker({ + ...quiet, + enable: true, + reportColumns: ["name", "total"], + reportHandler: (r) => (report = r), + }); + const domain = tracker.domain("test")!; + domain.getTrace()("op", () => 1); + + tracker.updateConfig({ reportColumns: ["name", "calls"] }); + tracker.finish(); + + ok(report.includes("operation")); + ok(report.includes("calls")); + ok(!report.includes("total")); + }); + + it("adds new enabled domains via updateConfig", () => { + const tracker = new PerfTracker({ ...quiet, enable: "metro" }); + equal(tracker.isEnabled("resolve"), false); + + tracker.updateConfig({ enable: "resolve" }); + equal(tracker.isEnabled("resolve"), true); + equal(tracker.isEnabled("metro"), true); + tracker.finish(); + }); + }); + + describe("report columns", () => { + it("uses default columns when none specified", () => { + let report = ""; + const tracker = new PerfTracker({ + ...quiet, + enable: true, + reportHandler: (r) => (report = r), + }); + const domain = tracker.domain("test")!; + domain.getTrace()("op", () => 1); + tracker.finish(); + + ok(report.includes("operation")); + ok(report.includes("calls")); + ok(report.includes("total")); + ok(report.includes("avg")); + }); + + it("respects custom reportColumns", () => { + let report = ""; + const tracker = new PerfTracker({ + ...quiet, + enable: true, + reportColumns: ["name", "total"], + reportHandler: (r) => (report = r), + }); + const domain = tracker.domain("test")!; + domain.getTrace()("op", () => 1); + tracker.finish(); + + ok(report.includes("operation")); + ok(report.includes("total")); + ok(!report.includes("calls")); + ok(!report.includes("avg")); + }); + + it("respects maxNameWidth", () => { + let report = ""; + const tracker = new PerfTracker({ + ...quiet, + enable: true, + maxNameWidth: 10, + reportColumns: ["name"], + reportHandler: (r) => (report = r), + }); + const domain = tracker.domain("test")!; + domain.getTrace()("a-very-long-operation-name-here", () => 1); + tracker.finish(); + + ok(report.includes("..."), "should truncate long names"); + }); + }); +}); diff --git a/incubator/tools-performance/tsconfig.json b/incubator/tools-performance/tsconfig.json new file mode 100644 index 000000000..a23b858b9 --- /dev/null +++ b/incubator/tools-performance/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@rnx-kit/tsconfig/tsconfig.nodenext.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/yarn.lock b/yarn.lock index 11c75d608..c72fc577c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6142,6 +6142,15 @@ __metadata: languageName: unknown linkType: soft +"@rnx-kit/tools-performance@workspace:incubator/tools-performance": + version: 0.0.0-use.local + resolution: "@rnx-kit/tools-performance@workspace:incubator/tools-performance" + dependencies: + "@rnx-kit/scripts": "npm:*" + "@rnx-kit/tsconfig": "npm:*" + languageName: unknown + linkType: soft + "@rnx-kit/tools-react-native@npm:^2.1.0, @rnx-kit/tools-react-native@npm:^2.3.0, @rnx-kit/tools-react-native@npm:^2.3.1, @rnx-kit/tools-react-native@npm:^2.3.2, @rnx-kit/tools-react-native@npm:^2.3.3, @rnx-kit/tools-react-native@npm:^2.3.5, @rnx-kit/tools-react-native@workspace:packages/tools-react-native": version: 0.0.0-use.local resolution: "@rnx-kit/tools-react-native@workspace:packages/tools-react-native"