diff --git a/.changeset/clean-hotspot-attribution.md b/.changeset/clean-hotspot-attribution.md new file mode 100644 index 0000000..8c63868 --- /dev/null +++ b/.changeset/clean-hotspot-attribution.md @@ -0,0 +1,9 @@ +--- +"@lanterna-profiler/cli": minor +"@lanterna-profiler/core": minor +"@lanterna-profiler/detectors": minor +--- + +Improve hotspot attribution diagnostics and agent report readability. + +Lanterna now preserves anonymous user-code wrapper frames as actionable attribution clues, carries richer user-caller evidence through memory, async, GC, and CPU findings, and clarifies internal hotspot attribution naming without changing the public CLI contract. diff --git a/biome.json b/biome.json index d14f52f..a0a676f 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/docs/reading-a-report.md b/docs/reading-a-report.md index ebc37c6..f665ee2 100644 --- a/docs/reading-a-report.md +++ b/docs/reading-a-report.md @@ -101,6 +101,14 @@ When `measurementBasis === "histogram"`, `correlatedHotspots[]` is based on over Fires only when a function is **both** hot in the CPU profile **and** repeatedly deoptimised under `--deep`. Focus on stabilising shapes and types, then reprofile. One-off deopt entries are noise. +#### Memory trend findings and `correlatedAllocator` + +`memory-growth:*` and `external-buffer-pressure` are process-level heuristics, so `evidence.file` stays on `process.memoryUsage`. When available, `evidence.extra.correlatedAllocator` is the allocator lead to inspect first; prefer its `userCaller` when present. `basis: heap-sampled-allocator` comes from the V8 heap sampler, while `basis: cpu-top-user-hotspot` is used for off-heap pressure when a CPU profile was captured. + +#### Async findings and `userCaller` + +Async findings can expose `evidence.extra.userCaller` from async init stacks or CPU-window attribution. For `hot-async-context:`, `evidence.*` remains the hot execution frame, while `evidence.extra.entryFrame` names the async entry point that drove the chain. + #### `node-modules-hotspot:` A dependency hotspot is often a symptom — your code controls when and how often the dependency runs. Inspect the caller path, reduce input size or call frequency, and only then decide whether the dependency itself needs replacing. diff --git a/docs/report-schema.md b/docs/report-schema.md index ef4789f..70bcb7b 100644 --- a/docs/report-schema.md +++ b/docs/report-schema.md @@ -103,7 +103,7 @@ When present, prefer `source.file:source.line` for human diagnosis and patching, `source?: SourceLocation` can appear on CPU hotspots, hot-stack frames and anchors, memory allocators and memory summaries, async frame-bearing entries, deopts, and `findings[].evidence`. -`userCaller?: UserCallerAttribution` can appear when the visible cost is outside user code but Lanterna can identify the nearest user frame that led there. It contains `function`, `file`, `line`, optional `column`/`source`/`stackDistance`, `profilePct`, `supportPct`, `confidence` (`low`/`medium`/`high`), and `basis` (`cpu-sample-path`, `heap-sample-path`, `async-stack`, or `async-cpu-window`). `stackDistance: 1` means the closest user frame to the external callee. Attributed findings may also expose `evidence.extra.candidateCallers[]`, ordered by proximity first and support second. Treat low-confidence attribution as an inspection lead, not automatically as the line to patch. +`userCaller?: UserCallerAttribution` can appear when Lanterna can identify the user frame that explains a finding. It contains `function`, `file`, `line`, optional `column`/`source`/`stackDistance`, `profilePct`, `supportPct`, `confidence` (`low`/`medium`/`high`), and `basis` (`cpu-sample-path`, `heap-sample-path`, `async-stack`, or `async-cpu-window`). `stackDistance: 1` means the closest user frame to an external callee; `stackDistance: 0` means the sampled user frame itself is the fix location. Attributed findings may also expose `evidence.extra.candidateCallers[]`, ordered by proximity first and support second. Treat low-confidence attribution as an inspection lead, not automatically as the line to patch. ## `profiles.cpu` @@ -126,7 +126,7 @@ Detail: [kinds/cpu.md](./kinds/cpu.md). | --- | --- | | `summary` | Total sampled bytes, top allocator, RSS / heapUsed / external / arrayBuffers stats (start/end/min/max/mean/p95) plus linear `slopeBytesPerSec`. | | `quality` | Memory confidence gate — `confidence`, `reasons[]`, `recommendations[]`. | -| `hotAllocators` | Frames ranked by `selfBytes` / `totalBytes`, with file/line, frame category, and optional `userCaller` for external allocators. | +| `hotAllocators` | Frames ranked by `selfBytes` / `totalBytes`, with file/line, frame category, and optional `userCaller`. | | `memoryUsage` | Compact `process.memoryUsage()` metadata (`sampleCount`, first/last sample). Raw samples present only with `--include-memory-samples`. | | `heapSnapshotAnalysis` | Optional start/end retained-growth summary when `--heap-snapshot-analysis` is enabled. Very large snapshots return `available: false` with a warning instead of being parsed unbounded. | @@ -160,6 +160,15 @@ Each finding has the same shape regardless of which kind produced it: Findings are sorted by `priority.score`, then severity, then attributed weight. +Common `evidence.extra` anchors: + +| Field | Meaning | +| --- | --- | +| `userCaller` | User-code caller or self frame that should usually be inspected before the callee/runtime frame. | +| `candidateCallers[]` | Alternative caller candidates for attributed CPU findings. | +| `correlatedAllocator` | Memory trend findings (`memory-growth:*`, `external-buffer-pressure`) use this to point from process-level growth back to an editable allocator lead. `basis` distinguishes heap-sampled allocators from CPU fallback attribution. | +| `entryFrame` | `hot-async-context:*` keeps the hot CPU frame in `evidence.*` and exposes the async chain entry point here. | + The full catalog of built-in findings, grouped by kind, is in [extending/detectors.md](./extending/detectors.md#built-in-findings). ## Schema versioning diff --git a/packages/cli/src/renderers/agent-renderer.ts b/packages/cli/src/renderers/agent-renderer.ts index 3f8c17f..1377ccb 100644 --- a/packages/cli/src/renderers/agent-renderer.ts +++ b/packages/cli/src/renderers/agent-renderer.ts @@ -26,6 +26,7 @@ type ReadTargetReason = | 'user-caller' | 'dependency-hotspot-caller' | 'runtime-hotspot-caller' + | 'correlated-allocator' | 'cpu-user-stack' | 'top-cpu-culprit' | 'top-cpu-hotspot' @@ -37,6 +38,7 @@ type ReadTargetReason = | 'top-async-hot-file-caller' | 'long-async-operation' | 'long-async-operation-caller' + | 'async-entry-frame' | 'async-hot-file' | 'async-hot-file-caller' | 'async-cpu-attribution-root' @@ -192,6 +194,16 @@ function appendFindingDetail( if (candidateCallers.length > 0) { lines.push(`- candidate_callers: ${candidateCallers.map(formatUserCallerCompact).join('; ')}`); } + const correlatedAllocator = correlatedAllocatorFromEvidenceExtra(finding.evidence.extra); + if (correlatedAllocator) { + lines.push( + `- correlated_allocator: ${frameLabel(correlatedAllocator)} at ${frameLocation(correlatedAllocator)}${basisSuffix(correlatedAllocator.basis)}${userCallerSuffix(correlatedAllocator.userCaller)}`, + ); + } + const entryFrame = entryFrameFromEvidenceExtra(finding.evidence.extra); + if (entryFrame) { + lines.push(`- entry_frame: ${frameLabel(entryFrame)} at ${frameLocation(entryFrame)}`); + } const userStack = cpuUserStackForFinding(finding, report); if (userStack) lines.push(`- user_stack: ${userStack}`); lines.push(`- observed: ${formatMeasurements(finding.measurements?.observed)}`); @@ -271,29 +283,34 @@ function appendCpuKindReview(lines: string[], report: LanternaReport): void { appendIndentedTable( lines, ['#', 'function', 'location', 'self%', 'total%', 'user_caller'], - hotspots.map((h, i) => [ - String(i + 1), - h.function ?? '—', - frameLocation(h), - formatPct(h.selfPct), - formatPct(h.totalPct), - userCallerCell(h.userCaller), + hotspots.map((hotspot, hotspotIndex) => [ + String(hotspotIndex + 1), + hotspot.function ?? '—', + frameLocation(hotspot), + formatPct(hotspot.selfPct), + formatPct(hotspot.totalPct), + userCallerCell(hotspot.userCaller), ]), ); } const stacks = (cpu.hotStacks ?? []).slice(0, 3); - const stackRows = stacks.flatMap((stack, i) => { - const frame = - stack.frames.find((f) => Boolean(f.source) && isRenderableReviewFrame(f)) ?? + const hotStackRows = stacks.flatMap((stack, stackIndex) => { + const stackAnchorFrame = + stack.frames.find((frame) => Boolean(frame.source) && isRenderableReviewFrame(frame)) ?? stack.frames.find(isRenderableReviewFrame); - if (!frame) return []; + if (!stackAnchorFrame) return []; return [ - [String(i + 1), frame.function ?? '—', frameLocation(frame), formatPct(stack.weightPct)], + [ + String(stackIndex + 1), + stackAnchorFrame.function ?? '—', + frameLocation(stackAnchorFrame), + formatPct(stack.weightPct), + ], ]; }); - if (stackRows.length > 0) { + if (hotStackRows.length > 0) { lines.push('- hot_stacks:'); - appendIndentedTable(lines, ['#', 'anchor', 'location', 'weight%'], stackRows); + appendIndentedTable(lines, ['#', 'anchor', 'location', 'weight%'], hotStackRows); } const clusters = (cpu.hotStackClusters ?? []) .filter((cluster) => isRenderableReviewFrame(cluster.anchor)) @@ -303,8 +320,8 @@ function appendCpuKindReview(lines: string[], report: LanternaReport): void { appendIndentedTable( lines, ['#', 'anchor', 'location', 'weight%'], - clusters.map((cluster, i) => [ - String(i + 1), + clusters.map((cluster, clusterIndex) => [ + String(clusterIndex + 1), cluster.anchor.function ?? '—', frameLocation(cluster.anchor), formatPct(cluster.weightPct), @@ -339,13 +356,13 @@ function appendMemoryKindReview(lines: string[], report: LanternaReport): void { appendIndentedTable( lines, ['#', 'function', 'location', 'self%', 'total%', 'user_caller'], - allocators.map((a, i) => [ - String(i + 1), - a.function ?? '—', - frameLocation(a), - formatPct(a.selfPct), - formatPct(a.totalPct), - userCallerCell(a.userCaller), + allocators.map((allocator, allocatorIndex) => [ + String(allocatorIndex + 1), + allocator.function ?? '—', + frameLocation(allocator), + formatPct(allocator.selfPct), + formatPct(allocator.totalPct), + userCallerCell(allocator.userCaller), ]), ); } @@ -376,17 +393,22 @@ function appendAsyncKindReview(lines: string[], report: LanternaReport): void { `- top_async_hot_file: ${frameLabel(asyncProfile.summary.topAsyncHotFile)} at ${frameLocation(asyncProfile.summary.topAsyncHotFile)}${userCallerSuffix(asyncProfile.summary.topAsyncHotFile.userCaller)}`, ); } - const operationRows = (asyncProfile.topOperations ?? []).flatMap((op, i) => { - const frame = preferredAsyncOperationFrame(op); - if (!isRenderableReviewFrame(frame) && !isRenderableReviewFrame(op.userCaller)) return []; + const operationRows = (asyncProfile.topOperations ?? []).flatMap((operation, operationIndex) => { + const operationFrame = preferredAsyncOperationFrame(operation); + if ( + !isRenderableReviewFrame(operationFrame) && + !isRenderableReviewFrame(operation.userCaller) + ) { + return []; + } return [ [ - String(i + 1), - op.kind, - String(op.asyncId), - isRenderableReviewFrame(frame) ? frameLocation(frame) : '—', - formatScalarOrDash(op.durationMs), - userCallerCell(op.userCaller), + String(operationIndex + 1), + operation.kind, + String(operation.asyncId), + isRenderableReviewFrame(operationFrame) ? frameLocation(operationFrame) : '—', + formatScalarOrDash(operation.durationMs), + userCallerCell(operation.userCaller), ], ]; }); @@ -546,8 +568,8 @@ function decisionForFinding(finding: Finding): 'actionable' | 'hypothesis' | 're function proofLevelFromExtra(finding: Finding): string { const extra = finding.evidence.extra; if (extra && typeof extra === 'object' && !Array.isArray(extra) && 'proofLevel' in extra) { - const value = Reflect.get(extra, 'proofLevel'); - if (typeof value === 'string') return value; + const proofLevel = Reflect.get(extra, 'proofLevel'); + if (typeof proofLevel === 'string') return proofLevel; } return 'unknown'; } @@ -559,8 +581,36 @@ function userCallerFromEvidenceExtra(extra: unknown): UserCallerAttribution | un function candidateCallersFromEvidenceExtra(extra: unknown): UserCallerAttribution[] { if (!extra || typeof extra !== 'object') return []; - const value = (extra as { candidateCallers?: unknown }).candidateCallers; - return Array.isArray(value) ? (value as UserCallerAttribution[]) : []; + const candidateCallers = (extra as { candidateCallers?: unknown }).candidateCallers; + return Array.isArray(candidateCallers) ? (candidateCallers as UserCallerAttribution[]) : []; +} + +function correlatedAllocatorFromEvidenceExtra( + extra: unknown, +): (Frame & { basis?: string; userCaller?: UserCallerAttribution; totalPct?: number }) | undefined { + if (!extra || typeof extra !== 'object') return undefined; + const correlatedAllocator = Reflect.get(extra, 'correlatedAllocator'); + if ( + !correlatedAllocator || + typeof correlatedAllocator !== 'object' || + Array.isArray(correlatedAllocator) + ) { + return undefined; + } + const frame = correlatedAllocator as Partial< + Frame & { basis?: string; userCaller?: UserCallerAttribution; totalPct?: number } + >; + if (typeof frame.file !== 'string' || typeof frame.line !== 'number') return undefined; + return frame as Frame & { basis?: string; userCaller?: UserCallerAttribution; totalPct?: number }; +} + +function entryFrameFromEvidenceExtra(extra: unknown): Frame | undefined { + if (!extra || typeof extra !== 'object') return undefined; + const entryFrame = Reflect.get(extra, 'entryFrame'); + if (!entryFrame || typeof entryFrame !== 'object' || Array.isArray(entryFrame)) return undefined; + const frame = entryFrame as Partial; + if (typeof frame.file !== 'string' || typeof frame.line !== 'number') return undefined; + return frame as Frame; } function cpuUserStackForFinding(finding: Finding, report: LanternaReport): string | undefined { @@ -579,7 +629,7 @@ function matchedCpuUserStackForFinding( ): { userFrames: CpuStackFrame[]; leaf?: CpuStackFrame; weightPct: number } | undefined { if (finding.profileKind !== 'cpu') return undefined; const hotStacks = report.profiles?.cpu?.hotStacks ?? []; - const matches = hotStacks + const scoredStacks = hotStacks .map((stack) => ({ stack, score: scoreCpuStackForFinding(stack.frames, finding), @@ -588,17 +638,22 @@ function matchedCpuUserStackForFinding( .sort( (left, right) => right.score - left.score || right.stack.weightPct - left.stack.weightPct, ); - const best = matches[0]; - if (!best) return undefined; - const stack = trimCpuUserStackForFinding(best.stack.frames, finding); - if (stack.userFrames.length === 0) return undefined; - return { ...stack, weightPct: best.stack.weightPct }; + const bestStackMatch = scoredStacks[0]; + if (!bestStackMatch) return undefined; + const trimmedStack = trimCpuUserStackForFinding(bestStackMatch.stack.frames, finding); + if (trimmedStack.userFrames.length === 0) return undefined; + return { ...trimmedStack, weightPct: bestStackMatch.stack.weightPct }; } function scoreCpuStackForFinding(frames: readonly CpuStackFrame[], finding: Finding): number { let score = frames.some((frame) => frameMatchesTarget(frame, finding.evidence)) ? 100 : 0; - const callee = calleeNameFromExtra(finding.evidence.extra); - if (callee && frames.some((frame) => frameMatchesFunction(frame, callee))) score += 50; + const expectedCalleeName = calleeNameFromExtra(finding.evidence.extra); + if ( + expectedCalleeName && + frames.some((frame) => frameMatchesFunction(frame, expectedCalleeName)) + ) { + score += 50; + } for (const caller of candidateCallersFromEvidenceExtra(finding.evidence.extra)) { if (frames.some((frame) => frameMatchesTarget(frame, caller))) { score += caller.stackDistance === 1 ? 12 : 8; @@ -638,8 +693,8 @@ function formatCpuStackFrames(frames: readonly CpuStackFrame[]): string { function calleeNameFromExtra(extra: unknown): string | undefined { if (!extra || typeof extra !== 'object') return undefined; - const value = Reflect.get(extra, 'callee'); - return typeof value === 'string' && value.length > 0 ? value : undefined; + const calleeName = Reflect.get(extra, 'callee'); + return typeof calleeName === 'string' && calleeName.length > 0 ? calleeName : undefined; } function frameMatchesTarget( @@ -686,12 +741,6 @@ function isUserStackFrame(frame: CpuStackFrame): boolean { return frame.category === 'user'; } -function isAnonymousUserStackFrame(frame: CpuStackFrame): boolean { - return ( - isUserStackFrame(frame) && (frame.function === '(anonymous)' || frame.function?.trim() === '') - ); -} - // --------------------------------------------------------------------------- // Location, files, frames helpers. // --------------------------------------------------------------------------- @@ -734,6 +783,10 @@ function userCallerSuffix(caller: UserCallerAttribution | undefined): string { return ` — user_caller ${formatUserCallerCompact(caller)}`; } +function basisSuffix(basis: string | undefined): string { + return basis ? ` (${basis})` : ''; +} + function formatUserCallerCompact(caller: UserCallerAttribution): string { const stackDistance = caller.stackDistance !== undefined ? `, distance ${caller.stackDistance}` : ''; @@ -745,7 +798,11 @@ function collectReadTargets(report: LanternaReport): ReadTarget[] { collectFindingReadTargets(targets, report); collectAggregateReadTargets(targets, report); return dedupeReadTargets(targets) - .sort((a, b) => a.rank - b.rank || a.location.localeCompare(b.location)) + .sort( + (leftTarget, rightTarget) => + leftTarget.rank - rightTarget.rank || + leftTarget.location.localeCompare(rightTarget.location), + ) .slice(0, 10); } @@ -766,6 +823,7 @@ function collectFindingReadTargets(targets: ReadTarget[], report: LanternaReport decision: findingIsActionable ? 'read-first' : 'inspect-lead', rank: findingIsActionable ? index : 100 + index, }); + collectExtraFindingReadTargets(targets, finding, index, signal); collectCpuUserStackReadTargets(targets, finding, report, index); return; } @@ -804,10 +862,37 @@ function collectFindingReadTargets(targets: ReadTarget[], report: LanternaReport rank: 150 + index * 10 + candidateIndex, }); }); + collectExtraFindingReadTargets(targets, finding, index, signal); collectCpuUserStackReadTargets(targets, finding, report, index); }); } +function collectExtraFindingReadTargets( + targets: ReadTarget[], + finding: Finding, + findingIndex: number, + signal: string, +): void { + const correlatedAllocator = correlatedAllocatorFromEvidenceExtra(finding.evidence.extra); + const allocatorTarget = correlatedAllocator?.userCaller ?? correlatedAllocator; + pushReadTarget(targets, allocatorTarget, { + reason: 'correlated-allocator', + source: 'finding', + signal, + decision: + correlatedAllocator?.userCaller?.confidence === 'high' ? 'read-first' : 'inspect-lead', + rank: 60 + findingIndex * 20, + }); + + pushReadTarget(targets, entryFrameFromEvidenceExtra(finding.evidence.extra), { + reason: 'async-entry-frame', + source: 'finding', + signal, + decision: 'inspect-lead', + rank: 65 + findingIndex * 20, + }); +} + function collectCpuUserStackReadTargets( targets: ReadTarget[], finding: Finding, @@ -816,9 +901,7 @@ function collectCpuUserStackReadTargets( ): void { const stack = matchedCpuUserStackForFinding(finding, report); if (!stack) return; - const hasNamedUserFrame = stack.userFrames.some((frame) => !isAnonymousUserStackFrame(frame)); stack.userFrames.forEach((frame, frameIndex) => { - if (hasNamedUserFrame && isAnonymousUserStackFrame(frame)) return; const target = readTargetFrame(frame); if (!target) return; targets.push({ @@ -859,10 +942,10 @@ function collectAggregateReadTargets(targets: ReadTarget[], report: LanternaRepo rank: 200, }); for (const hotspot of cpu.hotspots ?? []) { - const userCaller = readTargetFrame(hotspot.userCaller); - if (userCaller && isExternalOrRuntimeFrame(hotspot)) { + const userCallerTarget = readTargetFrame(hotspot.userCaller); + if (userCallerTarget && isExternalOrRuntimeFrame(hotspot)) { targets.push({ - ...userCaller, + ...userCallerTarget, reason: reasonForExternalUserCaller(hotspot), source: 'cpu', signal: signalFromPctFrame(hotspot), @@ -913,26 +996,26 @@ function collectAggregateReadTargets(targets: ReadTarget[], report: LanternaRepo function collectAllocatorReadTarget( targets: ReadTarget[], - frame: (Frame & { userCaller?: UserCallerAttribution; selfPct?: number }) | undefined, + allocatorFrame: (Frame & { userCaller?: UserCallerAttribution; selfPct?: number }) | undefined, rank: number, ): void { - if (!frame) return; - const userCaller = readTargetFrame(frame.userCaller); - if (userCaller && isExternalOrRuntimeFrame(frame)) { + if (!allocatorFrame) return; + const userCallerTarget = readTargetFrame(allocatorFrame.userCaller); + if (userCallerTarget && isExternalOrRuntimeFrame(allocatorFrame)) { targets.push({ - ...userCaller, + ...userCallerTarget, reason: 'memory-allocator', source: 'memory', - signal: signalFromPctFrame(frame), - decision: frame.userCaller?.confidence === 'high' ? 'read-first' : 'inspect-lead', + signal: signalFromPctFrame(allocatorFrame), + decision: allocatorFrame.userCaller?.confidence === 'high' ? 'read-first' : 'inspect-lead', rank, }); return; } - pushReadTarget(targets, frame, { + pushReadTarget(targets, allocatorFrame, { reason: 'memory-allocator', source: 'memory', - signal: signalFromPctFrame(frame), + signal: signalFromPctFrame(allocatorFrame), decision: 'inspect-lead', rank, }); @@ -1061,10 +1144,10 @@ function dedupeReadTargets(targets: ReadTarget[]): ReadTarget[] { return [...byLocation.values()]; } -function compareReadTargetPriority(a: ReadTarget, b: ReadTarget): number { - const decisionDelta = decisionRank(a.decision) - decisionRank(b.decision); +function compareReadTargetPriority(left: ReadTarget, right: ReadTarget): number { + const decisionDelta = decisionRank(left.decision) - decisionRank(right.decision); if (decisionDelta !== 0) return decisionDelta; - return a.rank - b.rank; + return left.rank - right.rank; } function decisionRank(decision: ReadTargetDecision): number { @@ -1090,6 +1173,8 @@ function formatReadTargetReason(reason: ReadTargetReason): string { return 'user caller for dependency hotspot'; case 'runtime-hotspot-caller': return 'user caller for runtime hotspot'; + case 'correlated-allocator': + return 'correlated allocator'; case 'cpu-user-stack': return 'CPU user stack'; case 'top-cpu-culprit': @@ -1112,6 +1197,8 @@ function formatReadTargetReason(reason: ReadTargetReason): string { return 'long async operation'; case 'long-async-operation-caller': return 'long async operation caller'; + case 'async-entry-frame': + return 'async entry frame'; case 'async-hot-file': return 'async hot file'; case 'async-hot-file-caller': @@ -1200,30 +1287,32 @@ function signalFromAsyncHotFile(hotFile: { return '—'; } -function preferredAsyncOperationFrame(op: AsyncTopOperation): AsyncStackFrameReport | undefined { +function preferredAsyncOperationFrame( + operation: AsyncTopOperation, +): AsyncStackFrameReport | undefined { return ( - op.primaryFrame ?? - op.awaitFrame ?? - op.executionFrame ?? - op.cdpAsyncContextFrame ?? - op.initFrame ?? - op.creationFrame ?? - op.promiseRegistrationFrame ?? - op.promiseHandlerFrame + operation.primaryFrame ?? + operation.awaitFrame ?? + operation.executionFrame ?? + operation.cdpAsyncContextFrame ?? + operation.initFrame ?? + operation.creationFrame ?? + operation.promiseRegistrationFrame ?? + operation.promiseHandlerFrame ); } -function asyncOperationFrames(op: AsyncTopOperation): AsyncStackFrameReport[] { +function asyncOperationFrames(operation: AsyncTopOperation): AsyncStackFrameReport[] { return [ - op.initFrame, - op.primaryFrame, - op.awaitFrame, - op.executionFrame, - op.cdpAsyncContextFrame, - op.creationFrame, - op.promiseRegistrationFrame, - op.promiseHandlerFrame, - ...op.initStack, + operation.initFrame, + operation.primaryFrame, + operation.awaitFrame, + operation.executionFrame, + operation.cdpAsyncContextFrame, + operation.creationFrame, + operation.promiseRegistrationFrame, + operation.promiseHandlerFrame, + ...operation.initStack, ].filter((frame): frame is AsyncStackFrameReport => Boolean(frame)); } @@ -1240,15 +1329,17 @@ function formatImpact(finding: Finding): string { function formatMeasurements(values: Record | undefined): string { if (!values || Object.keys(values).length === 0) return 'none'; return Object.entries(values) - .map(([key, value]) => `${key}=${formatRawNumber(value)}`) + .map(([metricName, metricValue]) => `${metricName}=${formatRawNumber(metricValue)}`) .join(' '); } function formatRemediation(remediation: Finding['remediation']): string { if (!remediation) return 'none'; const entries = Object.entries(remediation) - .filter(([, value]) => value !== undefined) - .map(([key, value]) => (key === 'kind' ? `kind=${String(value)}` : `${key}=${String(value)}`)); + .filter(([, remediationValue]) => remediationValue !== undefined) + .map(([key, remediationValue]) => + key === 'kind' ? `kind=${String(remediationValue)}` : `${key}=${String(remediationValue)}`, + ); return entries.join(' '); } @@ -1381,16 +1472,18 @@ function yamlString(value: string): string { function yamlInlineList(values: readonly string[]): string { if (values.length === 0) return '[]'; - return `[${values.map((v) => yamlString(v)).join(', ')}]`; + return `[${values.map((entry) => yamlString(entry)).join(', ')}]`; } function appendTable(lines: string[], headers: string[], rows: string[][]): void { const escaped = rows.map((row) => row.map(escapeCell)); - const widths = headers.map((h, i) => - Math.max(h.length, ...escaped.map((row) => (row[i] ?? '').length)), + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...escaped.map((row) => (row[columnIndex] ?? '').length)), + ); + const widthAt = (columnIndex: number): number => widths[columnIndex] ?? 0; + lines.push( + `| ${headers.map((header, columnIndex) => pad(header, widthAt(columnIndex))).join(' | ')} |`, ); - const widthAt = (i: number): number => widths[i] ?? 0; - lines.push(`| ${headers.map((h, i) => pad(h, widthAt(i))).join(' | ')} |`); lines.push(`| ${widths.map((w) => '-'.repeat(Math.max(3, w))).join(' | ')} |`); for (const row of escaped) { lines.push(`| ${row.map((cell, i) => pad(cell ?? '', widthAt(i))).join(' | ')} |`); diff --git a/packages/cli/src/renderers/markdown-renderer.ts b/packages/cli/src/renderers/markdown-renderer.ts index f20be8d..37fdb85 100644 --- a/packages/cli/src/renderers/markdown-renderer.ts +++ b/packages/cli/src/renderers/markdown-renderer.ts @@ -150,9 +150,9 @@ export class MarkdownReportRenderer implements ReportRenderer { } lines.push('| Async ID | Kind | Duration | Run | User caller |'); lines.push('| ---: | --- | ---: | ---: | --- |'); - for (const op of operations.slice(0, 5)) { + for (const operation of operations.slice(0, 5)) { lines.push( - `| ${op.asyncId} | ${op.kind} | ${formatMs(op.durationMs)} | ${formatMs(op.runMs)} | ${op.userCaller ? escapePipe(formatUserCaller(op.userCaller)) : ''} |`, + `| ${operation.asyncId} | ${operation.kind} | ${formatMs(operation.durationMs)} | ${formatMs(operation.runMs)} | ${operation.userCaller ? escapePipe(formatUserCaller(operation.userCaller)) : ''} |`, ); } } @@ -164,9 +164,9 @@ export class MarkdownReportRenderer implements ReportRenderer { } lines.push('| File | CPU | Ops | User caller |'); lines.push('| --- | ---: | ---: | --- |'); - for (const file of hotFiles.slice(0, 5)) { + for (const hotFile of hotFiles.slice(0, 5)) { lines.push( - `| \`${escapeBackticks(file.file)}\` | ${formatPct(file.cpuPct)} | ${file.operationCount} | ${file.userCaller ? escapePipe(formatUserCaller(file.userCaller)) : ''} |`, + `| \`${escapeBackticks(hotFile.file)}\` | ${formatPct(hotFile.cpuPct)} | ${hotFile.operationCount} | ${hotFile.userCaller ? escapePipe(formatUserCaller(hotFile.userCaller)) : ''} |`, ); } } @@ -190,26 +190,26 @@ export class MarkdownReportRenderer implements ReportRenderer { lines.push('No findings.'); return; } - for (const f of findings) { - lines.push(`### ${f.title}`); + for (const finding of findings) { + lines.push(`### ${finding.title}`); lines.push(''); - lines.push(`- Severity: ${f.severity}`); - lines.push(`- Kind: ${f.profileKind}`); + lines.push(`- Severity: ${finding.severity}`); + lines.push(`- Kind: ${finding.profileKind}`); lines.push( - `- Evidence: \`${escapeBackticks(f.evidence.function)}\` at \`${escapeBackticks(formatFrameLocation(f.evidence))}\``, + `- Evidence: \`${escapeBackticks(finding.evidence.function)}\` at \`${escapeBackticks(formatFrameLocation(finding.evidence))}\``, ); - const userCaller = userCallerFromEvidenceExtra(f.evidence.extra); + const userCaller = userCallerFromEvidenceExtra(finding.evidence.extra); if (userCaller) lines.push(`- User caller: ${formatUserCaller(userCaller)}`); - const candidateCallers = candidateCallersFromEvidenceExtra(f.evidence.extra); + const candidateCallers = candidateCallersFromEvidenceExtra(finding.evidence.extra); if (candidateCallers.length > 0) { lines.push('- Candidate callers:'); for (const caller of candidateCallers) { lines.push(` - ${formatUserCaller(caller)}`); } } - lines.push(`- Suggestion: ${f.suggestion}`); - if (f.evidence.extra !== undefined) { - const extra = renderValue(f.evidence.extra); + lines.push(`- Suggestion: ${finding.suggestion}`); + if (finding.evidence.extra !== undefined) { + const extra = renderValue(finding.evidence.extra); if (extra.length > 0) { lines.push('- Details:'); for (const line of extra) lines.push(` ${line}`); @@ -247,6 +247,6 @@ function userCallerFromEvidenceExtra(extra: unknown): UserCallerAttribution | un function candidateCallersFromEvidenceExtra(extra: unknown): UserCallerAttribution[] { if (!extra || typeof extra !== 'object') return []; - const value = (extra as { candidateCallers?: unknown }).candidateCallers; - return Array.isArray(value) ? (value as UserCallerAttribution[]) : []; + const candidateCallers = (extra as { candidateCallers?: unknown }).candidateCallers; + return Array.isArray(candidateCallers) ? (candidateCallers as UserCallerAttribution[]) : []; } diff --git a/packages/cli/src/renderers/text-renderer.ts b/packages/cli/src/renderers/text-renderer.ts index 61fda32..242ada9 100644 --- a/packages/cli/src/renderers/text-renderer.ts +++ b/packages/cli/src/renderers/text-renderer.ts @@ -105,12 +105,12 @@ export class TextReportRenderer implements ReportRenderer { } private renderHotspots(lines: string[], hotspots: Hotspot[], indent: string): void { - const top = hotspots.slice(0, 5); - if (top.length === 0) { + const topHotspots = hotspots.slice(0, 5); + if (topHotspots.length === 0) { lines.push(`${indent}None`); return; } - for (const hotspot of top) { + for (const hotspot of topHotspots) { lines.push( `${indent}${hotspot.function} (${formatFrameLocation(hotspot)}): self ${formatPct(hotspot.selfPct)}, total ${formatPct(hotspot.totalPct)}`, ); @@ -125,12 +125,12 @@ export class TextReportRenderer implements ReportRenderer { allocators: MemoryHotAllocator[], indent: string, ): void { - const top = allocators.slice(0, 5); - if (top.length === 0) { + const topAllocators = allocators.slice(0, 5); + if (topAllocators.length === 0) { lines.push(`${indent}None`); return; } - for (const allocator of top) { + for (const allocator of topAllocators) { lines.push( `${indent}${allocator.function} (${formatFrameLocation(allocator)}): self ${formatBytes(allocator.selfBytes)} (${formatPct(allocator.selfPct)}), total ${formatBytes(allocator.totalBytes)} (${formatPct(allocator.totalPct)})`, ); @@ -145,33 +145,33 @@ export class TextReportRenderer implements ReportRenderer { operations: AsyncTopOperation[], indent: string, ): void { - const top = operations.slice(0, 5); - if (top.length === 0) { + const topOperations = operations.slice(0, 5); + if (topOperations.length === 0) { lines.push(`${indent}None`); return; } - for (const op of top) { + for (const operation of topOperations) { lines.push( - `${indent}#${op.asyncId} ${op.kind} (${formatMs(op.durationMs)}, run ${formatMs(op.runMs)})`, + `${indent}#${operation.asyncId} ${operation.kind} (${formatMs(operation.durationMs)}, run ${formatMs(operation.runMs)})`, ); - if (op.userCaller) { - lines.push(`${indent} User caller: ${formatUserCaller(op.userCaller)}`); + if (operation.userCaller) { + lines.push(`${indent} User caller: ${formatUserCaller(operation.userCaller)}`); } } } private renderAsyncHotFiles(lines: string[], hotFiles: AsyncHotFile[], indent: string): void { - const top = hotFiles.slice(0, 5); - if (top.length === 0) { + const topHotFiles = hotFiles.slice(0, 5); + if (topHotFiles.length === 0) { lines.push(`${indent}None`); return; } - for (const file of top) { + for (const hotFile of topHotFiles) { lines.push( - `${indent}${file.file}: cpu ${formatPct(file.cpuPct)}, ops ${file.operationCount}`, + `${indent}${hotFile.file}: cpu ${formatPct(hotFile.cpuPct)}, ops ${hotFile.operationCount}`, ); - if (file.userCaller) { - lines.push(`${indent} User caller: ${formatUserCaller(file.userCaller)}`); + if (hotFile.userCaller) { + lines.push(`${indent} User caller: ${formatUserCaller(hotFile.userCaller)}`); } } } @@ -181,12 +181,12 @@ export class TextReportRenderer implements ReportRenderer { chains: AsyncCpuAttributionEntry[], indent: string, ): void { - const top = chains.slice(0, 5); - if (top.length === 0) { + const topChains = chains.slice(0, 5); + if (topChains.length === 0) { lines.push(`${indent}None`); return; } - for (const chain of top) { + for (const chain of topChains) { lines.push( `${indent}root #${chain.rootAsyncId} ${chain.rootKind}: cpu ${formatPct(chain.cpuPct)} (${formatMs(chain.cpuMs)})`, ); @@ -201,25 +201,25 @@ export class TextReportRenderer implements ReportRenderer { lines.push(`${indent}No findings`); return; } - for (const f of findings) { - lines.push(`${indent}[${f.severity}] ${f.title}`); - lines.push(`${indent} ${f.suggestion}`); + for (const finding of findings) { + lines.push(`${indent}[${finding.severity}] ${finding.title}`); + lines.push(`${indent} ${finding.suggestion}`); lines.push( - `${indent} Evidence: ${f.evidence.function} (${formatFrameLocation(f.evidence)})`, + `${indent} Evidence: ${finding.evidence.function} (${formatFrameLocation(finding.evidence)})`, ); - const userCaller = userCallerFromEvidenceExtra(f.evidence.extra); + const userCaller = userCallerFromEvidenceExtra(finding.evidence.extra); if (userCaller) { lines.push(`${indent} User caller: ${formatUserCaller(userCaller)}`); } - const candidateCallers = candidateCallersFromEvidenceExtra(f.evidence.extra); + const candidateCallers = candidateCallersFromEvidenceExtra(finding.evidence.extra); if (candidateCallers.length > 0) { lines.push(`${indent} Candidate callers:`); for (const caller of candidateCallers) { lines.push(`${indent} - ${formatUserCaller(caller)}`); } } - if (f.evidence.extra !== undefined) { - const extra = renderValue(f.evidence.extra); + if (finding.evidence.extra !== undefined) { + const extra = renderValue(finding.evidence.extra); if (extra.length > 0) { lines.push(`${indent} Details:`); for (const line of extra) lines.push(`${indent} ${line}`); @@ -248,6 +248,6 @@ function userCallerFromEvidenceExtra(extra: unknown): UserCallerAttribution | un function candidateCallersFromEvidenceExtra(extra: unknown): UserCallerAttribution[] { if (!extra || typeof extra !== 'object') return []; - const value = (extra as { candidateCallers?: unknown }).candidateCallers; - return Array.isArray(value) ? (value as UserCallerAttribution[]) : []; + const candidateCallers = (extra as { candidateCallers?: unknown }).candidateCallers; + return Array.isArray(candidateCallers) ? (candidateCallers as UserCallerAttribution[]) : []; } diff --git a/packages/core/src/analysis/core/pipeline.ts b/packages/core/src/analysis/core/pipeline.ts index 456082f..c0da6ad 100644 --- a/packages/core/src/analysis/core/pipeline.ts +++ b/packages/core/src/analysis/core/pipeline.ts @@ -93,12 +93,12 @@ export class AnalysisPipeline { // Phase 1 — kind contributors populate `profiles.` and views. for (const kind of this.kinds) { const dataKey = kind.id as keyof CaptureKindDataMap; - const data = bundle.kinds?.[dataKey]; - if (data === undefined) continue; + const kindData = bundle.kinds?.[dataKey]; + if (kindData === undefined) continue; try { const contributor = kind.createAnalysisContributor(); - const kindCtx: KindCtx = { - data, + const kindContext: KindCtx = { + data: kindData, bundle, analysis: context, options, @@ -110,7 +110,7 @@ export class AnalysisPipeline { context.setView(kind.id as keyof KindViews, view as KindViews[keyof KindViews]); }, }; - contributor.analyze(kindCtx); + contributor.analyze(kindContext); } catch (error) { logger.warn({ kindId: kind.id, err: error }, 'kind analysis contributor failed'); recordCaptureDiagnostic(bundle.captureIntegrity, { @@ -157,11 +157,11 @@ export class AnalysisPipeline { for (const kind of this.kinds) { if (!kind.finalize) continue; const dataKey = kind.id as keyof CaptureKindDataMap; - const data = bundle.kinds?.[dataKey]; - if (data === undefined) continue; + const kindData = bundle.kinds?.[dataKey]; + if (kindData === undefined) continue; try { kind.finalize({ - data, + data: kindData, snapshot: { profiles: snapshot.profiles, findings: snapshot.findings }, }); } catch (error) { @@ -174,14 +174,14 @@ export class AnalysisPipeline { } } - const result: AnalysisResult = { + const analysisResult: AnalysisResult = { profiles: snapshot.profiles, findings: snapshot.findings, }; if (Object.keys(snapshot.extensions).length > 0) { - result.extensions = snapshot.extensions; + analysisResult.extensions = snapshot.extensions; } - return result; + return analysisResult; } } @@ -312,11 +312,11 @@ function buildStubMeta( const kindsIntegrity: Record = { ...bundle.captureIntegrity.kinds }; const capturedKinds: string[] = []; for (const kind of kinds) { - const data = bundle.kinds?.[kind.id as keyof CaptureKindDataMap]; - if (data === undefined) continue; + const kindData = bundle.kinds?.[kind.id as keyof CaptureKindDataMap]; + if (kindData === undefined) continue; capturedKinds.push(kind.id); - if (kind.contributeMeta) kindsMeta[kind.id] = kind.contributeMeta(data); - if (kind.contributeIntegrity) kindsIntegrity[kind.id] = kind.contributeIntegrity(data); + if (kind.contributeMeta) kindsMeta[kind.id] = kind.contributeMeta(kindData); + if (kind.contributeIntegrity) kindsIntegrity[kind.id] = kind.contributeIntegrity(kindData); } return { schemaVersion: LANTERNA_REPORT_SCHEMA_VERSION, diff --git a/packages/core/src/analysis/kind-scoped-detector.ts b/packages/core/src/analysis/kind-scoped-detector.ts index 5baf05e..9aa5229 100644 --- a/packages/core/src/analysis/kind-scoped-detector.ts +++ b/packages/core/src/analysis/kind-scoped-detector.ts @@ -9,6 +9,7 @@ import type { AnalysisContext, AnalysisSnapshot, FindingAnalyzer } from './core/ export interface KindScopedDetectorShared { readonly findings: readonly Finding[]; readonly meta: ReportMeta; + readonly profiles: Readonly; } export type KindScopedDetectorBundle = { @@ -62,6 +63,7 @@ export function createFindingAnalyzerFromKindScopedDetector ({ diff --git a/packages/core/src/analysis/model/hotspots.ts b/packages/core/src/analysis/model/hotspots.ts index 6bd1588..9e6c864 100644 --- a/packages/core/src/analysis/model/hotspots.ts +++ b/packages/core/src/analysis/model/hotspots.ts @@ -303,8 +303,18 @@ export function buildHotspotAnalysis( selfPct: (aggregate.selfSamples / totalSamples) * 100, totalMs: aggregate.totalMs, totalPct: (aggregate.totalSamples / totalSamples) * 100, - callers: topRefs(aggregate.callerSamples, hotspotAggregatesByKey, totalSamples, 3), - callees: topRefs(aggregate.calleeSamples, hotspotAggregatesByKey, totalSamples, 3), + callers: topHotspotRefsBySamplePct( + aggregate.callerSamples, + hotspotAggregatesByKey, + totalSamples, + 3, + ), + callees: topHotspotRefsBySamplePct( + aggregate.calleeSamples, + hotspotAggregatesByKey, + totalSamples, + 3, + ), optimizationState: aggregate.optimizationState, }; if (aggregate.package) hotspot.package = aggregate.package; @@ -312,7 +322,7 @@ export function buildHotspotAnalysis( fullHotspots.push(hotspot); hotspotById.set(hotspot.id, hotspot); - const candidateCallers = buildUserCallerCandidates( + const candidateCallers = buildUserCallerAttributions( aggregate, hotspotAggregatesByKey, totalSamples, @@ -340,13 +350,13 @@ export function buildHotspotAnalysis( }; } -function buildUserCallerCandidates( +function buildUserCallerAttributions( aggregate: HotspotAggregate, hotspotAggregatesByKey: Map, totalSamples: number, ): UserCallerAttribution[] { const totalPathSamples = Math.max(1, aggregate.pathSamples || aggregate.totalSamples); - const candidates = Array.from(aggregate.candidateUserAncestorSamples.entries()) + const rankedUserCallerAttributions = Array.from(aggregate.candidateUserAncestorSamples.entries()) .sort((left, right) => { const distanceDelta = (aggregate.candidateUserAncestorDistance.get(left[0]) ?? Number.MAX_SAFE_INTEGER) - @@ -377,9 +387,8 @@ function buildUserCallerCandidates( if (userHotspotAggregate.source) userCaller.source = userHotspotAggregate.source; return [userCaller]; }); - return candidates.some((candidate) => !isAnonymousUserCaller(candidate)) - ? candidates.filter((candidate) => !isAnonymousUserCaller(candidate)) - : candidates; + // Keep the full user caller chain, including anonymous user wrappers. + return rankedUserCallerAttributions; } function attributionConfidenceForSupport(supportPct: number): 'low' | 'medium' | 'high' { @@ -388,10 +397,6 @@ function attributionConfidenceForSupport(supportPct: number): 'low' | 'medium' | return 'low'; } -function isAnonymousUserCaller(candidate: UserCallerAttribution): boolean { - return candidate.function === '(anonymous)' || candidate.function.trim() === ''; -} - function buildSampleDurationsMs(profile: RawCpuProfile, fallbackMs: number): number[] { const sampleCount = profile.samples?.length ?? 0; if (sampleCount === 0) return []; @@ -402,7 +407,7 @@ function buildSampleDurationsMs(profile: RawCpuProfile, fallbackMs: number): num return deltas.map((deltaUs) => deltaUs / 1000); } -function topRefs( +function topHotspotRefsBySamplePct( samplesByAggregateKey: Map, hotspotAggregatesByKey: Map, totalSamples: number, diff --git a/packages/core/src/analysis/model/summary.ts b/packages/core/src/analysis/model/summary.ts index 2275e7c..80d83be 100644 --- a/packages/core/src/analysis/model/summary.ts +++ b/packages/core/src/analysis/model/summary.ts @@ -58,8 +58,11 @@ export function deriveTopUserHotspot( correlatedHotspots: readonly CorrelatedHotspot[] = [], findings: LanternaReport['findings'] = [], ): SummaryUserHotspot | undefined { - const candidateHotspots = dedupeHotspots([...hotspots, ...candidateCallerHotspots(findings)]); - const candidates = candidateHotspots + const candidateHotspots = dedupeHotspots([ + ...hotspots, + ...hotspotsFromCandidateCallers(findings), + ]); + const eligibleUserHotspots = candidateHotspots .filter( (hotspot) => hotspot.category === 'user' && @@ -71,19 +74,19 @@ export function deriveTopUserHotspot( correlated: findCorrelatedHotspot(hotspot, correlatedHotspots), explained: isExplainedBySpecificFinding(hotspot, findings), })); - const unexplainedCandidates = candidates.filter((candidate) => !candidate.explained); - const explanationPool = unexplainedCandidates.length > 0 ? unexplainedCandidates : candidates; - const namedPool = explanationPool.some((candidate) => !isAnonymousWrapper(candidate.hotspot)) - ? explanationPool.filter((candidate) => !isAnonymousWrapper(candidate.hotspot)) - : explanationPool; - const matches = namedPool.sort((left, right) => compareTopHotspotCandidates(left, right)); - const topCandidate = matches[0]; - const top = topCandidate?.hotspot; - if (!top) return undefined; + const unexplainedUserHotspots = eligibleUserHotspots.filter((candidate) => !candidate.explained); + const userHotspotsToRank = + unexplainedUserHotspots.length > 0 ? unexplainedUserHotspots : eligibleUserHotspots; + const rankedUserHotspots = [...userHotspotsToRank].sort((left, right) => + compareTopHotspotCandidates(left, right), + ); + const primaryCandidate = rankedUserHotspots[0]; + const primaryHotspot = primaryCandidate?.hotspot; + if (!primaryHotspot) return undefined; - const correlated = topCandidate.correlated; - const alternatives = matches.slice(1, 3).map(({ hotspot }) => { - const alt: SummaryUserHotspot['alternativeHotspots'] extends (infer T)[] | undefined + const primaryCorrelation = primaryCandidate.correlated; + const alternativeHotspots = rankedUserHotspots.slice(1, 3).map(({ hotspot }) => { + const alternative: SummaryUserHotspot['alternativeHotspots'] extends (infer T)[] | undefined ? T : never = { id: hotspot.id, @@ -93,22 +96,22 @@ export function deriveTopUserHotspot( selfPct: hotspot.selfPct, totalPct: hotspot.totalPct, }; - if (hotspot.source) alt.source = hotspot.source; - return alt; + if (hotspot.source) alternative.source = hotspot.source; + return alternative; }); const summary: SummaryUserHotspot = { - function: top.function, - file: top.file, - line: top.line, - selfPct: top.selfPct, - totalPct: top.totalPct, - eventLoopCorrelation: correlated - ? { overlapPct: correlated.overlapPct, samplePct: correlated.samplePct } + function: primaryHotspot.function, + file: primaryHotspot.file, + line: primaryHotspot.line, + selfPct: primaryHotspot.selfPct, + totalPct: primaryHotspot.totalPct, + eventLoopCorrelation: primaryCorrelation + ? { overlapPct: primaryCorrelation.overlapPct, samplePct: primaryCorrelation.samplePct } : undefined, - alternativeHotspots: alternatives.length > 0 ? alternatives : undefined, + alternativeHotspots: alternativeHotspots.length > 0 ? alternativeHotspots : undefined, }; - if (top.source) summary.source = top.source; + if (primaryHotspot.source) summary.source = primaryHotspot.source; return summary; } @@ -116,7 +119,7 @@ export function deriveTopCpuCulprit( hotspots: readonly Hotspot[], correlatedHotspots: readonly CorrelatedHotspot[] = [], ): SummaryUserHotspot | undefined { - const candidates = hotspots + const eligibleCpuCulprits = hotspots .filter( (hotspot) => hotspot.category === 'user' && hotspot.selfPct >= TOP_USER_HOTSPOT_MIN_SELF_PCT, ) @@ -124,18 +127,15 @@ export function deriveTopCpuCulprit( hotspot, correlated: findCorrelatedHotspot(hotspot, correlatedHotspots), })); - const namedPool = candidates.some((candidate) => !isAnonymousWrapper(candidate.hotspot)) - ? candidates.filter((candidate) => !isAnonymousWrapper(candidate.hotspot)) - : candidates; - const matches = namedPool.sort(compareCpuCulpritCandidates); - const topCandidate = matches[0]; - const top = topCandidate?.hotspot; - if (!top) return undefined; + const rankedCpuCulprits = [...eligibleCpuCulprits].sort(compareCpuCulpritCandidates); + const primaryCandidate = rankedCpuCulprits[0]; + const primaryHotspot = primaryCandidate?.hotspot; + if (!primaryHotspot) return undefined; return toSummaryUserHotspot( - top, - topCandidate.correlated, - matches.slice(1, 3).map(({ hotspot }) => hotspot), + primaryHotspot, + primaryCandidate.correlated, + rankedCpuCulprits.slice(1, 3).map(({ hotspot }) => hotspot), ); } @@ -185,19 +185,19 @@ function toSummaryUserHotspot( correlated: CorrelatedHotspot | undefined, alternativeHotspots: readonly Hotspot[], ): SummaryUserHotspot { - const alternatives = alternativeHotspots.map((alternative) => { - const alt: SummaryUserHotspot['alternativeHotspots'] extends (infer T)[] | undefined + const alternatives = alternativeHotspots.map((alternativeHotspot) => { + const alternative: SummaryUserHotspot['alternativeHotspots'] extends (infer T)[] | undefined ? T : never = { - id: alternative.id, - function: alternative.function, - file: alternative.file, - line: alternative.line, - selfPct: alternative.selfPct, - totalPct: alternative.totalPct, + id: alternativeHotspot.id, + function: alternativeHotspot.function, + file: alternativeHotspot.file, + line: alternativeHotspot.line, + selfPct: alternativeHotspot.selfPct, + totalPct: alternativeHotspot.totalPct, }; - if (alternative.source) alt.source = alternative.source; - return alt; + if (alternativeHotspot.source) alternative.source = alternativeHotspot.source; + return alternative; }); const summary: SummaryUserHotspot = { function: hotspot.function, @@ -214,20 +214,16 @@ function toSummaryUserHotspot( return summary; } -function isAnonymousWrapper(hotspot: Hotspot): boolean { - return hotspot.function === '(anonymous)' || hotspot.function.trim() === ''; -} - function dedupeHotspots(hotspots: Hotspot[]): Hotspot[] { - const byId = new Map(); + const hotspotById = new Map(); for (const hotspot of hotspots) { - if (!byId.has(hotspot.id)) byId.set(hotspot.id, hotspot); + if (!hotspotById.has(hotspot.id)) hotspotById.set(hotspot.id, hotspot); } - return Array.from(byId.values()); + return Array.from(hotspotById.values()); } -function candidateCallerHotspots(findings: LanternaReport['findings']): Hotspot[] { - const byId = new Map(); +function hotspotsFromCandidateCallers(findings: LanternaReport['findings']): Hotspot[] { + const hotspotById = new Map(); for (const finding of findings) { if (!SPECIFIC_FINDING_CATEGORIES.has(finding.category)) continue; const extra = finding.evidence.extra as { candidateCallers?: unknown } | undefined; @@ -235,8 +231,8 @@ function candidateCallerHotspots(findings: LanternaReport['findings']): Hotspot[ for (const candidate of extra.candidateCallers) { if (!isUserCallerCandidate(candidate)) continue; const id = `${candidate.file}:${candidate.line}:${candidate.function}`; - if (byId.has(id)) continue; - byId.set(id, { + if (hotspotById.has(id)) continue; + hotspotById.set(id, { id, function: candidate.function, file: candidate.file, @@ -254,7 +250,7 @@ function candidateCallerHotspots(findings: LanternaReport['findings']): Hotspot[ }); } } - return Array.from(byId.values()); + return Array.from(hotspotById.values()); } function isUserCallerCandidate(candidate: unknown): candidate is { @@ -267,17 +263,17 @@ function isUserCallerCandidate(candidate: unknown): candidate is { source?: SummaryUserHotspot['source']; } { if (!candidate || typeof candidate !== 'object') return false; - const value = candidate as { + const candidateValue = candidate as { function?: unknown; file?: unknown; line?: unknown; profilePct?: unknown; }; return ( - typeof value.function === 'string' && - typeof value.file === 'string' && - typeof value.line === 'number' && - typeof value.profilePct === 'number' + typeof candidateValue.function === 'string' && + typeof candidateValue.file === 'string' && + typeof candidateValue.line === 'number' && + typeof candidateValue.profilePct === 'number' ); } @@ -295,27 +291,27 @@ function isExplainedBySpecificFinding( ): boolean { return findings.some((finding) => { if (!SPECIFIC_FINDING_CATEGORIES.has(finding.category)) return false; - if (matchesHotspot(finding.evidence, hotspot)) return true; + if (matchesHotspotFrame(finding.evidence, hotspot)) return true; const extra = finding.evidence.extra as | { userCaller?: unknown; candidateCallers?: unknown } | undefined; const userCaller = extra?.userCaller; const candidateCallers = extra?.candidateCallers; - if (matchesHotspot(userCaller, hotspot)) return true; + if (matchesHotspotFrame(userCaller, hotspot)) return true; if (Array.isArray(candidateCallers)) { - return candidateCallers.some((candidate) => matchesHotspot(candidate, hotspot)); + return candidateCallers.some((candidate) => matchesHotspotFrame(candidate, hotspot)); } return false; }); } -function matchesHotspot(candidate: unknown, hotspot: Hotspot): boolean { - if (!candidate || typeof candidate !== 'object') return false; - const value = candidate as { file?: unknown; line?: unknown; function?: unknown }; +function matchesHotspotFrame(frame: unknown, hotspot: Hotspot): boolean { + if (!frame || typeof frame !== 'object') return false; + const frameValue = frame as { file?: unknown; line?: unknown; function?: unknown }; return ( - value.file === hotspot.file && - value.line === hotspot.line && - value.function === hotspot.function + frameValue.file === hotspot.file && + frameValue.line === hotspot.line && + frameValue.function === hotspot.function ); } diff --git a/packages/core/src/capture/coordinator.ts b/packages/core/src/capture/coordinator.ts index d55612f..0e151aa 100644 --- a/packages/core/src/capture/coordinator.ts +++ b/packages/core/src/capture/coordinator.ts @@ -250,7 +250,7 @@ async function stopProbes( ): Promise> { const kindsData: Record = {}; for (const probeInstance of probeInstances) { - const result = await stopProbe( + const stoppedProbe = await stopProbe( probeInstance, connected, cdp, @@ -259,8 +259,8 @@ async function stopProbes( captureIntegrity, stopReason, ); - if (!result.ok) continue; - kindsData[probeInstance.kind.id] = result.value; + if (!stoppedProbe.ok) continue; + kindsData[probeInstance.kind.id] = stoppedProbe.value; } return kindsData; } @@ -285,7 +285,7 @@ async function stopProbe( ? { liveSourceSignals: connected.drainLiveSignals.bind(connected) } : {}), }); - const result = + const stopResult = stopTimeoutMs === false ? { ok: true as const, @@ -293,9 +293,9 @@ async function stopProbe( } : await withTimeoutResult(probe.stop(ctx), stopTimeoutMs); - if (result.ok) { + if (stopResult.ok) { stopSucceeded = true; - return result; + return stopResult; } recordCaptureDiagnostic(captureIntegrity, { @@ -355,11 +355,11 @@ async function disposeProbe( stopReason, stopSucceeded, }); - const result = + const disposeResult = disposeTimeoutMs === false ? { ok: true as const, value: await probe.dispose(ctx) } : await withTimeoutResult(probe.dispose(ctx), disposeTimeoutMs); - if (result.ok) return; + if (disposeResult.ok) return; recordCaptureDiagnostic(captureIntegrity, { stage: 'probe-dispose', kindId: kind.id, diff --git a/packages/core/src/kinds/async/probe.ts b/packages/core/src/kinds/async/probe.ts index 1522369..f6447cc 100644 --- a/packages/core/src/kinds/async/probe.ts +++ b/packages/core/src/kinds/async/probe.ts @@ -26,6 +26,7 @@ export function createAsyncProbe(options: AsyncProbeOptions): CaptureProbe void> = []; let asyncStackSupport: 'enabled' | 'unsupported' | 'unknown' = 'unknown'; return { + stopTimeoutMs: 15_000, async start(ctx: ProbeLifecycleContext) { const { cdp } = ctx; // Best-effort. Older Node builds may reject either call; the report still diff --git a/packages/core/src/kinds/memory/analysis.ts b/packages/core/src/kinds/memory/analysis.ts index 5fbce75..9b426a1 100644 --- a/packages/core/src/kinds/memory/analysis.ts +++ b/packages/core/src/kinds/memory/analysis.ts @@ -157,6 +157,13 @@ function buildMemoryQuality( if (data.heapSamplingAvailable === false) { reasons.push('V8 heap sampling profile was unavailable'); recommendations.add('Rerun the capture while the target process remains reachable over CDP.'); + } else if (totalSampledBytes === 0) { + reasons.push( + 'V8 heap sampling profile contains 0 bytes — the probe ran but observed no heap allocations during the capture window', + ); + recommendations.add( + 'Increase --duration, generate representative load (use --workload), or check that the target actually allocates on the V8 heap (Buffer.alloc and other external allocations are not visible to the heap sampler).', + ); } for (const warning of data.warnings ?? []) { diff --git a/packages/core/src/report/schema/findings.ts b/packages/core/src/report/schema/findings.ts index 055b21c..f67d91f 100644 --- a/packages/core/src/report/schema/findings.ts +++ b/packages/core/src/report/schema/findings.ts @@ -15,8 +15,28 @@ import { sourceLocationSchema, stallCorrelationSchema, stallIntervalSchema, + userCallerAttributionSchema, } from './primitives.js'; +const frameEvidenceSchema = z.object({ + function: z.string(), + file: z.string(), + line: z.number().int(), + column: z.number().int().optional(), + source: sourceLocationSchema.optional(), +}); + +const correlatedAllocatorSchema = z.object({ + function: z.string(), + file: z.string(), + line: z.number().int(), + totalPct: z.number(), + selfPct: z.number().optional(), + basis: z.enum(['heap-sampled-allocator', 'cpu-top-user-hotspot']).optional(), + userCaller: userCallerAttributionSchema.optional(), + source: sourceLocationSchema.optional(), +}); + export const blockingIoExtraSchema = attributionEvidenceSchema.extend({ api: z.string().min(1), callee: z.string().min(1), @@ -51,8 +71,30 @@ export const excessiveGcExtraSchema = z.object({ ratioConfidence: z.enum(['high', 'medium']), counts: gcCountSchema, candidateHotspots: z.array(correlatedHotspotSchema), + userCaller: userCallerAttributionSchema.optional(), }); +export const memoryGrowthExtraSchema = z + .object({ + metric: z.enum(['rss', 'heapUsed']), + correlatedAllocator: correlatedAllocatorSchema.optional(), + }) + .passthrough(); + +export const externalBufferPressureExtraSchema = z + .object({ + ratio: z.number(), + correlatedAllocator: correlatedAllocatorSchema.optional(), + }) + .passthrough(); + +export const hotAsyncContextExtraSchema = z + .object({ + entryFrame: frameEvidenceSchema.nullable().optional(), + userCaller: userCallerAttributionSchema.optional(), + }) + .passthrough(); + export const eventLoopStallExtraSchema = z.object({ proofLevel: z.union([z.literal('aggregate-correlation'), z.literal('hotspot-fallback')]), p99LagMs: z.number(), @@ -175,6 +217,9 @@ export const findingSchema = z 'json-on-hot-path': jsonHotPathExtraSchema, 'node-modules-hotspot': nodeModulesHotspotExtraSchema, 'cpu-hotspot': cpuHotspotExtraSchema, + 'memory-growth': memoryGrowthExtraSchema, + 'external-buffer-pressure': externalBufferPressureExtraSchema, + 'hot-async-context': hotAsyncContextExtraSchema, } as const; const extraSchema = schemaByCategory[category as keyof typeof schemaByCategory]; diff --git a/packages/core/src/report/schema/primitives.ts b/packages/core/src/report/schema/primitives.ts index dc06821..cb6e21b 100644 --- a/packages/core/src/report/schema/primitives.ts +++ b/packages/core/src/report/schema/primitives.ts @@ -45,7 +45,7 @@ export const userCallerAttributionSchema = z.object({ line: z.number().int(), column: z.number().int().optional(), source: sourceLocationSchema.optional(), - stackDistance: z.number().int().positive().optional(), + stackDistance: z.number().int().nonnegative().optional(), profilePct: z.number(), supportPct: z.number(), confidence: z.enum(['low', 'medium', 'high']), diff --git a/packages/core/src/report/types.ts b/packages/core/src/report/types.ts index 1e0d467..3334008 100644 --- a/packages/core/src/report/types.ts +++ b/packages/core/src/report/types.ts @@ -261,7 +261,7 @@ export interface UserCallerAttribution { line: number; column?: number; source?: SourceLocation; - /** 1 means the closest user frame to the external callee; larger values are outer callers. */ + /** 0 means the sampled user frame itself; 1 means the closest user frame to an external callee. */ stackDistance?: number; /** Percent of the whole profile attributed to this user caller. */ profilePct: number; @@ -328,6 +328,7 @@ export interface ExcessiveGcEvidenceExtra { ratioConfidence: 'high' | 'medium'; counts: GcReport['count']; candidateHotspots: CorrelatedHotspot[]; + userCaller?: UserCallerAttribution; } export interface EventLoopStallEvidenceExtra { diff --git a/packages/core/test/analysis-model.test.ts b/packages/core/test/analysis-model.test.ts index f745744..c827d20 100644 --- a/packages/core/test/analysis-model.test.ts +++ b/packages/core/test/analysis-model.test.ts @@ -337,7 +337,7 @@ describe('buildHotspotAnalysis', () => { expect(candidates?.map((candidate) => candidate.supportPct)).toEqual([60, 40]); }); - it('keeps nested user ancestors as attribution candidates', () => { + it('keeps nested user ancestors, including anonymous user wrappers, as attribution candidates', () => { const profile: RawCpuProfile = { startTime: 0, endTime: 100_000, @@ -413,12 +413,12 @@ describe('buildHotspotAnalysis', () => { analysis.candidateCallersById .get(cryptoHotspot?.id ?? '') ?.map((candidate) => candidate.function), - ).toEqual(['hashPassword', 'processBatch']); + ).toEqual(['hashPassword', 'processBatch', '(anonymous)']); expect( analysis.candidateCallersById .get(cryptoHotspot?.id ?? '') ?.map((candidate) => candidate.stackDistance), - ).toEqual([1, 2]); + ).toEqual([1, 2, 3]); }); it('orders candidates by stack proximity before common parent support', () => { diff --git a/packages/core/test/async-kind.test.ts b/packages/core/test/async-kind.test.ts index ade6fbc..2173146 100644 --- a/packages/core/test/async-kind.test.ts +++ b/packages/core/test/async-kind.test.ts @@ -779,6 +779,12 @@ describe('async installer lifecycle', () => { }); describe('async probe lifecycle', () => { + it('allows slower async snapshot serialization during stop', () => { + const probe = createAsyncProbe({ asyncStackDepth: 32 }); + + expect(probe.stopTimeoutMs).toBe(15_000); + }); + it('disables the in-target installer over CDP at dispose()', async () => { const evaluated: string[] = []; const sent: string[] = []; diff --git a/packages/core/test/summary.test.ts b/packages/core/test/summary.test.ts index 06f1e03..2b00edb 100644 --- a/packages/core/test/summary.test.ts +++ b/packages/core/test/summary.test.ts @@ -361,6 +361,40 @@ describe('deriveTopUserHotspot', () => { expect(top?.eventLoopCorrelation).toEqual({ overlapPct: 70, samplePct: 50 }); }); + it('keeps anonymous user wrappers eligible as top hotspots', () => { + const wrapper = makeHotspot({ + category: 'user', + function: '(anonymous)', + file: 'src/app.js', + line: 1, + selfPct: 5, + totalPct: 95, + }); + const named = makeHotspot({ + category: 'user', + function: 'processBatch', + file: 'src/app.js', + line: 12, + selfPct: 12, + totalPct: 55, + }); + + expect(deriveTopUserHotspot([wrapper, named])?.function).toBe('(anonymous)'); + }); + + it('falls back to anonymous hotspots when no named hotspot exists', () => { + const wrapper = makeHotspot({ + category: 'user', + function: '(anonymous)', + file: 'src/app.js', + line: 1, + selfPct: 12, + totalPct: 95, + }); + + expect(deriveTopUserHotspot([wrapper])?.function).toBe('(anonymous)'); + }); + it('forwards the source location when set on the hotspot', () => { const hotspot = makeHotspot({ category: 'user', @@ -399,6 +433,40 @@ describe('deriveTopCpuCulprit', () => { expect(deriveTopCpuCulprit([wrapper, compute])?.function).toBe('scoreRecommendations'); }); + it('keeps anonymous user wrappers eligible as CPU culprits', () => { + const wrapper = makeHotspot({ + category: 'user', + function: '(anonymous)', + file: 'src/app.ts', + line: 1, + selfPct: 80, + totalPct: 95, + }); + const compute = makeHotspot({ + category: 'user', + function: 'scoreRecommendations', + file: 'src/search.ts', + line: 13, + selfPct: 30, + totalPct: 40, + }); + + expect(deriveTopCpuCulprit([wrapper, compute])?.function).toBe('(anonymous)'); + }); + + it('falls back to anonymous CPU culprits when no named culprit exists', () => { + const wrapper = makeHotspot({ + category: 'user', + function: '(anonymous)', + file: 'src/app.ts', + line: 1, + selfPct: 80, + totalPct: 95, + }); + + expect(deriveTopCpuCulprit([wrapper])?.function).toBe('(anonymous)'); + }); + it('does not report an inclusive-only wrapper as the CPU culprit', () => { const wrapper = makeHotspot({ category: 'user', diff --git a/packages/detectors/src/config.ts b/packages/detectors/src/config.ts index 7e68fea..0be2ec0 100644 --- a/packages/detectors/src/config.ts +++ b/packages/detectors/src/config.ts @@ -277,9 +277,9 @@ export const DETECTOR_THRESHOLDS: DetectorThresholds = { // known anti-patterns; self-heavy user frames are actionable, inclusive-only // user frames are emitted as lower-confidence caller/context leads. cpuHotspot: { minSelfPct: 10, minTotalPct: 25, criticalPct: 40, maxFindings: 3 }, - // 5 deopts for the same function is meaningful (V8 stops optimising - // after a few tries); 20+ is pathological. - deoptLoop: { minCount: 5, criticalCount: 20 }, + // V8 v22+ often abandons optimisation after 2-3 deopts; 3 deopts is the + // canonical signal, and 10+ indicates persistent instability. + deoptLoop: { minCount: 3, criticalCount: 10 }, excessiveGc: { // 10% of on-CPU in GC is the soft trigger; 25% is critical. ratioTrigger: 0.1, diff --git a/packages/detectors/src/detectors/alloc-in-hot-path.ts b/packages/detectors/src/detectors/alloc-in-hot-path.ts index 8321b26..920a1be 100644 --- a/packages/detectors/src/detectors/alloc-in-hot-path.ts +++ b/packages/detectors/src/detectors/alloc-in-hot-path.ts @@ -4,8 +4,10 @@ import type { Hotspot, KindScopedDetector, MemoryHotAllocator, + UserCallerAttribution, } from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS } from '../config.js'; +import { selfHotspotUserCaller } from './shared.js'; /** * Cross-kind detector: a frame that appears in the top CPU hotspots AND in the @@ -20,11 +22,14 @@ export const allocInHotPathDetector: KindScopedDetector<'cpu' | 'memory'> = { detect({ cpu, memory }): Finding[] { const thresholds = DETECTOR_THRESHOLDS.allocInHotPath; const cpuHotspots = cpu.report.hotspots.filter(isActionableFrame); - const memAllocators = memory.report.hotAllocators.filter(isActionableFrame); - if (cpuHotspots.length === 0 || memAllocators.length === 0) return []; + const actionableMemAllocators = memory.report.hotAllocators.filter(isActionableFrame); + const dominantAllocator = memory.report.hotAllocators.find( + (allocator) => allocator.totalPct >= thresholds.minAllocTotalPct, + ); + if (cpuHotspots.length === 0) return []; const memByKey = new Map(); - for (const allocator of memAllocators) { + for (const allocator of actionableMemAllocators) { memByKey.set(frameKey(allocator.function, allocator.file, allocator.line), allocator); } @@ -40,6 +45,18 @@ export const allocInHotPathDetector: KindScopedDetector<'cpu' | 'memory'> = { seen.add(key); findings.push(buildFinding(hotspot, allocator)); } + if ( + findings.length === 0 && + dominantAllocator && + isDominantSystemAllocator(dominantAllocator) + ) { + const correlated = findCpuCorrelatedAllocator( + cpu.report.hotspots, + cpuHotspots, + dominantAllocator, + ); + if (correlated) findings.push(buildFinding(correlated, dominantAllocator)); + } return findings; }, }; @@ -52,6 +69,10 @@ function buildFinding( const combined = hotspot.totalPct + allocator.totalPct; const severity: BaseFinding['severity'] = combined >= thresholds.criticalCombinedPct ? 'critical' : 'warning'; + const userCaller = + hotspot.userCaller ?? + (hotspot.category === 'user' ? selfHotspotUserCaller(hotspot) : undefined) ?? + allocatorUserCaller(allocator); return { id: `alloc-in-hot-path:${allocator.id}`, profileKind: 'memory', @@ -77,6 +98,7 @@ function buildFinding( allocTotalBytes: allocator.totalBytes, combinedPct: combined, ...(allocator.package ? { package: allocator.package } : {}), + ...(userCaller ? { userCaller } : {}), }, }, measurements: { @@ -101,10 +123,86 @@ function buildFinding( }; } +function allocatorUserCaller(allocator: MemoryHotAllocator): UserCallerAttribution | undefined { + if (allocator.userCaller) return allocator.userCaller; + if (allocator.category !== 'user') return undefined; + return { + function: allocator.function, + file: allocator.file, + line: allocator.line, + column: allocator.column, + ...(allocator.source ? { source: allocator.source } : {}), + stackDistance: 0, + profilePct: allocator.totalPct, + supportPct: 100, + confidence: 'high', + basis: 'heap-sample-path', + }; +} + function frameKey(fn: string, file: string, line: number): string { return `${file}::${fn}::${line}`; } +function findCpuCorrelatedAllocator( + allHotspots: readonly Hotspot[], + cpuHotspots: readonly Hotspot[], + allocator: MemoryHotAllocator, +): Hotspot | undefined { + const thresholds = DETECTOR_THRESHOLDS.allocInHotPath; + const minSystemCpuPct = thresholds.minCpuTotalPct; + const hotspotById = new Map(allHotspots.map((hotspot) => [hotspot.id, hotspot])); + let best: { hotspot: Hotspot; systemCpuPct: number } | undefined; + for (const hotspot of cpuHotspots) { + if (hotspot.totalPct < thresholds.minCpuTotalPct) continue; + const systemCpuPct = Math.max( + attributedSystemCpuPct(allHotspots, hotspot), + systemCalleeCpuPct(hotspot, hotspotById), + ); + if (systemCpuPct < minSystemCpuPct) continue; + if (!best || hotspot.totalPct + systemCpuPct > best.hotspot.totalPct + best.systemCpuPct) { + best = { hotspot, systemCpuPct }; + } + } + if (!best) return undefined; + if (allocator.totalPct + best.hotspot.totalPct < thresholds.criticalCombinedPct) { + return undefined; + } + return best.hotspot; +} + +function systemCalleeCpuPct(hotspot: Hotspot, hotspotById: ReadonlyMap): number { + return hotspot.callees.reduce((total, callee) => { + const calleeHotspot = hotspotById.get(callee.id); + if (!calleeHotspot || isActionableFrame(calleeHotspot)) return total; + return total + callee.pct; + }, 0); +} + +function attributedSystemCpuPct(allHotspots: readonly Hotspot[], userHotspot: Hotspot): number { + return allHotspots.reduce((total, hotspot) => { + if (isActionableFrame(hotspot)) return total; + return userCallerMatchesHotspot(hotspot.userCaller, userHotspot) + ? total + hotspot.totalPct + : total; + }, 0); +} + +function userCallerMatchesHotspot( + caller: UserCallerAttribution | undefined, + hotspot: Hotspot, +): boolean { + return ( + caller?.function === hotspot.function && + caller.file === hotspot.file && + caller.line === hotspot.line + ); +} + +function isDominantSystemAllocator(allocator: MemoryHotAllocator): boolean { + return !isActionableFrame(allocator) && allocator.totalPct >= 40; +} + function isActionableFrame( frame: Pick, ) { diff --git a/packages/detectors/src/detectors/async-evidence.ts b/packages/detectors/src/detectors/async-evidence.ts index 4865dea..0ff6a40 100644 --- a/packages/detectors/src/detectors/async-evidence.ts +++ b/packages/detectors/src/detectors/async-evidence.ts @@ -3,6 +3,7 @@ import type { AsyncProfileReport, AsyncStackFrameReport, BaseFinding, + UserCallerAttribution, } from '@lanterna-profiler/core'; export interface AsyncAnchor { @@ -65,6 +66,28 @@ export function asyncEvidenceExtra( }; } +export function resolveAsyncUserCaller( + entity: { userCaller?: UserCallerAttribution } | undefined, + fallbackFrame: AsyncStackFrameReport | undefined, + options: Partial< + Pick + > = {}, +): UserCallerAttribution | undefined { + if (entity?.userCaller) return entity.userCaller; + if (!fallbackFrame) return undefined; + return { + function: fallbackFrame.function, + file: fallbackFrame.file, + line: fallbackFrame.line, + column: fallbackFrame.column, + ...(fallbackFrame.source ? { source: fallbackFrame.source } : {}), + profilePct: options.profilePct ?? 0, + supportPct: options.supportPct ?? 100, + confidence: options.confidence ?? 'high', + basis: options.basis ?? 'async-stack', + }; +} + export function asyncConfidence( report: AsyncProfileReport, base: BaseFinding['confidence'], diff --git a/packages/detectors/src/detectors/blocking-io.ts b/packages/detectors/src/detectors/blocking-io.ts index 5415ac3..a081f3b 100644 --- a/packages/detectors/src/detectors/blocking-io.ts +++ b/packages/detectors/src/detectors/blocking-io.ts @@ -125,9 +125,11 @@ import { findStallCorrelation, isBuiltinRuntimeHotspot, maxHotspotPct, + pickPrimaryCallerBySource, readFrameSourceText, resolveAttribution, severityForPct, + sourceCallPatternForApi, } from './shared.js'; const ZLIB_PROCESS_CHUNK_SYNC = 'processChunkSync'; @@ -160,7 +162,15 @@ export const blockingIoDetector: KindScopedDetector<'cpu'> = { if (!perFrameHit && !familyExceeded) continue; const callee = 'callee' in patternMatch ? patternMatch.callee : undefined; findings.push( - buildFinding(hotspot, patternMatch.api, categoryTotalPct, report, context, { callee }), + buildFinding( + hotspot, + patternMatch.api, + categoryTotalPct, + report, + context, + cpu.view.bundle.target.cwd, + { callee }, + ), ); } return findings; @@ -207,15 +217,24 @@ function buildFinding( categoryTotalPct: number, report: { eventLoop: EventLoopReport }, context: CpuHotspotContext, + cwd: string, options: { callee?: string } = {}, ): BuiltinFinding<'blocking-io'> { const asyncApi = api.replace(/Sync$/, ''); const { attribution, caller, candidateCallers } = resolveAttribution(hotspot, context); + const sourceCaller = pickPrimaryCallerBySource( + candidateCallers, + cwd, + sourceCallPatternForApi(api), + ); + const evidenceAttribution = sourceCaller ?? attribution; + const highConfidenceCaller = + evidenceAttribution?.confidence === 'high' ? evidenceAttribution : undefined; const evidenceExtra: BlockingIoEvidenceExtra = { api, callee: options.callee ?? hotspot.function, - ...buildAttributionEvidence(attribution, caller, candidateCallers), - eventLoopCorrelation: findStallCorrelation(caller, report), + ...buildAttributionEvidence(evidenceAttribution, highConfidenceCaller, candidateCallers), + eventLoopCorrelation: findStallCorrelation(sourceCaller ?? caller ?? attribution, report), categoryTotalPct: categoryTotalPct > 0 ? categoryTotalPct : undefined, }; const thresholds = DETECTOR_THRESHOLDS.blockingIo; @@ -226,7 +245,7 @@ function buildFinding( severity: severityForPct(maxHotspotPct(hotspot), thresholds.criticalPct), title: `Blocking I/O call on hot path (${api})`, hotspot, - caller, + caller: sourceCaller ?? caller, selfPct: maxHotspotPct(hotspot), extra: evidenceExtra, measurements: { diff --git a/packages/detectors/src/detectors/cpu-hotspot.ts b/packages/detectors/src/detectors/cpu-hotspot.ts index ce32e96..e5b4f06 100644 --- a/packages/detectors/src/detectors/cpu-hotspot.ts +++ b/packages/detectors/src/detectors/cpu-hotspot.ts @@ -32,34 +32,28 @@ export const cpuHotspotDetector: KindScopedDetector<'cpu'> = { order: 90, detect({ cpu }, shared): Finding[] { const thresholds = DETECTOR_THRESHOLDS.cpuHotspot; - const candidates = cpu.report.hotspots + const unexplainedUserHotspots = cpu.report.hotspots .filter((hotspot) => hotspot.category === 'user') .filter( (hotspot) => hotspot.selfPct >= thresholds.minSelfPct || hotspot.totalPct >= thresholds.minTotalPct, ) - .filter( - (hotspot) => - !( - hasSpecificCpuFindings(shared.findings) && - isAnonymousWrapper(hotspot) && - hotspot.selfPct < thresholds.minSelfPct - ), - ) .filter((hotspot) => !isExplainedBySpecificFinding(hotspot, shared.findings)) - .sort(compareHotspots); - const selfHotspots = candidates.filter((hotspot) => hotspot.selfPct >= thresholds.minSelfPct); - const mode: CpuHotspotMode = selfHotspots.length > 0 ? 'self' : 'inclusive-entry'; - const explanationPool = selfHotspots.length > 0 ? selfHotspots : candidates; - const namedPool = explanationPool.some((hotspot) => !isAnonymousWrapper(hotspot)) - ? explanationPool.filter((hotspot) => !isAnonymousWrapper(hotspot)) - : explanationPool; + .sort(compareHotspotsBySelfThenTotalPct); + const selfCpuHotspots = unexplainedUserHotspots.filter( + (hotspot) => hotspot.selfPct >= thresholds.minSelfPct, + ); + const mode: CpuHotspotMode = selfCpuHotspots.length > 0 ? 'self' : 'inclusive-entry'; + const hotspotsToExplain = + selfCpuHotspots.length > 0 ? selfCpuHotspots : unexplainedUserHotspots; - return namedPool.slice(0, thresholds.maxFindings).map((hotspot, index) => - buildFinding( + return hotspotsToExplain.slice(0, thresholds.maxFindings).map((hotspot, index) => + buildCpuHotspotFinding( hotspot, - candidates.filter((candidate) => candidate.id !== hotspot.id).slice(0, 2), - cpu.report.eventLoop.correlatedHotspots?.find((candidate) => sameFrame(candidate, hotspot)), + unexplainedUserHotspots.filter((candidate) => candidate.id !== hotspot.id).slice(0, 2), + cpu.report.eventLoop.correlatedHotspots?.find((candidate) => + sameFrameLocation(candidate, hotspot), + ), mode, index, ), @@ -67,21 +61,13 @@ export const cpuHotspotDetector: KindScopedDetector<'cpu'> = { }, }; -function compareHotspots(left: Hotspot, right: Hotspot): number { +function compareHotspotsBySelfThenTotalPct(left: Hotspot, right: Hotspot): number { const selfDelta = right.selfPct - left.selfPct; if (selfDelta !== 0) return selfDelta; return right.totalPct - left.totalPct; } -function isAnonymousWrapper(hotspot: Hotspot): boolean { - return hotspot.function === '(anonymous)' || hotspot.function.trim() === ''; -} - -function hasSpecificCpuFindings(findings: readonly LanternaReport['findings'][number][]): boolean { - return findings.some((finding) => SPECIFIC_CPU_FINDING_CATEGORIES.has(finding.category)); -} - -function buildFinding( +function buildCpuHotspotFinding( hotspot: Hotspot, alternatives: Hotspot[], eventLoopCorrelation: StallCorrelation | undefined, @@ -179,23 +165,26 @@ function isExplainedBySpecificFinding( ): boolean { return findings.some((finding) => { if (!SPECIFIC_CPU_FINDING_CATEGORIES.has(finding.category)) return false; - if (sameFrame(finding.evidence, hotspot)) return true; - if (sameSourceFrame(finding.evidence.source, hotspot)) return true; + if (sameFrameLocation(finding.evidence, hotspot)) return true; + if (sameSourceMappedFrame(finding.evidence.source, hotspot)) return true; const extra = finding.evidence.extra as | { userCaller?: unknown; candidateCallers?: unknown } | undefined; - if (sameUnknownFrame(extra?.userCaller, hotspot)) return true; + if (sameUnknownFindingFrame(extra?.userCaller, hotspot)) return true; if (!Array.isArray(extra?.candidateCallers)) return false; - return extra.candidateCallers.some((candidate) => sameUnknownFrame(candidate, hotspot)); + return extra.candidateCallers.some((candidate) => sameUnknownFindingFrame(candidate, hotspot)); }); } -function sameUnknownFrame(candidate: unknown, hotspot: Hotspot): boolean { +function sameUnknownFindingFrame(candidate: unknown, hotspot: Hotspot): boolean { if (!candidate || typeof candidate !== 'object') return false; - return sameFrame(candidate as { file?: string; line?: number; function?: string }, hotspot); + return sameFrameLocation( + candidate as { file?: string; line?: number; function?: string }, + hotspot, + ); } -function sameFrame( +function sameFrameLocation( candidate: { file?: string; line?: number; function?: string }, hotspot: Hotspot, ): boolean { @@ -206,7 +195,7 @@ function sameFrame( ); } -function sameSourceFrame( +function sameSourceMappedFrame( source: { file?: string; line?: number; name?: string } | undefined, hotspot: Hotspot, ): boolean { diff --git a/packages/detectors/src/detectors/deep-async-chain.ts b/packages/detectors/src/detectors/deep-async-chain.ts index 00b3575..343894f 100644 --- a/packages/detectors/src/detectors/deep-async-chain.ts +++ b/packages/detectors/src/detectors/deep-async-chain.ts @@ -1,6 +1,11 @@ import type { BaseFinding, Finding, KindScopedDetector } from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS } from '../config.js'; -import { anchorForFile, asyncConfidence, asyncEvidenceExtra } from './async-evidence.js'; +import { + anchorForFile, + asyncConfidence, + asyncEvidenceExtra, + resolveAsyncUserCaller, +} from './async-evidence.js'; /** * Fires when an async trigger chain reaches deep into the tree. Deep chains @@ -27,6 +32,10 @@ export const deepAsyncChainDetector: KindScopedDetector<'async'> = { const rootFrame = chain.rootFrame; const anchor = anchorForFile(report, chain.dominantFile ?? rootFrame?.file); const frame = anchor.frame ?? rootFrame; + const userCaller = resolveAsyncUserCaller(undefined, frame, { + confidence: 'high', + basis: 'async-stack', + }); findings.push({ id: `deep-async-chain:${chain.rootAsyncId}`, profileKind: 'async', @@ -53,6 +62,7 @@ export const deepAsyncChainDetector: KindScopedDetector<'async'> = { totalOperations: chain.totalOperations, totalDurationMs: chain.totalDurationMs, deepestPath: chain.deepestPath, + ...(userCaller ? { userCaller } : {}), ...asyncEvidenceExtra(report, anchor), }, }, diff --git a/packages/detectors/src/detectors/deopt-loop.ts b/packages/detectors/src/detectors/deopt-loop.ts index a896fda..a19fc20 100644 --- a/packages/detectors/src/detectors/deopt-loop.ts +++ b/packages/detectors/src/detectors/deopt-loop.ts @@ -20,7 +20,7 @@ export const deoptLoopDetector: KindScopedDetector<'cpu'> = { const context: CpuHotspotContext = cpu.view.hotspotAnalysis; const thresholds = DETECTOR_THRESHOLDS.deoptLoop; const findings: Finding[] = []; - for (const deopt of aggregateDeopts(report.deopts)) { + for (const deopt of aggregateDeopts(report.deopts, context)) { if (deopt.count < thresholds.minCount) continue; const matchingHotspot = findHotDeoptHotspot(deopt.function, deopt.file, deopt.line, context); if (!matchingHotspot) continue; @@ -34,7 +34,7 @@ export const deoptLoopDetector: KindScopedDetector<'cpu'> = { const finding: BuiltinFinding<'deopt-loop'> = defineBuiltinFinding({ id: `deopt-loop:${deopt.function}`, profileKind: 'cpu', - severity: deopt.count > thresholds.criticalCount ? 'critical' : 'warning', + severity: deopt.count >= thresholds.criticalCount ? 'critical' : 'warning', category: 'deopt-loop', title: `Repeated deoptimisation of ${deopt.function} (${deopt.count}×)`, confidence: 'medium', @@ -64,26 +64,25 @@ export const deoptLoopDetector: KindScopedDetector<'cpu'> = { }, }; -function aggregateDeopts(deopts: readonly DeoptEntry[]): DeoptEntry[] { +function aggregateDeopts(deopts: readonly DeoptEntry[], context: CpuHotspotContext): DeoptEntry[] { const grouped = new Map(); - const output: DeoptEntry[] = []; + const unknownFallbackHotspot = inferUnknownDeoptHotspot(deopts, context); for (const deopt of deopts) { - if (deopt.file && deopt.line > 0) { - output.push(deopt); - continue; - } - const key = deopt.function; + const enriched = enrichDeoptWithCpuFrame(deopt, context, unknownFallbackHotspot); + const key = + enriched.file && enriched.line > 0 + ? `${enriched.function}:${enriched.file}:${enriched.line}` + : enriched.function; const existing = grouped.get(key); if (!existing) { - grouped.set(key, { ...deopt }); + grouped.set(key, { ...enriched }); continue; } - existing.count += deopt.count; - existing.reason = mergeLabel(existing.reason, deopt.reason); - existing.bailoutType = mergeLabel(existing.bailoutType, deopt.bailoutType); + existing.count += enriched.count; + existing.reason = mergeLabel(existing.reason, enriched.reason); + existing.bailoutType = mergeLabel(existing.bailoutType, enriched.bailoutType); } - output.push(...grouped.values()); - return output.sort((a, b) => b.count - a.count); + return [...grouped.values()].sort((a, b) => b.count - a.count); } function mergeLabel(left: string, right: string): string { @@ -116,6 +115,73 @@ function findHotDeoptHotspot( ); } +function inferUnknownDeoptHotspot( + deopts: readonly DeoptEntry[], + context: CpuHotspotContext, +): Hotspot | undefined { + if (!deopts.some((deopt) => deopt.function === '' && (!deopt.file || deopt.line <= 0))) { + return undefined; + } + const candidates = new Map(); + for (const deopt of deopts) { + if (deopt.function === '') continue; + const hotspot = findUniqueUserHotspotByFunction(deopt.function, context); + if (hotspot) candidates.set(hotspot.id, hotspot); + } + return candidates.size === 1 ? [...candidates.values()][0] : undefined; +} + +function enrichDeoptWithCpuFrame( + deopt: DeoptEntry, + context: CpuHotspotContext, + unknownFallbackHotspot?: Hotspot, +): DeoptEntry { + if (deopt.function !== '') { + if (deopt.file && deopt.line > 0) return deopt; + const matchingHotspot = findUniqueUserHotspotByFunction(deopt.function, context); + return matchingHotspot ? deoptFromHotspot(deopt, matchingHotspot) : deopt; + } + if (!deopt.file || deopt.line <= 0) { + return unknownFallbackHotspot ? deoptFromHotspot(deopt, unknownFallbackHotspot) : deopt; + } + const matchingHotspot = context.fullHotspots.find( + (hotspot) => + hotspot.category === 'user' && + matchesDeoptFile(hotspot.file, deopt.file) && + Math.abs(hotspot.line - deopt.line) <= 1 && + hotspot.totalPct > 1, + ); + if (!matchingHotspot) return deopt; + return { + ...deopt, + function: matchingHotspot.function, + file: matchingHotspot.file, + line: matchingHotspot.line, + ...(matchingHotspot.source ? { source: matchingHotspot.source } : {}), + }; +} + +function findUniqueUserHotspotByFunction( + functionName: string, + context: CpuHotspotContext, +): Hotspot | undefined { + const matches = context.fullHotspots.filter( + (hotspot) => + hotspot.function === functionName && hotspot.category === 'user' && hotspot.totalPct > 1, + ); + return matches.length === 1 ? matches[0] : undefined; +} + +function deoptFromHotspot(deopt: DeoptEntry, hotspot: Hotspot): DeoptEntry { + return { + ...deopt, + function: hotspot.function, + file: hotspot.file, + line: hotspot.line, + ...(hotspot.source ? { source: hotspot.source } : {}), + }; +} + function matchesDeoptFile(hotspotFile: string, deoptFile: string): boolean { return hotspotFile === deoptFile || deoptFile.endsWith(`/${hotspotFile}`); } diff --git a/packages/detectors/src/detectors/excessive-gc.ts b/packages/detectors/src/detectors/excessive-gc.ts index 44844c6..755ffb0 100644 --- a/packages/detectors/src/detectors/excessive-gc.ts +++ b/packages/detectors/src/detectors/excessive-gc.ts @@ -2,10 +2,12 @@ import type { BuiltinFinding, ExcessiveGcEvidenceExtra, Finding, + Hotspot, KindScopedDetector, } from '@lanterna-profiler/core'; import { defineBuiltinFinding } from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS } from '../config.js'; +import { findActionableUserCpuHotspot, selfHotspotUserCaller } from './shared.js'; export const excessiveGcDetector: KindScopedDetector<'cpu'> = { id: 'excessive-gc', @@ -15,32 +17,52 @@ export const excessiveGcDetector: KindScopedDetector<'cpu'> = { const thresholds = DETECTOR_THRESHOLDS.excessiveGc; const gcRatio = report.summary.gcRatio; const longestPauseMs = report.gc.longestPauseMs; - const ratioTrigger = gcRatio > thresholds.ratioTrigger; - const pauseTrigger = longestPauseMs > thresholds.longestPauseTrigger; - if (!ratioTrigger && !pauseTrigger) return []; + const gcRatioExceeded = gcRatio > thresholds.ratioTrigger; + const longPauseExceeded = longestPauseMs > thresholds.longestPauseTrigger; + if (!gcRatioExceeded && !longPauseExceeded) return []; const totalTimedGcEvents = Object.values(report.gc.count).reduce( (sum, count) => sum + count, 0, ); const hasTimedGcEvidence = totalTimedGcEvents > 0 || report.gc.totalPauseMs > 0; - const cpuMeta = shared.meta.kinds.cpu as { samplesTotal?: number } | undefined; + const cpuKindMeta = shared.meta.kinds.cpu as { samplesTotal?: number } | undefined; const hasEnoughCpuSamplesForRatioOnly = shared.meta.durationMs >= thresholds.minDurationMs && - (cpuMeta?.samplesTotal ?? 0) >= thresholds.minSamples; - if (ratioTrigger && !pauseTrigger && !hasTimedGcEvidence && !hasEnoughCpuSamplesForRatioOnly) { + (cpuKindMeta?.samplesTotal ?? 0) >= thresholds.minSamples; + if ( + gcRatioExceeded && + !longPauseExceeded && + !hasTimedGcEvidence && + !hasEnoughCpuSamplesForRatioOnly + ) { return []; } - const topCandidate = report.gc.correlatedHotspots?.[0]; + const correlatedHotspots = report.gc.correlatedHotspots ?? []; + const fallbackUserHotspot = + correlatedHotspots.length > 0 + ? undefined + : correlatedHotspotFromHotspot(findActionableUserCpuHotspot(report.hotspots)); + const gcCulpritHotspots = + correlatedHotspots.length > 0 + ? correlatedHotspots + : fallbackUserHotspot + ? [fallbackUserHotspot] + : []; + const primaryGcHotspot = gcCulpritHotspots[0]; + const userCaller = primaryGcHotspot ? selfHotspotUserCaller(primaryGcHotspot) : undefined; const severity: Finding['severity'] = gcRatio > thresholds.ratioCritical || longestPauseMs > thresholds.longestPauseCritical ? 'critical' : 'warning'; - const evidenceParts: string[] = []; - if (ratioTrigger) - evidenceParts.push(`GC consumed ${(gcRatio * 100).toFixed(1)}% of on-CPU time`); - if (pauseTrigger) evidenceParts.push(`longest pause was ${longestPauseMs.toFixed(1)}ms`); + const evidenceSentences: string[] = []; + if (gcRatioExceeded) { + evidenceSentences.push(`GC consumed ${(gcRatio * 100).toFixed(1)}% of on-CPU time`); + } + if (longPauseExceeded) { + evidenceSentences.push(`longest pause was ${longestPauseMs.toFixed(1)}ms`); + } const evidenceExtra: ExcessiveGcEvidenceExtra = { proofLevel: 'aggregate-correlation', gcRatio, @@ -48,7 +70,8 @@ export const excessiveGcDetector: KindScopedDetector<'cpu'> = { timedGcEventCount: totalTimedGcEvents, ratioConfidence: hasTimedGcEvidence ? 'high' : 'medium', counts: report.gc.count, - candidateHotspots: report.gc.correlatedHotspots ?? [], + candidateHotspots: gcCulpritHotspots, + ...(userCaller ? { userCaller } : {}), }; return [ @@ -61,11 +84,11 @@ export const excessiveGcDetector: KindScopedDetector<'cpu'> = { confidence: hasTimedGcEvidence ? 'high' : 'medium', proofLevel: 'correlated-window', evidence: { - file: topCandidate?.file ?? '(process)', - line: topCandidate?.line ?? 0, - function: topCandidate?.function ?? '(aggregate)', - selfPct: topCandidate?.samplePct ?? 0, - ...(topCandidate?.source ? { source: topCandidate.source } : {}), + file: primaryGcHotspot?.file ?? '(process)', + line: primaryGcHotspot?.line ?? 0, + function: primaryGcHotspot?.function ?? '(aggregate)', + selfPct: primaryGcHotspot?.samplePct ?? 0, + ...(primaryGcHotspot?.source ? { source: primaryGcHotspot.source } : {}), extra: evidenceExtra, }, measurements: { @@ -77,7 +100,7 @@ export const excessiveGcDetector: KindScopedDetector<'cpu'> = { longestPauseCritical: thresholds.longestPauseCritical, }, }, - why: `${evidenceParts.join(' and ')}. High GC usually means too many short-lived allocations on hot paths: unbounded caches, per-request object churn, large Buffer concat, or repeated JSON parse/stringify.`, + why: `${evidenceSentences.join(' and ')}. High GC usually means too many short-lived allocations on hot paths: unbounded caches, per-request object churn, large Buffer concat, or repeated JSON parse/stringify.`, suggestion: `Look at the top user-code hotspots for allocation patterns: replace array/string concat in loops with pre-sized buffers or streams, use bounded caches (lru-cache), reuse objects where safe, avoid \`JSON.parse(JSON.stringify(x))\` for deep clone (use \`structuredClone\`). Check old-space growth with \`--trace-gc --trace-gc-verbose\`.`, references: [ 'https://v8.dev/blog/trash-talk', @@ -87,3 +110,20 @@ export const excessiveGcDetector: KindScopedDetector<'cpu'> = { ]; }, }; + +function correlatedHotspotFromHotspot( + hotspot: Hotspot | undefined, +): ExcessiveGcEvidenceExtra['candidateHotspots'][number] | undefined { + if (!hotspot) return undefined; + return { + id: `${hotspot.file}:${hotspot.line}:${hotspot.function}`, + function: hotspot.function, + file: hotspot.file, + line: hotspot.line, + overlapPct: hotspot.totalPct, + samplePct: hotspot.totalPct, + rank: 1, + confidence: 'medium', + ...(hotspot.source ? { source: hotspot.source } : {}), + }; +} diff --git a/packages/detectors/src/detectors/external-buffer-pressure.ts b/packages/detectors/src/detectors/external-buffer-pressure.ts index f408bdc..dd0e468 100644 --- a/packages/detectors/src/detectors/external-buffer-pressure.ts +++ b/packages/detectors/src/detectors/external-buffer-pressure.ts @@ -1,5 +1,18 @@ -import type { BaseFinding, Finding, KindScopedDetector } from '@lanterna-profiler/core'; +import type { + BaseFinding, + Finding, + KindScopedDetector, + KindScopedDetectorShared, + MemoryHotAllocator, + MemorySummary, +} from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS } from '../config.js'; +import { + type CorrelatedAllocatorEvidence, + correlatedAllocatorFromCpuHotspot, + correlatedAllocatorFromMemory, +} from './memory-evidence.js'; +import { findActionableUserCpuHotspot } from './shared.js'; const BYTES_PER_MB = 1024 * 1024; @@ -12,31 +25,32 @@ const BYTES_PER_MB = 1024 * 1024; export const externalBufferPressureDetector: KindScopedDetector<'memory'> = { id: 'external-buffer-pressure', kindIds: ['memory'], - detect({ memory }): Finding[] { + detect({ memory }, shared): Finding[] { const thresholds = DETECTOR_THRESHOLDS.externalBufferPressure; - const series = memory.view.series; - const heapUsed = series.heapUsed; - const external = series.external; - const arrayBuffers = series.arrayBuffers; + const memorySeries = memory.view.series; + const heapUsed = memorySeries.heapUsed; + const external = memorySeries.external; + const arrayBuffers = memorySeries.arrayBuffers; if (!heapUsed || !external || !arrayBuffers) return []; const externalMeanMB = external.meanBytes / BYTES_PER_MB; if (externalMeanMB < thresholds.minExternalMeanMB) return []; - const ratio = external.meanBytes / Math.max(heapUsed.meanBytes, 1); - if (ratio < thresholds.warnRatio) return []; + const externalToHeapRatio = external.meanBytes / Math.max(heapUsed.meanBytes, 1); + if (externalToHeapRatio < thresholds.warnRatio) return []; const severity: BaseFinding['severity'] = - ratio >= thresholds.criticalRatio ? 'critical' : 'warning'; + externalToHeapRatio >= thresholds.criticalRatio ? 'critical' : 'warning'; const peakExternalMB = external.maxBytes / BYTES_PER_MB; const heapMeanMB = heapUsed.meanBytes / BYTES_PER_MB; + const correlatedAllocator = correlatedExternalAllocator(memory, shared); - const finding: BaseFinding> = { + const bufferPressureFinding: BaseFinding> = { id: 'external-buffer-pressure', profileKind: 'memory', severity, category: 'external-buffer-pressure', - title: `Off-heap memory is ${ratio.toFixed(1)}× the V8 heap`, + title: `Off-heap memory is ${externalToHeapRatio.toFixed(1)}× the V8 heap`, confidence: 'medium', proofLevel: 'heuristic', evidence: { @@ -45,7 +59,7 @@ export const externalBufferPressureDetector: KindScopedDetector<'memory'> = { function: 'external', selfPct: 0, extra: { - ratio, + ratio: externalToHeapRatio, externalMeanMB, peakExternalMB, heapMeanMB, @@ -53,11 +67,12 @@ export const externalBufferPressureDetector: KindScopedDetector<'memory'> = { externalMeanBytes: external.meanBytes, arrayBuffersMeanBytes: arrayBuffers.meanBytes, heapUsedMeanBytes: heapUsed.meanBytes, + ...(correlatedAllocator ? { correlatedAllocator } : {}), }, }, measurements: { observed: { - ratio, + ratio: externalToHeapRatio, externalMeanMB, peakExternalMB, heapMeanMB, @@ -76,6 +91,17 @@ export const externalBufferPressureDetector: KindScopedDetector<'memory'> = { 'https://nodejs.org/api/process.html#processmemoryusage', ], }; - return [finding]; + return [bufferPressureFinding]; }, }; + +function correlatedExternalAllocator( + memory: { report: { summary: MemorySummary; hotAllocators: readonly MemoryHotAllocator[] } }, + shared: KindScopedDetectorShared, +): CorrelatedAllocatorEvidence | undefined { + const cpuHotspots = shared.profiles.cpu?.hotspots ?? []; + return ( + correlatedAllocatorFromCpuHotspot(findActionableUserCpuHotspot(cpuHotspots)) ?? + correlatedAllocatorFromMemory(memory.report.summary, memory.report.hotAllocators) + ); +} diff --git a/packages/detectors/src/detectors/hot-async-context.ts b/packages/detectors/src/detectors/hot-async-context.ts index 0c235ee..41a47bb 100644 --- a/packages/detectors/src/detectors/hot-async-context.ts +++ b/packages/detectors/src/detectors/hot-async-context.ts @@ -5,7 +5,12 @@ import type { KindScopedDetector, } from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS } from '../config.js'; -import { anchorForFrame, asyncConfidence, asyncEvidenceExtra } from './async-evidence.js'; +import { + anchorForFrame, + asyncConfidence, + asyncEvidenceExtra, + resolveAsyncUserCaller, +} from './async-evidence.js'; /** * Cross-kind detector: maps hot CPU back to the async chain that produced it @@ -34,6 +39,12 @@ export const hotAsyncContextDetector: KindScopedDetector<'cpu' | 'async'> = { entry.cpuPct >= thresholds.criticalCpuPct ? 'critical' : 'warning'; const anchor = anchorForFrame(async.report, entry.executionFrame ?? entry.rootFrame); const frame = anchor.frame; + const userCaller = resolveAsyncUserCaller(undefined, entry.rootFrame, { + profilePct: entry.cpuPct, + supportPct: 100, + confidence: 'high', + basis: 'async-cpu-window', + }); findings.push({ id: `hot-async-context:${entry.rootAsyncId}`, profileKind: 'async', @@ -52,12 +63,14 @@ export const hotAsyncContextDetector: KindScopedDetector<'cpu' | 'async'> = { rootAsyncId: entry.rootAsyncId, rootKind: entry.rootKind, rootFrame: entry.rootFrame ?? null, + entryFrame: entry.rootFrame ?? null, executionFrame: entry.executionFrame ?? null, executionConfidence: entry.executionConfidence ?? null, cpuPct: entry.cpuPct, cpuMs: entry.cpuMs, contributingOperations: entry.contributingOperations, attributedCpuPct: attribution.attributedCpuPct, + ...(userCaller ? { userCaller } : {}), ...asyncEvidenceExtra(async.report, anchor), }, }, diff --git a/packages/detectors/src/detectors/json-on-hot-path.ts b/packages/detectors/src/detectors/json-on-hot-path.ts index a56c12b..93a6aa4 100644 --- a/packages/detectors/src/detectors/json-on-hot-path.ts +++ b/packages/detectors/src/detectors/json-on-hot-path.ts @@ -14,8 +14,11 @@ import { buildAttributionEvidence, type CpuHotspotContext, findStallCorrelation, + pickPrimaryCallerBySource, readFrameSourceText, resolveAttribution, + selfHotspotUserCaller, + sourceCallPatternForApi, } from './shared.js'; export const jsonOnHotPathDetector: KindScopedDetector<'cpu'> = { @@ -41,7 +44,14 @@ export const jsonOnHotPathDetector: KindScopedDetector<'cpu'> = { if (!patternMatch) continue; if (hotspot.category !== 'node:builtin' && hotspot.category !== 'native') continue; if (hotspot.totalPct < thresholds.minTotalPct && !familyExceeded) continue; - const finding = buildFinding(hotspot, patternMatch.api, categoryTotalPct, report, context); + const finding = buildFinding( + hotspot, + patternMatch.api, + categoryTotalPct, + report, + context, + cwd, + ); if (seen.has(finding.id)) continue; seen.add(finding.id); findings.push(finding); @@ -52,7 +62,7 @@ export const jsonOnHotPathDetector: KindScopedDetector<'cpu'> = { if (!hasDominantSelfCost(hotspot, thresholds.minTotalPct)) continue; const api = inlinedJsonApi(readFrameSourceText(hotspot, cwd), hotspot.line); if (!api) continue; - const finding = buildFinding(hotspot, api, categoryTotalPct, report, context); + const finding = buildFinding(hotspot, api, categoryTotalPct, report, context, cwd); if (seen.has(finding.id)) continue; seen.add(finding.id); findings.push(finding); @@ -95,13 +105,25 @@ function buildFinding( categoryTotalPct: number, report: { eventLoop: EventLoopReport }, context: CpuHotspotContext, + cwd: string, ): BuiltinFinding<'json-on-hot-path'> { const { attribution, caller, candidateCallers } = resolveAttribution(hotspot, context); + const sourceCaller = pickPrimaryCallerBySource( + candidateCallers, + cwd, + sourceCallPatternForApi(api), + ); + const evidenceAttribution = + sourceCaller ?? + attribution ?? + (hotspot.category === 'user' ? selfHotspotUserCaller(hotspot) : undefined); + const highConfidenceCaller = + evidenceAttribution?.confidence === 'high' ? evidenceAttribution : undefined; const evidenceExtra: JsonHotPathEvidenceExtra = { callee: hotspot.category === 'user' ? api : hotspot.function, calleeTotalPct: hotspot.totalPct, - ...buildAttributionEvidence(attribution, caller, candidateCallers), - eventLoopCorrelation: findStallCorrelation(caller, report), + ...buildAttributionEvidence(evidenceAttribution, highConfidenceCaller, candidateCallers), + eventLoopCorrelation: findStallCorrelation(sourceCaller ?? caller ?? attribution, report), categoryTotalPct: categoryTotalPct > 0 ? categoryTotalPct : undefined, }; const thresholds = DETECTOR_THRESHOLDS.jsonHotPath; @@ -112,7 +134,8 @@ function buildFinding( severity: hotspot.totalPct >= thresholds.criticalPct ? 'critical' : 'warning', title: `${api} on hot path`, hotspot, - caller, + caller: + sourceCaller ?? caller ?? (hotspot.category === 'user' ? evidenceAttribution : undefined), selfPct: hotspot.totalPct, extra: evidenceExtra, measurements: { diff --git a/packages/detectors/src/detectors/large-allocator.ts b/packages/detectors/src/detectors/large-allocator.ts index 9e00e45..aa7db66 100644 --- a/packages/detectors/src/detectors/large-allocator.ts +++ b/packages/detectors/src/detectors/large-allocator.ts @@ -1,4 +1,10 @@ -import type { BaseFinding, Finding, KindScopedDetector } from '@lanterna-profiler/core'; +import type { + BaseFinding, + Finding, + KindScopedDetector, + MemoryHotAllocator, + UserCallerAttribution, +} from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS } from '../config.js'; /** @@ -28,7 +34,7 @@ export const largeAllocatorDetector: KindScopedDetector<'memory'> = { }, }; -function isActionableAllocator(allocator: import('@lanterna-profiler/core').MemoryHotAllocator) { +function isActionableAllocator(allocator: MemoryHotAllocator) { if (allocator.category !== 'user' && allocator.category !== 'node_modules') return false; if (allocator.file.startsWith('node:')) return false; if (/^(?:native |extensions::|evalmachine\.|node:internal\/)/.test(allocator.file)) return false; @@ -38,25 +44,24 @@ function isActionableAllocator(allocator: import('@lanterna-profiler/core').Memo return true; } -function allocationSubtreeKey( - allocator: import('@lanterna-profiler/core').MemoryHotAllocator, -): string { +function allocationSubtreeKey(allocator: MemoryHotAllocator): string { return `${Math.round(allocator.totalBytes / 1024)}:${Math.round(allocator.totalPct * 10)}`; } -function isInclusiveWrapper(allocator: import('@lanterna-profiler/core').MemoryHotAllocator) { +function isInclusiveWrapper(allocator: MemoryHotAllocator) { if (allocator.totalBytes <= 0) return false; return allocator.selfBytes / allocator.totalBytes < 0.05; } function buildFinding( - allocator: import('@lanterna-profiler/core').MemoryHotAllocator, + allocator: MemoryHotAllocator, score: number, ): BaseFinding> { const thresholds = DETECTOR_THRESHOLDS.largeAllocator; const severity: BaseFinding['severity'] = score >= thresholds.criticalTotalPct ? 'critical' : 'warning'; const totalMB = allocator.totalBytes / (1024 * 1024); + const userCaller = allocatorUserCaller(allocator); return { id: `large-allocator:${allocator.id}`, profileKind: 'memory', @@ -79,6 +84,7 @@ function buildFinding( selfPct: allocator.selfPct, totalPct: allocator.totalPct, totalMB, + ...(userCaller ? { userCaller } : {}), }, }, measurements: { @@ -104,6 +110,23 @@ function buildFinding( }; } +function allocatorUserCaller(allocator: MemoryHotAllocator): UserCallerAttribution | undefined { + if (allocator.userCaller) return allocator.userCaller; + if (allocator.category !== 'user') return undefined; + return { + function: allocator.function, + file: allocator.file, + line: allocator.line, + column: allocator.column, + ...(allocator.source ? { source: allocator.source } : {}), + stackDistance: 0, + profilePct: allocator.totalPct, + supportPct: 100, + confidence: 'high', + basis: 'heap-sample-path', + }; +} + function formatBytes(bytes: number): string { if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; diff --git a/packages/detectors/src/detectors/long-await.ts b/packages/detectors/src/detectors/long-await.ts index 2e6e35d..989c59f 100644 --- a/packages/detectors/src/detectors/long-await.ts +++ b/packages/detectors/src/detectors/long-await.ts @@ -5,7 +5,12 @@ import type { KindScopedDetector, } from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS } from '../config.js'; -import { anchorForFrame, asyncConfidence, asyncEvidenceExtra } from './async-evidence.js'; +import { + anchorForFrame, + asyncConfidence, + asyncEvidenceExtra, + resolveAsyncUserCaller, +} from './async-evidence.js'; /** * Surfaces the longest-running async operations. Each finding is anchored on @@ -18,23 +23,27 @@ export const longAwaitDetector: KindScopedDetector<'async'> = { detect({ async }): Finding[] { const thresholds = DETECTOR_THRESHOLDS.longAwait; const report = async.report; + const captureDurationMs = async.view.bundle.durationMs; if (!report.summary.available) return []; if (report.summary.totalOperations < thresholds.minOperations) return []; const dropped = report.summary.recordsDropped > 0; const findings: Finding[] = []; - for (const op of report.topOperations) { + for (const op of rankLongAwaitOperations(report.topOperations)) { if (findings.length >= thresholds.maxFindings) break; + if (isBackgroundWindowOperation(op, captureDurationMs)) continue; + if (isShadowedByResumedOperation(op, report.topOperations)) continue; if (op.durationMs < thresholds.minDurationMs) break; // sorted desc const severity: BaseFinding['severity'] = op.durationMs >= thresholds.criticalDurationMs ? 'critical' : 'warning'; const category = op.kind === 'promise' ? 'long-promise-await' : 'long-io-await'; - const anchorFrame = - op.kind === 'promise' - ? (op.awaitFrame ?? op.promiseHandlerFrame ?? op.initFrame) - : (op.creationFrame ?? op.initFrame); + const anchorFrame = preferredLongAwaitFrame(op); const anchor = anchorForFrame(report, anchorFrame); const frame = anchor.frame; + const userCaller = resolveAsyncUserCaller(undefined, frame, { + confidence: op.overallConfidence ?? 'high', + basis: 'async-stack', + }); const baseConfidence: BaseFinding['confidence'] = op.orphan ? 'medium' : 'high'; const confidence: BaseFinding['confidence'] = dropped ? 'low' @@ -79,6 +88,7 @@ export const longAwaitDetector: KindScopedDetector<'async'> = { cdpAsyncContextConfidence: op.cdpAsyncContextConfidence ?? null, cpuAttributedSamples: op.cpuAttributedSamples ?? null, cpuAmbiguousSamples: op.cpuAmbiguousSamples ?? null, + ...(userCaller ? { userCaller } : {}), ...asyncEvidenceExtra(report, anchor), }, }, @@ -105,6 +115,58 @@ export const longAwaitDetector: KindScopedDetector<'async'> = { }, }; +function isBackgroundWindowOperation(op: AsyncTopOperation, captureDurationMs: number): boolean { + return op.runMs === 0 && op.durationMs > captureDurationMs * 0.9; +} + +function rankLongAwaitOperations(operations: readonly AsyncTopOperation[]): AsyncTopOperation[] { + return [...operations].sort((a, b) => { + const durationDelta = b.durationMs - a.durationMs; + if (Math.abs(durationDelta) <= 50) { + const aRan = a.runMs > 0 || a.runCount > 0; + const bRan = b.runMs > 0 || b.runCount > 0; + if (aRan !== bRan) return aRan ? -1 : 1; + } + return durationDelta; + }); +} + +function isShadowedByResumedOperation( + op: AsyncTopOperation, + operations: readonly AsyncTopOperation[], +): boolean { + if (op.runMs > 0 || op.runCount > 0) return false; + const opFrameKeys = new Set(op.initStack.map(asyncFrameKey)); + if (opFrameKeys.size === 0) return false; + return operations.some((other) => { + if (other.asyncId === op.asyncId) return false; + if (other.runMs <= 0 && other.runCount <= 0) return false; + if (Math.abs(other.durationMs - op.durationMs) > 50) return false; + return other.initStack.some((frame) => opFrameKeys.has(asyncFrameKey(frame))); + }); +} + +function asyncFrameKey(frame: NonNullable): string { + return `${frame.file}:${frame.line}:${frame.function}`; +} + +function preferredLongAwaitFrame( + op: AsyncTopOperation, +): AsyncTopOperation['initFrame'] | undefined { + return ( + firstEditableFrame(op.initStack) ?? + (op.kind === 'promise' + ? (op.awaitFrame ?? op.promiseHandlerFrame ?? op.initFrame) + : (op.creationFrame ?? op.initFrame)) + ); +} + +function firstEditableFrame( + frames: readonly NonNullable[], +): AsyncTopOperation['initFrame'] | undefined { + return frames.find(isUserEditableFrame); +} + function buildSuggestion(op: AsyncTopOperation, frame: AsyncTopOperation['initFrame']): string { const timeoutGuidance = 'Network and database calls should always carry a deadline (`AbortController`, axios `timeout`, `pg` statement_timeout, etc.).'; diff --git a/packages/detectors/src/detectors/memory-evidence.ts b/packages/detectors/src/detectors/memory-evidence.ts new file mode 100644 index 0000000..ea56977 --- /dev/null +++ b/packages/detectors/src/detectors/memory-evidence.ts @@ -0,0 +1,101 @@ +import type { + MemoryHotAllocator, + MemorySummary, + SummaryUserHotspot, +} from '@lanterna-profiler/core'; + +export interface CorrelatedAllocatorEvidence { + function: string; + file: string; + line: number; + totalPct: number; + selfPct?: number; + basis?: 'heap-sampled-allocator' | 'cpu-top-user-hotspot'; + userCaller?: MemoryHotAllocator['userCaller']; + source?: MemoryHotAllocator['source']; +} + +export function correlatedAllocatorFromMemory( + summary: MemorySummary, + hotAllocators: readonly MemoryHotAllocator[], +): CorrelatedAllocatorEvidence | undefined { + const topAllocator = summary.topAllocator; + if (!topAllocator) return undefined; + const allocator = selectCorrelatedMemoryAllocator(topAllocator, hotAllocators); + if (!allocator) return undefined; + return { + function: allocator.function, + file: allocator.file, + line: allocator.line, + totalPct: allocator.totalPct, + selfPct: allocator.selfPct, + basis: 'heap-sampled-allocator', + ...(allocator.userCaller ? { userCaller: allocator.userCaller } : {}), + ...(allocator.source ? { source: allocator.source } : {}), + }; +} + +export function correlatedAllocatorFromCpuHotspot( + hotspot: SummaryUserHotspot | undefined, +): CorrelatedAllocatorEvidence | undefined { + if (!hotspot) return undefined; + return { + function: hotspot.function, + file: hotspot.file, + line: hotspot.line, + totalPct: hotspot.totalPct, + selfPct: hotspot.selfPct, + basis: 'cpu-top-user-hotspot', + ...(hotspot.source ? { source: hotspot.source } : {}), + }; +} + +function selectCorrelatedMemoryAllocator( + topAllocator: NonNullable, + hotAllocators: readonly MemoryHotAllocator[], +): MemoryHotAllocator | NonNullable | undefined { + const matchingHotAllocator = hotAllocators.find( + (allocator) => + allocator.function === topAllocator.function && + allocator.file === topAllocator.file && + allocator.line === topAllocator.line, + ); + if (matchingHotAllocator && isEditableAllocator(matchingHotAllocator)) { + return matchingHotAllocator; + } + // Summary topAllocator lacks `category`; treat it as editable when the path + // is not a runtime path. + if (!matchingHotAllocator && !isRuntimeAllocatorPath(topAllocator.file)) return topAllocator; + return findEditableAllocatorForEvidence(hotAllocators, topAllocator.file); +} + +/** + * Returns the first editable allocator, preferring one from `preferredFile`. + * Anonymous user-code wrappers remain editable because their file/line is + * actionable. + */ +function findEditableAllocatorForEvidence( + hotAllocators: readonly MemoryHotAllocator[], + preferredFile: string, +): MemoryHotAllocator | undefined { + return ( + hotAllocators.find( + (allocator) => isEditableAllocator(allocator) && allocator.file === preferredFile, + ) ?? hotAllocators.find(isEditableAllocator) + ); +} + +/** + * An allocator is "editable" when it belongs to user code or a `node_modules` + * dependency. Runtime paths are excluded. + */ +function isEditableAllocator(allocator: Pick): boolean { + return ( + (allocator.category === 'user' || allocator.category === 'node_modules') && + !isRuntimeAllocatorPath(allocator.file) + ); +} + +function isRuntimeAllocatorPath(file: string): boolean { + return file.startsWith('node:') || file.includes('/node_modules/'); +} diff --git a/packages/detectors/src/detectors/memory-growth.ts b/packages/detectors/src/detectors/memory-growth.ts index 1f6ee90..6cde7b9 100644 --- a/packages/detectors/src/detectors/memory-growth.ts +++ b/packages/detectors/src/detectors/memory-growth.ts @@ -2,9 +2,12 @@ import type { BaseFinding, Finding, KindScopedDetector, + MemoryHotAllocator, MemorySeriesStats, + MemorySummary, } from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS } from '../config.js'; +import { correlatedAllocatorFromMemory } from './memory-evidence.js'; const BYTES_PER_MB = 1024 * 1024; @@ -29,12 +32,26 @@ export const memoryGrowthDetector: KindScopedDetector<'memory'> = { if (rss) { const finding = hasRssRetentionCorroboration(memory.view.series) - ? buildGrowthFinding('rss', rss, durationMs, sampleCount) + ? buildGrowthFinding( + 'rss', + rss, + durationMs, + sampleCount, + memory.report.summary, + memory.report.hotAllocators, + ) : null; if (finding) findings.push(finding); } if (heapUsed) { - const finding = buildGrowthFinding('heapUsed', heapUsed, durationMs, sampleCount); + const finding = buildGrowthFinding( + 'heapUsed', + heapUsed, + durationMs, + sampleCount, + memory.report.summary, + memory.report.hotAllocators, + ); if (finding) findings.push(finding); } return findings; @@ -60,6 +77,8 @@ function buildGrowthFinding( stats: MemorySeriesStats, durationMs: number, sampleCount: number, + summary: MemorySummary, + hotAllocators: readonly MemoryHotAllocator[], ): BaseFinding> | null { const slopeMBPerSec = toMBPerSec(stats); const thresholds = DETECTOR_THRESHOLDS.memoryGrowth; @@ -72,6 +91,7 @@ function buildGrowthFinding( metric === 'rss' && slopeMBPerSec >= critical ? 'critical' : 'warning'; const deltaMB = (stats.endBytes - stats.startBytes) / BYTES_PER_MB; const label = metric === 'rss' ? 'Resident set size' : 'V8 heap (heapUsed)'; + const allocator = correlatedAllocatorFromMemory(summary, hotAllocators); return { id: `memory-growth:${metric}`, @@ -95,6 +115,7 @@ function buildGrowthFinding( deltaMB, sampleCount, durationMs, + ...(allocator ? { correlatedAllocator: allocator } : {}), }, }, measurements: { diff --git a/packages/detectors/src/detectors/microtask-flood.ts b/packages/detectors/src/detectors/microtask-flood.ts index a9ccab9..40404e8 100644 --- a/packages/detectors/src/detectors/microtask-flood.ts +++ b/packages/detectors/src/detectors/microtask-flood.ts @@ -1,6 +1,11 @@ import type { BaseFinding, Finding, KindScopedDetector } from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS } from '../config.js'; -import { anchorForFrame, asyncConfidence, asyncEvidenceExtra } from './async-evidence.js'; +import { + anchorForFrame, + asyncConfidence, + asyncEvidenceExtra, + resolveAsyncUserCaller, +} from './async-evidence.js'; /** * Fires when the inflight async resource count stays high for the duration @@ -25,6 +30,10 @@ export const microtaskFloodDetector: KindScopedDetector<'async'> = { concurrency.maxInflight >= thresholds.criticalMaxInflight ? 'critical' : 'warning'; const anchor = anchorForFrame(report, undefined); const frame = anchor.frame; + const userCaller = resolveAsyncUserCaller(anchor.hotFile, frame, { + confidence: anchor.hotFile?.confidence ?? 'medium', + basis: 'async-stack', + }); return [ { @@ -47,6 +56,7 @@ export const microtaskFloodDetector: KindScopedDetector<'async'> = { meanActive: concurrency.meanActive, maxActive: concurrency.maxActive, samples: report.concurrencyTimeline.length, + ...(userCaller ? { userCaller } : {}), ...asyncEvidenceExtra(report, anchor), }, }, diff --git a/packages/detectors/src/detectors/node-modules-hotspot.ts b/packages/detectors/src/detectors/node-modules-hotspot.ts index 0b6d51b..0936b76 100644 --- a/packages/detectors/src/detectors/node-modules-hotspot.ts +++ b/packages/detectors/src/detectors/node-modules-hotspot.ts @@ -13,7 +13,9 @@ import { buildAttributionEvidence, type CpuHotspotContext, findStallCorrelation, + pickPrimaryCallerBySource, resolveAttribution, + sourcePatternForTerms, toAlternativeHotspotEvidence, } from './shared.js'; @@ -40,7 +42,9 @@ export const nodeModulesHotspotDetector: KindScopedDetector<'cpu'> = { }); const hotspot = matches[0]; if (!hotspot) return []; - return [buildFinding(hotspot, matches.slice(1, 3), report, context)]; + return [ + buildFinding(hotspot, matches.slice(1, 3), report, context, cpu.view.bundle.target.cwd), + ]; }, }; @@ -49,16 +53,25 @@ function buildFinding( alternatives: Hotspot[], report: { eventLoop: EventLoopReport }, context: CpuHotspotContext, + cwd: string, ): BuiltinFinding<'node-modules-hotspot'> { const { attribution, caller, candidateCallers } = resolveAttribution(hotspot, context); + const sourceCaller = pickPrimaryCallerBySource( + candidateCallers, + cwd, + sourcePatternForTerms([hotspot.package ?? '', hotspot.function]), + ); + const evidenceAttribution = sourceCaller ?? attribution; + const highConfidenceCaller = + evidenceAttribution?.confidence === 'high' ? evidenceAttribution : undefined; const evidenceExtra: NodeModulesHotspotEvidenceExtra = { package: hotspot.package, callee: hotspot.function, calleeFile: hotspot.file, calleeLine: hotspot.line, calleeTotalPct: hotspot.totalPct, - ...buildAttributionEvidence(attribution, caller, candidateCallers), - eventLoopCorrelation: findStallCorrelation(caller, report), + ...buildAttributionEvidence(evidenceAttribution, highConfidenceCaller, candidateCallers), + eventLoopCorrelation: findStallCorrelation(sourceCaller ?? caller ?? attribution, report), alternativeHotspots: alternatives.map(toAlternativeHotspotEvidence), }; const thresholds = DETECTOR_THRESHOLDS.nodeModulesHotspot; @@ -69,7 +82,7 @@ function buildFinding( severity: hotspot.totalPct >= thresholds.criticalTotalPct ? 'critical' : 'warning', title: `Dependency hotspot on hot path (${hotspot.package ?? hotspot.function})`, hotspot, - caller, + caller: sourceCaller ?? caller, selfPct: hotspot.totalPct, extra: evidenceExtra, measurements: { diff --git a/packages/detectors/src/detectors/orphan-async-resource.ts b/packages/detectors/src/detectors/orphan-async-resource.ts index 5e8b56a..e4dfba8 100644 --- a/packages/detectors/src/detectors/orphan-async-resource.ts +++ b/packages/detectors/src/detectors/orphan-async-resource.ts @@ -1,6 +1,11 @@ import type { BaseFinding, Finding, KindScopedDetector } from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS } from '../config.js'; -import { anchorForFrame, asyncConfidence, asyncEvidenceExtra } from './async-evidence.js'; +import { + anchorForFrame, + asyncConfidence, + asyncEvidenceExtra, + resolveAsyncUserCaller, +} from './async-evidence.js'; /** * Fires when many async resources never destroy/resolve before flush. A @@ -53,6 +58,12 @@ export const orphanAsyncResourceDetector: KindScopedDetector<'async'> = { totalOrphans >= thresholds.minOrphans * 2 ? 'high' : 'medium'; const anchor = anchorForFrame(report, dominantFrame?.sample); const frame = anchor.frame; + const userCallerConfidence: BaseFinding['confidence'] = + dominantFrame && dominantFrame.count === aged.length ? 'high' : baseConfidence; + const userCaller = resolveAsyncUserCaller(undefined, frame, { + confidence: userCallerConfidence, + basis: 'async-stack', + }); const confidence: BaseFinding['confidence'] = dropped ? 'low' : asyncConfidence(report, baseConfidence); @@ -80,6 +91,7 @@ export const orphanAsyncResourceDetector: KindScopedDetector<'async'> = { byKind, dominantKind, dominantFrameOccurrences: dominantFrame?.count ?? 0, + ...(userCaller ? { userCaller } : {}), ...asyncEvidenceExtra(report, anchor), samplePeak: aged.slice(0, 10).map((o) => ({ asyncId: o.asyncId, diff --git a/packages/detectors/src/detectors/require-in-hot-path.ts b/packages/detectors/src/detectors/require-in-hot-path.ts index 942e322..f4c83ab 100644 --- a/packages/detectors/src/detectors/require-in-hot-path.ts +++ b/packages/detectors/src/detectors/require-in-hot-path.ts @@ -11,6 +11,7 @@ import { buildAttributedFinding, buildAttributionEvidence, type CpuHotspotContext, + pickPrimaryCallerBySource, resolveAttribution, } from './shared.js'; @@ -28,7 +29,7 @@ export const requireInHotPathDetector: KindScopedDetector<'cpu'> = { if (hotspot.category !== 'node:builtin' && hotspot.category !== 'node_modules') continue; if (hotspot.selfPct < thresholds.minSelfPct && hotspot.totalPct < thresholds.minTotalPct) continue; - findings.push(buildFinding(hotspot, context)); + findings.push(buildFinding(hotspot, context, cpu.view.bundle.target.cwd)); } return findings; }, @@ -37,11 +38,20 @@ export const requireInHotPathDetector: KindScopedDetector<'cpu'> = { function buildFinding( hotspot: Hotspot, context: CpuHotspotContext, + cwd: string, ): BuiltinFinding<'require-in-hot-path'> { const { attribution, caller, candidateCallers } = resolveAttribution(hotspot, context); + const sourceCaller = pickPrimaryCallerBySource( + candidateCallers, + cwd, + /\b(?:require|import)\s*\(/, + ); + const evidenceAttribution = sourceCaller ?? attribution; + const highConfidenceCaller = + evidenceAttribution?.confidence === 'high' ? evidenceAttribution : undefined; const evidenceExtra: RequireInHotPathEvidenceExtra = { callee: hotspot.function, - ...buildAttributionEvidence(attribution, caller, candidateCallers), + ...buildAttributionEvidence(evidenceAttribution, highConfidenceCaller, candidateCallers), }; const thresholds = DETECTOR_THRESHOLDS.requireInHotPath; return defineBuiltinFinding( @@ -51,7 +61,7 @@ function buildFinding( category: 'require-in-hot-path', title: 'Module loading on hot path', hotspot, - caller, + caller: sourceCaller ?? caller, selfPct: hotspot.selfPct, extra: evidenceExtra, measurements: { diff --git a/packages/detectors/src/detectors/shared.ts b/packages/detectors/src/detectors/shared.ts index 2c309ec..b3d9625 100644 --- a/packages/detectors/src/detectors/shared.ts +++ b/packages/detectors/src/detectors/shared.ts @@ -72,6 +72,13 @@ export function exceedsCategoryThreshold(categoryTotalPct: number, thresholdPct: return categoryTotalPct >= thresholdPct; } +export function findActionableUserCpuHotspot( + hotspots: readonly Hotspot[], + minTotalPct = 1, +): Hotspot | undefined { + return hotspots.find((hotspot) => hotspot.category === 'user' && hotspot.totalPct > minTotalPct); +} + /** * Resolves the user-code caller most likely responsible for a non-user hotspot. * @@ -109,17 +116,73 @@ export function buildAttributionEvidence( caller: UserCallerAttribution | undefined, candidateCallers: readonly UserCallerAttribution[] = attribution ? [attribution] : [], ): AttributionEvidence { - const candidates = + const candidateCallerEvidence = candidateCallers.length > 0 ? [...candidateCallers] : attribution ? [attribution] : undefined; return { proofLevel: caller ? 'attributed-caller' : 'direct-builtin', attributionBasis: attribution ? 'sample-path' : 'builtin-only', attributionConfidence: attribution?.confidence ?? 'low', userCaller: attribution, - candidateCallers: candidates, + candidateCallers: candidateCallerEvidence, + }; +} + +export function selfHotspotUserCaller( + hotspot: Pick & { + column?: number; + totalPct?: number; + samplePct?: number; + }, +): UserCallerAttribution { + return { + function: hotspot.function, + file: hotspot.file, + line: hotspot.line, + column: hotspot.column, + ...(hotspot.source ? { source: hotspot.source } : {}), + stackDistance: 0, + profilePct: hotspot.totalPct ?? hotspot.samplePct ?? 0, + supportPct: 100, + confidence: 'high', + basis: 'cpu-sample-path', }; } +export function pickPrimaryCallerBySource( + candidateCallers: readonly UserCallerAttribution[], + cwd: string, + pattern: RegExp, +): UserCallerAttribution | undefined { + for (const candidate of candidateCallers) { + const sourceText = readFrameSourceText(candidate, cwd); + const anchorLine = candidate.source?.line ?? candidate.line; + const matchedLine = + findPatternLineNearAnchor(sourceText, anchorLine, pattern) ?? + findPatternLineInFunctionBlock(sourceText, anchorLine, pattern); + if (matchedLine === undefined) continue; + return { + ...candidate, + line: matchedLine, + ...(candidate.source ? { source: { ...candidate.source, line: matchedLine } } : {}), + }; + } + return undefined; +} + +export function sourceCallPatternForApi(api: string): RegExp { + const apiPathParts = api.split('.').filter(Boolean).map(escapeRegExp); + const apiLeafName = apiPathParts.at(-1); + if (!apiLeafName) return /$a/; + const dottedApiPattern = apiPathParts.join('\\s*\\.\\s*'); + return new RegExp(`\\b(?:${dottedApiPattern}|${apiLeafName})\\s*\\(`); +} + +export function sourcePatternForTerms(terms: readonly string[]): RegExp { + const escaped = terms.filter(Boolean).map(escapeRegExp); + if (escaped.length === 0) return /$a/; + return new RegExp(escaped.join('|')); +} + export function toAlternativeHotspotEvidence(hotspot: Hotspot): AlternativeHotspotEvidence { return { id: hotspot.id, @@ -290,12 +353,12 @@ export function readFrameSourceText( cwd: string, ): string | undefined { if (!frame) return undefined; - const candidates = [frame.source?.file, frame.file].filter((file): file is string => + const sourceFileCandidates = [frame.source?.file, frame.file].filter((file): file is string => Boolean(file), ); - for (const candidate of candidates) { - if (candidate.startsWith('node:') || candidate.startsWith('native ')) continue; - const path = isAbsolute(candidate) ? candidate : join(cwd, candidate); + for (const sourceFile of sourceFileCandidates) { + if (sourceFile.startsWith('node:') || sourceFile.startsWith('native ')) continue; + const path = isAbsolute(sourceFile) ? sourceFile : join(cwd, sourceFile); if (!existsSync(path)) continue; try { return readFileSync(path, 'utf8'); @@ -305,3 +368,55 @@ export function readFrameSourceText( } return undefined; } + +function findPatternLineNearAnchor( + sourceText: string | undefined, + line: number, + pattern: RegExp, + radius = 2, +): number | undefined { + if (!sourceText || line <= 0) return undefined; + const lines = sourceText.split(/\r?\n/); + const index = line - 1; + if (index < 0 || index >= lines.length) return undefined; + const start = Math.max(0, index - radius); + const end = Math.min(lines.length, index + radius + 1); + for (let current = start; current < end; current += 1) { + if (matchesSourceLine(lines[current] ?? '', pattern)) return current + 1; + } + return undefined; +} + +function findPatternLineInFunctionBlock( + sourceText: string | undefined, + line: number, + pattern: RegExp, +): number | undefined { + if (!sourceText || line <= 0) return undefined; + const lines = sourceText.split(/\r?\n/); + let depth = 0; + let enteredBlock = false; + for (let current = line - 1; current < lines.length; current += 1) { + const text = lines[current] ?? ''; + if (enteredBlock && matchesSourceLine(text, pattern)) return current + 1; + for (const char of text) { + if (char === '{') { + depth += 1; + enteredBlock = true; + } else if (char === '}') { + depth -= 1; + if (enteredBlock && depth <= 0) return undefined; + } + } + } + return undefined; +} + +function matchesSourceLine(line: string, pattern: RegExp): boolean { + pattern.lastIndex = 0; + return pattern.test(line); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/detectors/src/detectors/sync-crypto.ts b/packages/detectors/src/detectors/sync-crypto.ts index bc5c915..7f46b64 100644 --- a/packages/detectors/src/detectors/sync-crypto.ts +++ b/packages/detectors/src/detectors/sync-crypto.ts @@ -5,7 +5,6 @@ import type { FindingRemediation, Hotspot, SyncCryptoEvidenceExtra, - UserCallerAttribution, } from '@lanterna-profiler/core'; import { defineBuiltinFinding, stripOptPrefix } from '@lanterna-profiler/core'; import { DETECTOR_THRESHOLDS, SYNC_CRYPTO_FNS, SYNC_CRYPTO_PATTERNS } from '../config.js'; @@ -63,9 +62,10 @@ import { exceedsCategoryThreshold, findStallCorrelation, isBuiltinRuntimeHotspot, - readFrameSourceText, + pickPrimaryCallerBySource, resolveAttribution, severityForPct, + sourceCallPatternForApi, } from './shared.js'; export const syncCryptoDetector: KindScopedDetector<'cpu'> = { @@ -109,13 +109,22 @@ function buildFinding( cwd: string, ): BuiltinFinding<'sync-crypto'> { const { attribution, caller, candidateCallers } = resolveAttribution(hotspot, context); - const sourceCallsiteCaller = - findSourceCallsiteCaller(candidateCallers, hotspot.function, cwd) ?? caller; + const sourceCallsiteCaller = pickPrimaryCallerBySource( + candidateCallers, + cwd, + sourceCallPatternForApi(stripOptPrefix(hotspot.function)), + ); + const evidenceAttribution = sourceCallsiteCaller ?? attribution; + const highConfidenceCaller = + evidenceAttribution?.confidence === 'high' ? evidenceAttribution : undefined; const evidenceExtra: SyncCryptoEvidenceExtra = { callee: hotspot.function, calleeTotalPct: hotspot.totalPct, - ...buildAttributionEvidence(attribution, caller, candidateCallers), - eventLoopCorrelation: findStallCorrelation(caller ?? attribution, report), + ...buildAttributionEvidence(evidenceAttribution, highConfidenceCaller, candidateCallers), + eventLoopCorrelation: findStallCorrelation( + sourceCallsiteCaller ?? caller ?? attribution, + report, + ), categoryTotalPct: categoryTotalPct > 0 ? categoryTotalPct : undefined, }; const thresholds = DETECTOR_THRESHOLDS.syncCrypto; @@ -126,7 +135,7 @@ function buildFinding( severity: severityForPct(hotspot.totalPct, thresholds.criticalPct), title: `Synchronous crypto on hot path (${hotspot.function})`, hotspot, - caller: sourceCallsiteCaller, + caller: sourceCallsiteCaller ?? caller, selfPct: hotspot.totalPct, extra: evidenceExtra, measurements: { @@ -151,78 +160,3 @@ function buildFinding( }), ); } - -function findSourceCallsiteCaller( - candidates: readonly UserCallerAttribution[], - callee: string, - cwd: string, -): UserCallerAttribution | undefined { - const pattern = new RegExp(`\\b${escapeRegExp(callExpressionName(callee))}\\s*\\(`); - for (const candidate of candidates) { - const source = readFrameSourceText(candidate, cwd); - const anchorLine = candidate.source?.line ?? candidate.line; - const line = - findPatternLineNearAnchor(source, anchorLine, pattern) ?? - findPatternLineInFunctionBlock(source, anchorLine, pattern); - if (line !== undefined) { - return { - ...candidate, - line, - ...(candidate.source ? { source: { ...candidate.source, line } } : {}), - }; - } - } - return undefined; -} - -function callExpressionName(callee: string): string { - const normalized = stripOptPrefix(callee); - return normalized.split('.').at(-1) ?? normalized; -} - -function findPatternLineNearAnchor( - sourceText: string | undefined, - line: number, - pattern: RegExp, - radius = 2, -): number | undefined { - if (!sourceText || line <= 0) return undefined; - const lines = sourceText.split(/\r?\n/); - const index = line - 1; - if (index < 0 || index >= lines.length) return undefined; - const start = Math.max(0, index - radius); - const end = Math.min(lines.length, index + radius + 1); - for (let current = start; current < end; current += 1) { - if (pattern.test(lines[current] ?? '')) return current + 1; - } - return undefined; -} - -function findPatternLineInFunctionBlock( - sourceText: string | undefined, - line: number, - pattern: RegExp, -): number | undefined { - if (!sourceText || line <= 0) return undefined; - const lines = sourceText.split(/\r?\n/); - let depth = 0; - let enteredBlock = false; - for (let current = line - 1; current < lines.length; current += 1) { - const text = lines[current] ?? ''; - if (enteredBlock && pattern.test(text)) return current + 1; - for (const char of text) { - if (char === '{') { - depth += 1; - enteredBlock = true; - } else if (char === '}') { - depth -= 1; - if (enteredBlock && depth <= 0) return undefined; - } - } - } - return undefined; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/packages/detectors/test/async-detectors.test.ts b/packages/detectors/test/async-detectors.test.ts index ee594c2..38ed0b9 100644 --- a/packages/detectors/test/async-detectors.test.ts +++ b/packages/detectors/test/async-detectors.test.ts @@ -276,6 +276,13 @@ describe('microtask-flood detector', () => { hotFileRank: 1, recordsDropped: 0, sampledStackRatio: 1, + userCaller: { + function: 'scheduleFanout', + file: '/app/src/fanout.js', + line: 31, + confidence: 'high', + basis: 'async-stack', + }, }); }); @@ -297,6 +304,89 @@ describe('microtask-flood detector', () => { expect(finding?.evidence.function).toBe('fetchUser'); expect(finding?.evidence.file).toBe('/app/src/users.js'); expect(finding?.evidence.line).toBe(42); + expect(finding?.evidence.extra).toMatchObject({ + userCaller: { + function: 'fetchUser', + file: '/app/src/users.js', + line: 42, + confidence: 'high', + basis: 'async-stack', + }, + }); + }); + + it('long-await prefers the async operation init stack over an outer await frame', () => { + const records: AsyncOperationRecord[] = []; + for (let i = 0; i < 8; i++) records.push(makeRecord(100 + i, 1, 'promise', 10)); + const slow = makeRecord(999, 1, 'promise', 1500); + slow.initStack = [ + { function: 'slowFetch', file: 'file:///app/src/api.js', line: 24, column: 2 }, + { function: 'loop', file: 'file:///app/src/runner.js', line: 8, column: 2 }, + ]; + slow.awaitStack = [{ function: 'loop', file: 'file:///app/src/runner.js', line: 8, column: 2 }]; + records.push(slow); + const bundle = makeBundle({ records }); + const pipeline = createAnalysisPipeline({ + kinds: [createAsyncProfileKind()], + findingAnalyzers: [createFindingAnalyzerFromKindScopedDetector(longAwaitDetector)], + }); + const result = pipeline.run(bundle, { command: ['node', 'app.js'], mode: 'spawn' }); + const finding = result.findings.find((f) => f.id === 'long-await:999'); + + expect(finding?.evidence.function).toBe('slowFetch'); + expect(finding?.evidence.file).toBe('/app/src/api.js'); + expect(finding?.evidence.extra).toMatchObject({ + userCaller: { + function: 'slowFetch', + file: '/app/src/api.js', + line: 24, + }, + }); + }); + + it('long-await skips idle background timers that span the capture window', () => { + const records: AsyncOperationRecord[] = []; + for (let i = 0; i < 8; i++) records.push(makeRecord(100 + i, 1, 'promise', 10)); + records.push( + withFrame(makeRecord(999, 1, 'timer', 4800), { + function: 'testHarnessTimeout', + file: 'file:///app/src/runner.js', + line: 4, + }), + ); + const bundle = makeBundle({ records, durationMs: 5000 }); + const pipeline = createAnalysisPipeline({ + kinds: [createAsyncProfileKind()], + findingAnalyzers: [createFindingAnalyzerFromKindScopedDetector(longAwaitDetector)], + }); + const result = pipeline.run(bundle, { command: ['node', 'app.js'], mode: 'spawn' }); + + expect(result.findings.some((f) => f.id === 'long-await:999')).toBe(false); + }); + + it('long-await prefers a near-tied operation that actually resumed', () => { + const records: AsyncOperationRecord[] = []; + for (let i = 0; i < 8; i++) records.push(makeRecord(100 + i, 1, 'promise', 10)); + const wrapper = makeRecord(998, 1, 'promise', 1500); + wrapper.initStack = [ + { function: 'runBatch', file: 'file:///app/src/app.js', line: 14, column: 2 }, + { function: 'loop', file: 'file:///app/src/app.js', line: 27, column: 2 }, + ]; + const resumed = makeRecord(999, 1, 'promise', 1498); + resumed.runMs = 0.4; + resumed.runCount = 1; + resumed.initStack = [{ function: 'loop', file: 'file:///app/src/app.js', line: 27, column: 2 }]; + records.push(wrapper, resumed); + const bundle = makeBundle({ records }); + const pipeline = createAnalysisPipeline({ + kinds: [createAsyncProfileKind()], + findingAnalyzers: [createFindingAnalyzerFromKindScopedDetector(longAwaitDetector)], + }); + const result = pipeline.run(bundle, { command: ['node', 'app.js'], mode: 'spawn' }); + const longAwaitFindings = result.findings.filter((f) => f.id.startsWith('long-await:')); + + expect(longAwaitFindings[0]?.id).toBe('long-await:999'); + expect(longAwaitFindings[0]?.evidence.function).toBe('loop'); }); it('downgrades confidence when recordsDropped > 0', () => { @@ -422,6 +512,90 @@ describe('hot-async-context detector', () => { expect(finding?.evidence.function).toBe('requestHandler'); expect(finding?.evidence.file).toBe('/app/src/server.js'); expect(finding?.severity).toBe('critical'); + expect(finding?.evidence.extra).toMatchObject({ + entryFrame: { + function: 'requestHandler', + file: '/app/src/server.js', + line: 88, + }, + userCaller: { + function: 'requestHandler', + file: '/app/src/server.js', + line: 88, + confidence: 'high', + basis: 'async-cpu-window', + }, + }); + }); + + it('keeps the CPU execution frame as evidence and exposes the async entry frame', () => { + const root = makeRecord(1, 0, 'promise', 200, 0); + root.runWindows = [{ startMs: 0, endMs: 200 }]; + root.initStack = [ + { function: 'processRequest', file: 'file:///app/src/server.js', line: 42, column: 2 }, + ]; + const records = [root]; + const bundle = makeBundle({ records }); + bundle.kinds.cpu = { + cpuProfile: { + nodes: [ + { + id: 1, + callFrame: { + functionName: '(root)', + scriptId: '0', + url: '', + lineNumber: -1, + columnNumber: -1, + }, + hitCount: 0, + children: [2], + }, + { + id: 2, + callFrame: { + functionName: 'heavyComputation', + scriptId: '1', + url: 'file:///app/src/cpu.js', + lineNumber: 10, + columnNumber: 0, + }, + hitCount: 200, + children: [], + }, + ], + startTime: 0, + endTime: 200_000, + samples: Array.from({ length: 200 }, () => 2), + timeDeltas: Array.from({ length: 200 }, () => 1000), + }, + deopts: [], + samplesTimed: true, + }; + const pipeline = createAnalysisPipeline({ + kinds: [ + createCpuProfileKind({ readStderrSoFar: () => '', sampleIntervalMicros: 1000 }), + createAsyncProfileKind(), + ], + findingAnalyzers: [createFindingAnalyzerFromKindScopedDetector(hotAsyncContextDetector)], + }); + const result = pipeline.run(bundle, { command: ['node', 'app.js'], mode: 'spawn' }); + const finding = result.findings.find((f) => f.id.startsWith('hot-async-context:')); + + expect(finding?.evidence.function).toBe('heavyComputation'); + expect(finding?.evidence.extra).toMatchObject({ + entryFrame: { + function: 'processRequest', + file: '/app/src/server.js', + line: 42, + }, + userCaller: { + function: 'processRequest', + file: '/app/src/server.js', + line: 42, + basis: 'async-cpu-window', + }, + }); }); it('skips silently when CPU kind is absent', () => { @@ -470,6 +644,12 @@ describe('deep async chain anchoring', () => { rootFrame: expect.objectContaining({ file: '/app/src/root.js' }), deepestFrame: expect.objectContaining({ file: '/app/src/deep.js' }), asyncQuality: 'high', + userCaller: { + function: 'step2', + file: '/app/src/deep.js', + confidence: 'high', + basis: 'async-stack', + }, }); }); }); @@ -488,6 +668,36 @@ describe('async detector edge cases', () => { expect(finding?.severity).toBe('critical'); }); + it('adds userCaller evidence for orphan async resources with init stacks', () => { + const records: AsyncOperationRecord[] = []; + for (let i = 0; i < 60; i++) { + records.push( + withFrame(makeRecord(100 + i, 1, 'tcp', undefined, 1000), { + function: 'openSocket', + file: 'file:///app/src/socket.js', + line: 16, + }), + ); + } + const bundle = makeBundle({ records, durationMs: 5000 }); + const pipeline = createAnalysisPipeline({ + kinds: [createAsyncProfileKind()], + findingAnalyzers: [createFindingAnalyzerFromKindScopedDetector(orphanAsyncResourceDetector)], + }); + const result = pipeline.run(bundle, { command: ['node', 'app.js'], mode: 'spawn' }); + const finding = result.findings.find((f) => f.id === 'orphan-async-resource'); + + expect(finding?.evidence.extra).toMatchObject({ + userCaller: { + function: 'openSocket', + file: '/app/src/socket.js', + line: 16, + confidence: 'high', + basis: 'async-stack', + }, + }); + }); + it('skips orphans younger than minOrphanAgeMs', () => { const records: AsyncOperationRecord[] = []; // 200 fresh orphans (initAt close to capture end → ageMs < 1s). diff --git a/packages/detectors/test/findings.test.ts b/packages/detectors/test/findings.test.ts index fb884bf..9afc03f 100644 --- a/packages/detectors/test/findings.test.ts +++ b/packages/detectors/test/findings.test.ts @@ -377,7 +377,7 @@ describe('findings – sync-crypto exact source callsite', () => { 'function processBatch(size) {', ' const out = [];', ' for (let i = 0; i < size; i++) {', - ' out.push(hashPassword(`user-${i}`, `salt-${i}`));', + ` out.push(hashPassword(\`user-\${i}\`, \`salt-\${i}\`));`, ' }', ' return out;', '}', @@ -536,6 +536,130 @@ describe('findings – excessive-gc', () => { ); assert.ok(f.suggestion.length > 10); }); + + it('excessive-gc promotes the top correlated hotspot as userCaller evidence', () => { + const correlatedReport = createReport( + makeRaw( + { + nodes: [ + { + id: 1, + callFrame: { + functionName: '(root)', + scriptId: '0', + url: '', + lineNumber: -1, + columnNumber: -1, + }, + hitCount: 0, + children: [2], + }, + { + id: 2, + callFrame: { + functionName: 'allocatePayload', + scriptId: '1', + url: `file://${CWD}/src/alloc.js`, + lineNumber: 10, + columnNumber: 0, + }, + hitCount: 100, + children: [], + }, + ], + startTime: 1000000, + endTime: 1100000, + samples: Array(100).fill(2), + timeDeltas: Array(100).fill(1000), + }, + { + durationMs: 100, + gcEvents: [{ atMs: 30, kind: 'markSweep', durationMs: 120 }], + }, + ), + { sampleIntervalMicros: 1000, deep: false, command: ['node', 'app.js'] }, + ); + const f = findFindingOrFail( + correlatedReport, + (finding) => finding.id === 'excessive-gc', + 'excessive-gc finding', + ); + const userCaller = (f.evidence.extra as Record).userCaller as + | Record + | undefined; + + assert.equal(userCaller?.function, f.evidence.function); + assert.equal(userCaller?.file, f.evidence.file); + assert.equal(userCaller?.basis, 'cpu-sample-path'); + assert.equal(userCaller?.confidence, 'high'); + }); + + it('falls back to the top user hotspot when GC timing events are absent', () => { + const ratioOnlyReport = createReport( + makeRaw( + { + nodes: [ + { + id: 1, + callFrame: { + functionName: '(root)', + scriptId: '0', + url: '', + lineNumber: -1, + columnNumber: -1, + }, + hitCount: 0, + children: [2, 3], + }, + { + id: 2, + callFrame: { + functionName: '(garbage collector)', + scriptId: '0', + url: '', + lineNumber: -1, + columnNumber: -1, + }, + hitCount: 60, + children: [], + }, + { + id: 3, + callFrame: { + functionName: 'allocBurst', + scriptId: '1', + url: `file://${CWD}/src/app.js`, + lineNumber: 2, + columnNumber: 0, + }, + hitCount: 40, + children: [], + }, + ], + startTime: 1000000, + endTime: 2000000, + samples: [...Array(60).fill(2), ...Array(40).fill(3)], + timeDeltas: Array(100).fill(1000), + }, + { durationMs: 1000, gcEvents: [] }, + ), + { sampleIntervalMicros: 1000, deep: false, command: ['node', 'app.js'] }, + ); + + const f = findFindingOrFail( + ratioOnlyReport, + (finding) => finding.id === 'excessive-gc', + 'ratio-only excessive-gc finding', + ); + const extra = f.evidence.extra as Record; + const userCaller = extra.userCaller as Record | undefined; + const candidateHotspots = extra.candidateHotspots as Array>; + + assert.equal(f.evidence.function, 'allocBurst'); + assert.equal(userCaller?.function, 'allocBurst'); + assert.equal(userCaller?.basis, 'cpu-sample-path'); + assert.equal(candidateHotspots[0]?.function, 'allocBurst'); + }); }); describe('findings – excessive-gc confidence gating', () => { @@ -875,6 +999,122 @@ describe('findings – blocking-io false positive suppression', () => { }); }); +describe('findings – blocking-io exact source callsite', () => { + const dir = mkdtempSync(join(tmpdir(), 'lanterna-blocking-source-')); + const sourcePath = join(dir, 'app.mjs'); + writeFileSync( + sourcePath, + [ + "import { readFileSync } from 'node:fs';", + '', + 'function loadConfig() {', + " return readFileSync('./config.json', 'utf8');", + '}', + '', + 'function processIteration() {', + ' loadConfig();', + " return 'ok';", + '}', + '', + 'processIteration();', + ].join('\n'), + ); + + const profile: RawCpuProfile = { + nodes: [ + { + id: 1, + callFrame: { + functionName: '(root)', + scriptId: '0', + url: '', + lineNumber: -1, + columnNumber: -1, + }, + hitCount: 0, + children: [2], + }, + { + id: 2, + callFrame: { + functionName: 'processIteration', + scriptId: '1', + url: pathToFileURL(sourcePath).href, + lineNumber: 6, + columnNumber: 0, + }, + hitCount: 0, + children: [3, 4], + }, + { + id: 3, + callFrame: { + functionName: 'readFileSync', + scriptId: '0', + url: 'node:fs', + lineNumber: 0, + columnNumber: 0, + }, + hitCount: 60, + children: [], + }, + { + id: 4, + callFrame: { + functionName: 'loadConfig', + scriptId: '1', + url: pathToFileURL(sourcePath).href, + lineNumber: 2, + columnNumber: 0, + }, + hitCount: 0, + children: [5], + }, + { + id: 5, + callFrame: { + functionName: 'readFileSync', + scriptId: '0', + url: 'node:fs', + lineNumber: 0, + columnNumber: 0, + }, + hitCount: 40, + children: [], + }, + ], + startTime: 1000000, + endTime: 2000000, + samples: Array(60).fill(3).concat(Array(40).fill(5)), + timeDeltas: [], + }; + + const report = createReport(makeRaw(profile, { target: { cwd: dir } }), { + sampleIntervalMicros: 1000, + deep: false, + command: ['node', sourcePath], + }); + + it('uses the source line that directly calls the blocking API as primary user caller', () => { + const finding = findFindingOrFail( + report, + (f) => f.id === 'blocking-io:fs.readFileSync', + 'blocking-io finding', + ); + const userCaller = (finding.evidence.extra as Record).userCaller as Record< + string, + unknown + >; + + assert.equal(finding.evidence.function, 'loadConfig'); + assert.equal(finding.evidence.line, 4); + assert.equal(userCaller.function, 'loadConfig'); + assert.equal(userCaller.line, 4); + }); + + rmSync(dir, { recursive: true, force: true }); +}); + describe('findings – deopt-loop', () => { const report = createReport( makeRaw( @@ -926,11 +1166,11 @@ describe('findings – deopt-loop', () => { { sampleIntervalMicros: 1000, deep: true, command: ['node', 'app.js'] }, ); - it('detects deopt-loop when same function deoptimised ≥ 5 times in deep mode', () => { + it('detects deopt-loop when same function deoptimised ≥ 3 times in deep mode', () => { findFindingOrFail(report, (f) => f.id.startsWith('deopt-loop:'), 'deopt-loop finding'); }); - it('deopt-loop finding has warning severity for count 5-20', () => { + it('deopt-loop finding has warning severity below the critical threshold', () => { const f = findFindingOrFail( report, (finding) => finding.id.startsWith('deopt-loop:'), @@ -997,6 +1237,67 @@ describe('findings – deopt-loop', () => { assert.equal(f.severity, 'critical'); }); + it('detects a 3-count deopt loop and resolves through the CPU profile location', () => { + const unknownReport = createReport( + makeRaw( + { + nodes: [ + { + id: 1, + callFrame: { + functionName: '(root)', + scriptId: '0', + url: '', + lineNumber: -1, + columnNumber: -1, + }, + hitCount: 0, + children: [2], + }, + { + id: 2, + callFrame: { + functionName: 'polymorphic', + scriptId: '1', + url: `file://${CWD}/src/poly.js`, + lineNumber: 12, + columnNumber: 0, + }, + hitCount: 100, + children: [], + }, + ], + startTime: 1000000, + endTime: 2000000, + samples: Array(100).fill(2), + timeDeltas: [], + }, + { + deopts: [ + { + function: '', + file: `${CWD}/src/poly.js`, + line: 13, + reason: 'wrong map', + bailoutType: 'soft', + count: 3, + }, + ], + }, + ), + { sampleIntervalMicros: 1000, deep: true, command: ['node', 'app.js'] }, + ); + + const finding = findFindingOrFail( + unknownReport, + (f) => f.id === 'deopt-loop:polymorphic', + 'resolved deopt-loop finding', + ); + + assert.equal(finding.evidence.function, 'polymorphic'); + assert.equal((finding.evidence.extra as Record).count, 3); + }); + it('aggregates deopt traces without file and line by function name', () => { const raw = makeRaw( { @@ -1068,6 +1369,86 @@ describe('findings – deopt-loop', () => { assert.equal(finding.evidence.file, 'src/shapes.js'); }); + it('attributes location-less deopts to the single hot named deopt function', () => { + const raw = makeRaw( + { + nodes: [ + { + id: 1, + callFrame: { + functionName: '(root)', + scriptId: '0', + url: '', + lineNumber: -1, + columnNumber: -1, + }, + hitCount: 0, + children: [2], + }, + { + id: 2, + callFrame: { + functionName: 'polymorphic', + scriptId: '1', + url: `file://${CWD}/src/poly.js`, + lineNumber: 0, + columnNumber: 0, + }, + hitCount: 100, + children: [], + }, + ], + startTime: 1000000, + endTime: 2000000, + samples: Array(100).fill(2), + timeDeltas: [], + }, + { + deopts: [ + { + function: 'polymorphic', + file: '', + line: 0, + reason: 'wrong map', + bailoutType: 'deopt-eager', + count: 1, + }, + { + function: 'polymorphic', + file: '', + line: 0, + reason: 'overflow', + bailoutType: 'deopt-eager', + count: 1, + }, + { + function: '', + file: '', + line: 0, + reason: 'prepare for on stack replacement (OSR)', + bailoutType: 'deopt-eager', + count: 1, + }, + ], + }, + ); + const deoptReport = createReport(raw, { + sampleIntervalMicros: 1000, + deep: true, + command: ['node', 'app.js'], + }); + + const finding = findFindingOrFail( + deoptReport, + (f) => f.id === 'deopt-loop:polymorphic', + 'deopt-loop finding with inferred location-less unknown deopt', + ); + + assert.equal(finding.evidence.function, 'polymorphic'); + assert.equal(finding.evidence.file, 'src/poly.js'); + assert.equal((finding.evidence.extra as Record).count, 3); + }); + it('does not emit deopt-loop without --deep mode', () => { const noDeepReport = createReport( makeRaw( @@ -1409,6 +1790,14 @@ describe('findings – json-on-hot-path', () => { assert.equal(finding.evidence.function, 'serializeResponse'); assert.equal((finding.evidence.extra as Record).callee, 'JSON.stringify'); + const userCaller = (finding.evidence.extra as Record).userCaller as + | Record + | undefined; + assert.equal(userCaller?.function, 'serializeResponse'); + assert.equal(userCaller?.file, 'http.js'); + assert.equal(userCaller?.line, 2); + assert.equal(userCaller?.confidence, 'high'); + assert.equal(userCaller?.basis, 'cpu-sample-path'); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/packages/detectors/test/memory-detectors.test.ts b/packages/detectors/test/memory-detectors.test.ts index 2a5408f..e494cbe 100644 --- a/packages/detectors/test/memory-detectors.test.ts +++ b/packages/detectors/test/memory-detectors.test.ts @@ -101,6 +101,117 @@ function singleAllocatorProfile( }; } +function anonymousTimerAllocatorProfile(): RawSamplingHeapProfile { + return { + head: { + callFrame: { functionName: '(root)', scriptId: '0', url: '', lineNumber: 0, columnNumber: 0 }, + selfSize: 0, + id: 1, + children: [ + { + callFrame: { + functionName: '(anonymous)', + scriptId: '1', + url: 'file:///app/src/app.js', + lineNumber: 9, + columnNumber: 29, + }, + selfSize: 9000, + id: 2, + children: [ + { + callFrame: { + functionName: 'addToCache', + scriptId: '1', + url: 'file:///app/src/app.js', + lineNumber: 2, + columnNumber: 19, + }, + selfSize: 100, + id: 3, + children: [], + }, + ], + }, + ], + }, + samples: [], + }; +} + +function timerOnlyAllocatorProfile(): RawSamplingHeapProfile { + return { + head: { + callFrame: { functionName: '(root)', scriptId: '0', url: '', lineNumber: 0, columnNumber: 0 }, + selfSize: 0, + id: 1, + children: [ + { + callFrame: { + functionName: 'listOnTimeout', + scriptId: '2', + url: 'node:internal/timers', + lineNumber: 547, + columnNumber: 24, + }, + selfSize: 0, + id: 2, + children: [ + { + callFrame: { + functionName: 'processTimers', + scriptId: '2', + url: 'node:internal/timers', + lineNumber: 527, + columnNumber: 24, + }, + selfSize: 1024, + id: 3, + children: [], + }, + ], + }, + ], + }, + samples: [], + }; +} + +function bufferAllocatorCpuProfile(): RawCpuProfile { + return { + nodes: [ + { + id: 1, + callFrame: { + functionName: '(root)', + scriptId: '0', + url: '', + lineNumber: -1, + columnNumber: -1, + }, + hitCount: 0, + children: [2], + }, + { + id: 2, + callFrame: { + functionName: 'appendChunk', + scriptId: '1', + url: 'file:///app/src/buffers.js', + lineNumber: 11, + columnNumber: 4, + }, + hitCount: 100, + children: [], + }, + ], + startTime: 1000000, + endTime: 2000000, + samples: Array(100).fill(2), + timeDeltas: Array(100).fill(1000), + }; +} + function growingSeries(slopeBytesPerMs: number, externalMB = 1, count = 25): MemoryUsageSample[] { const out: MemoryUsageSample[] = []; for (let i = 0; i < count; i++) { @@ -169,6 +280,14 @@ describe('memory-growth detector', () => { expect(rssFinding).toBeDefined(); expect(rssFinding?.severity).toBe('critical'); expect(rssFinding?.profileKind).toBe('memory'); + expect(rssFinding?.evidence.extra).toMatchObject({ + correlatedAllocator: { + function: 'alloc', + file: 'src/a.js', + line: 2, + totalPct: 100, + }, + }); }); it('recommends Lanterna heap snapshot analysis before external heap tooling', () => { @@ -188,6 +307,27 @@ describe('memory-growth detector', () => { expect(rssFinding?.suggestion).not.toContain('Chrome DevTools or `--inspect`'); }); + it('keeps an anonymous user allocator wrapper when it is the dominant allocation site', () => { + const bundle = makeBundle({ + samplingProfile: anonymousTimerAllocatorProfile(), + memoryUsageSamples: externalGrowthSeries(6 * 1024), + }); + const pipeline = createAnalysisPipeline({ + kinds: [createMemoryProfileKind()], + findingAnalyzers: [createFindingAnalyzerFromKindScopedDetector(memoryGrowthDetector)], + }); + const result = pipeline.run(bundle, { command: ['node', 'app.js'], mode: 'spawn' }); + + const rssFinding = result.findings.find((f) => f.id === 'memory-growth:rss'); + expect(rssFinding?.evidence.extra).toMatchObject({ + correlatedAllocator: { + function: '(anonymous)', + file: 'src/app.js', + line: 10, + }, + }); + }); + it('does not fire when growth is below threshold', () => { const bundle = makeBundle({ samplingProfile: singleAllocatorProfile('alloc', 'file:///app/src/a.js', 1, 1024), @@ -272,6 +412,15 @@ describe('large-allocator detector', () => { expect(finding).toBeDefined(); expect(finding?.severity).toBe('critical'); expect(finding?.evidence.function).toBe('big'); + expect(finding?.evidence.extra).toMatchObject({ + userCaller: { + function: 'big', + file: 'src/big.js', + line: 8, + confidence: 'high', + basis: 'heap-sample-path', + }, + }); }); it('ignores non-actionable native and builtin allocator frames', () => { @@ -463,6 +612,81 @@ describe('external-buffer-pressure detector', () => { const finding = result.findings.find((f) => f.id === 'external-buffer-pressure'); expect(finding).toBeDefined(); expect(finding?.severity).toBe('warning'); + expect(finding?.evidence.extra).toMatchObject({ + correlatedAllocator: { + function: 'alloc', + file: 'src/a.js', + line: 2, + totalPct: 100, + }, + }); + }); + + it('does not expose a node builtin heap allocator as external pressure culprit', () => { + const samples: MemoryUsageSample[] = []; + for (let i = 0; i < 20; i++) { + samples.push({ + atMs: i * 200, + rss: 500 * MB, + heapTotal: 50 * MB, + heapUsed: 4 * MB, + external: 400 * MB, + arrayBuffers: 390 * MB, + }); + } + const bundle = makeBundle({ + samplingProfile: timerOnlyAllocatorProfile(), + memoryUsageSamples: samples, + }); + const pipeline = createAnalysisPipeline({ + kinds: [createMemoryProfileKind()], + findingAnalyzers: [ + createFindingAnalyzerFromKindScopedDetector(externalBufferPressureDetector), + ], + }); + const result = pipeline.run(bundle, { command: ['node', 'app.js'], mode: 'spawn' }); + + const finding = result.findings.find((f) => f.id === 'external-buffer-pressure'); + expect(finding?.evidence.extra).not.toHaveProperty('correlatedAllocator'); + }); + + it('prefers a CPU user hotspot over heap-sampled timer wrappers for external pressure', () => { + const samples: MemoryUsageSample[] = []; + for (let i = 0; i < 20; i++) { + samples.push({ + atMs: i * 200, + rss: 500 * MB, + heapTotal: 50 * MB, + heapUsed: 4 * MB, + external: 400 * MB, + arrayBuffers: 390 * MB, + }); + } + const bundle = makeBundle({ + samplingProfile: timerOnlyAllocatorProfile(), + memoryUsageSamples: samples, + cpuProfile: bufferAllocatorCpuProfile(), + }); + const pipeline = createAnalysisPipeline({ + kinds: [ + createCpuProfileKind({ readStderrSoFar: () => '', sampleIntervalMicros: 1000 }), + createMemoryProfileKind(), + ], + findingAnalyzers: [ + createFindingAnalyzerFromKindScopedDetector(externalBufferPressureDetector), + ], + }); + const result = pipeline.run(bundle, { command: ['node', 'app.js'], mode: 'spawn' }); + + const finding = result.findings.find((f) => f.id === 'external-buffer-pressure'); + expect(finding?.evidence.extra).toMatchObject({ + correlatedAllocator: { + function: 'appendChunk', + file: 'src/buffers.js', + line: 12, + basis: 'cpu-top-user-hotspot', + }, + }); }); it('does not double-count arrayBuffers because process.memoryUsage external already includes it', () => { @@ -586,6 +810,90 @@ describe('alloc-in-hot-path detector', () => { const finding = result.findings.find((f) => f.id.startsWith('alloc-in-hot-path:')); expect(finding).toBeDefined(); expect(finding?.evidence.function).toBe('serializeBig'); + expect(finding?.evidence.extra).toMatchObject({ + userCaller: { + function: 'serializeBig', + file: 'src/serialize.js', + line: 13, + confidence: 'high', + basis: 'cpu-sample-path', + }, + }); + }); + + it('uses CPU user attribution when heap samples only expose a native allocator', () => { + const cpuProfile: RawCpuProfile = { + nodes: [ + { + id: 1, + callFrame: { + functionName: '(root)', + scriptId: '0', + url: '', + lineNumber: -1, + columnNumber: -1, + }, + hitCount: 0, + children: [2], + }, + { + id: 2, + callFrame: { + functionName: 'transform', + scriptId: '1', + url: 'file:///app/src/app.js', + lineNumber: 10, + columnNumber: 18, + }, + hitCount: 120, + children: [3], + }, + { + id: 3, + callFrame: { + functionName: 'utf8Write', + scriptId: '2', + url: 'node:internal/buffer', + lineNumber: 1067, + columnNumber: 38, + }, + hitCount: 800, + children: [], + }, + ], + startTime: 1000000, + endTime: 2000000, + samples: [...Array(80).fill(3), ...Array(20).fill(2)], + timeDeltas: Array(100).fill(1000), + }; + const bundle = makeBundle({ + samplingProfile: singleAllocatorProfile('push', '', -1, 9000), + memoryUsageSamples: growingSeries(0), + cpuProfile, + }); + + const pipeline = createAnalysisPipeline({ + kinds: [ + createCpuProfileKind({ readStderrSoFar: () => '', sampleIntervalMicros: 1000 }), + createMemoryProfileKind(), + ], + findingAnalyzers: [createFindingAnalyzerFromKindScopedDetector(allocInHotPathDetector)], + }); + const result = pipeline.run(bundle, { command: ['node', 'app.js'], mode: 'spawn' }); + + const finding = result.findings.find((f) => f.id.startsWith('alloc-in-hot-path:')); + expect(finding).toBeDefined(); + expect(finding?.evidence.function).toBe('transform'); + expect(finding?.evidence.extra).toMatchObject({ + allocTotalPct: 100, + userCaller: { + function: 'transform', + file: 'src/app.js', + line: 11, + confidence: 'high', + basis: 'cpu-sample-path', + }, + }); }); it('skips silently when only memory kind is present', () => {