Skip to content

Analysis

The analyzer runs four heuristic detectors against a pulse trace and returns structured findings.

ts
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.

ts
import { analyze, tw } from 'pulscheck'

const findings = analyze(tw.trace)

AnalyzeOptions

ts
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:

ts
const findings = analyze(tw.trace, {
  suppress: ['dangling-async'],
  minSeverity: 'warning',
  filter: (f) => !f.events.some((e) => e.callSite?.includes('node_modules')),
})

Returns: Finding[]

Finding

ts
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

ts
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 — needs response or error
  • setTimeout — needs timer-end or timer-clear
  • setInterval — needs timer-clear (ticks mean the interval is still running)
  • addEventListener — needs listener-remove
  • WebSocket — needs response, close, or error

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.

ts
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 site

where sortedLabels is the finding's events[*].label array sorted and joined with commas. Useful for building your own suppression layer:

ts
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.

ts
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

OptionDefaultDescription
intervalMs5000Polling interval for the internal analyze() loop
minSeverity'warning'Lowest severity the reporter surfaces (info findings are suppressed by default)
suppressundefinedArray of FindingPattern values to skip entirely
logconsole.logCustom log sink — receives already-formatted strings
quietfalseSuppress the [pulscheck] Reporter started banner

Reporter methods

MethodDescription
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.

PulsCheck — originated by Oliver Nordsve, 2026