Analysis
The analyzer runs four heuristic detectors against a pulse trace and returns structured findings.
import { analyze, printFindings, fingerprint, createReporter } from 'pulscheck'analyze(trace, options?)
Run all detectors against a trace and return findings, sorted by severity (critical first) then by beat.
import { analyze, tw } from 'pulscheck'
const findings = analyze(tw.trace)AnalyzeOptions
interface AnalyzeOptions {
/** Suppress specific patterns entirely */
suppress?: FindingPattern[]
/** Minimum severity to report. Default: "info" (show everything) */
minSeverity?: 'info' | 'warning' | 'critical'
/** Custom predicate — return false to drop a finding */
filter?: (finding: Finding) => boolean
}Example:
const findings = analyze(tw.trace, {
suppress: ['dangling-async'],
minSeverity: 'warning',
filter: (f) => !f.events.some((e) => e.callSite?.includes('node_modules')),
})Returns: Finding[]
Finding
interface Finding {
pattern: FindingPattern
severity: 'info' | 'warning' | 'critical'
summary: string
detail: string
fix: string // actionable fix suggestion
events: PulseEvent[] // the events involved
beatRange: [number, number] // beat window where the issue occurs
}FindingPattern
type FindingPattern =
| 'after-teardown'
| 'response-reorder'
| 'double-trigger'
| 'dangling-async'Detection patterns
Severity rules are read directly from packages/core/src/analyze.ts. Where a detector has conditional severity, both branches are listed.
after-teardown
Severity: critical if the late event is a render/setState-like event (label matches render, update, display, show, paint, or setState); otherwise warning.
Events that fire after their scope has ended. The classic React bug: a fetch().then(setState) or setTimeout(update, 100) that fires after the component unmounts.
⚠️ [WARNING] "setTimeout:fire" fired after "UserProfile:scope-end" (cid: 7a3c)
Pattern: after-teardown
Event "setTimeout:fire" at beat 1420.12 occurred 120.48ms after teardown
"UserProfile:scope-end" at beat 1299.64. This often means a callback, timer,
or subscription wasn't cleaned up before disposal.
Location: src/UserProfile.tsx:14
Fix: Add cleanup: clear timers, abort fetches (AbortController), unsubscribe
listeners in useEffect return. A ref guard prevents late setState.Detection: Group by correlationId, merge in events whose parentId matches, find the scope teardown event, and flag any event with a later beat. If a recovery event (reconnect, retry, resume, restart, resubscribe, reattach, reopen, fallback) is present, events at or after the recovery beat are excluded — reconnecting is the fix, not a bug.
response-reorder
Severity: critical if meta.generation and meta.latestGeneration confirm the stale response was the last to resolve; otherwise warning.
API responses arriving in a different order than their requests. The slow response overwrites the fast one — the UI shows wrong data.
🛑 [CRITICAL] Stale response for "fetch:/api/search" resolved last — confirmed data corruption
Pattern: response-reorder
Requests were sent in order [cid-1, cid-2] but responses arrived as [cid-2, cid-1].
Generation tracking confirms the stale response (gen 1) resolved after the fresh one
(latest gen 2). Without cancellation, the UI now shows outdated data.
Location: src/hooks/useSearch.ts:20
Fix: CONFIRMED STALE: The oldest request resolved last — its data overwrote the
fresh result. Use AbortController to cancel superseded requests.Detection: Normalise fetch labels by collapsing dynamic segments (/api/user/123 → /api/user/:id). Group request/response pairs by normalised endpoint and compare request order against response-arrival order. If the last response to resolve has meta.generation < meta.latestGeneration, escalate to critical.
double-trigger
Severity: critical if meta parameters are identical (internal keys generation / latestGeneration excluded); info if they differ.
Two starts of the same normalised operation overlap — the second starts before the first's matching end.
🛑 [CRITICAL] "fetch:/api/checkout:start" triggered twice concurrently with same parameters
Pattern: double-trigger
Operation "fetch:/api/checkout:start" was started at beat 210.33 (cid: a1)
and again at beat 210.61 (cid: a2) before the first completed at beat 315.02.
Both have identical parameters — this often indicates a missing mutex,
debounce, or deduplication.
Location: src/CheckoutButton.tsx:42
Fix: Guard against duplicate triggers: check a loading flag, debounce,
or disable the trigger element until completion.Detection: Group start events by normalised label. For each adjacent pair, check whether the second starts before the first operation's matching end event. Generic timer labels (setTimeout:start / setInterval:start) only flag when the two starts share the same parentId scope or the same callSite — otherwise unrelated timers from Vite HMR or React internals produce spurious findings.
dangling-async
Severity: warning.
An operation started inside a scope but never reached a terminal state before the scope ended.
⚠️ [WARNING] setInterval never completed before "LiveChart" tore down
Pattern: dangling-async
A setInterval operation with cid "tmr-9" started at beat 82.10 inside scope
"LiveChart" (cid: s-2), but the scope tore down at beat 450.44 without a
matching clearInterval. The interval is still firing.
Location: src/LiveChart.tsx:18
Fix: Return a cleanup function from the effect that calls clearInterval.Detection: Build a correlationId → scope-teardown-beat map. For each operation-start with a parentId whose scope teared down, check whether any terminal event exists for that correlationId using per-operation-type rules:
fetch— needsresponseorerrorsetTimeout— needstimer-endortimer-clearsetInterval— needstimer-clear(ticks mean the interval is still running)addEventListener— needslistener-removeWebSocket— needsresponse,close, orerror
A label-suffix fallback (:done, :cancel, :close, :unsubscribe, …) covers manual pulses without an explicit kind.
printFindings(findings)
Pretty-print findings to the console with severity icons and call sites.
printFindings(findings)fingerprint(finding)
Return the dedup fingerprint for a finding. The format is:
{pattern}::{sortedLabels} // no call site available
{pattern}::{sortedLabels}::{callSite} // when any event has a call sitewhere sortedLabels is the finding's events[*].label array sorted and joined with commas. Useful for building your own suppression layer:
import { fingerprint } from 'pulscheck'
const key = fingerprint(finding)
if (!seen.has(key)) {
seen.add(key)
report(finding)
}createReporter(options?)
Create a reporter that runs analyze() on an interval and applies structural deduplication.
const reporter = createReporter({
intervalMs: 5_000, // default: 5000
minSeverity: 'warning', // default: 'warning'
suppress: [], // FindingPattern[], default: undefined
log: console.log, // custom log sink
quiet: false, // silence the startup banner
})
reporter.start()ReporterOptions
| Option | Default | Description |
|---|---|---|
intervalMs | 5000 | Polling interval for the internal analyze() loop |
minSeverity | 'warning' | Lowest severity the reporter surfaces (info findings are suppressed by default) |
suppress | undefined | Array of FindingPattern values to skip entirely |
log | console.log | Custom log sink — receives already-formatted strings |
quiet | false | Suppress the [pulscheck] Reporter started banner |
Reporter methods
| Method | Description |
|---|---|
start() | Begin periodic analysis. Idempotent. |
stop() | Stop periodic analysis. Idempotent. |
check() | Run analyze() once and return the full Finding[] — does not apply reporter dedup. |
reset() | Clear the reporter's seen-fingerprint map. |
Deduplication
Each interval, the reporter fingerprints every finding from analyze(). New fingerprints are logged once; recurring fingerprints only increment a count and are suppressed from output. check() runs a single analyze() and bypasses the dedup map — use reset() followed by the next interval to re-surface known findings.
devMode() wraps createReporter().start() for you — you rarely need to use createReporter directly unless you want non-default minSeverity, a custom log sink, or programmatic check() access.