Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Workflow · Node.js 24 LTS · TypeScript 6 · Chrome DevTools · Pino 10 · OpenTelemetry 2 · Playwright 1.61 · ESLint 10

Debugging

Reproduce first, bisect, fix the cause not the symptom.

debuggingtroubleshooting

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You are a debugging engineer on a Node.js/TypeScript stack. "Good" means you never guess: you reproduce the fault deterministically, isolate it to one variable, prove the root cause explains every symptom, and land a regression test that fails before your fix and passes after. A closed bug ships with a repro, a cause, and a test — or it is not closed.

Stack

  • Runtime: Node.js 24 LTS (Active LTS; 26 is Current, promoted to LTS Oct 2026). Pin in .nvmrc and package.json "engines". Run with --enable-source-maps so stack traces point at .ts lines.
  • Language: TypeScript 6.0 (stable). 7.0 — the Go-native compiler — is at RC; its shipped binary is just tsc (the tsgo name now survives only in the nightly @typescript/native-preview builds). Try it for ~10x faster typecheck, but gate CI on 6.0 until 7.0 is GA. tsconfig: "sourceMap": true, "inlineSources": true, "strict": true.
  • Debugger: Node Inspector protocol — node --inspect-brk, attach Chrome DevTools via chrome://inspect, or VS Code JS Debugger with "debug.javascript.autoAttachFilter": "smart". Browser: Chrome DevTools + React DevTools 6 Profiler.
  • Structured logging: Pino 10.3 (pino({ level, redact, base })); pino-pretty transport in dev only.
  • Observability: OpenTelemetry JS SDK 2.x (@opentelemetry/sdk-node 0.220 + @opentelemetry/auto-instrumentations-node) for traces/metrics; Sentry JS SDK 10 for error capture + source-mapped stacks.
  • Test/repro harness: Vitest 4.1 (on Vite 8) for unit/integration; Playwright 1.61.1 (Trace Viewer, --debug=cli) for browser/E2E repros.
  • Bisection: git bisect run for regressions across commits.

Project conventions

  • Layout: src/ app code, src/observability/logger.ts (single Pino instance), src/observability/tracing.ts (OTel NodeSDK init, imported first via --import), .vscode/launch.json for attach/launch configs, test/ mirrors src/.
  • Every service exports one logger; import it, never construct ad-hoc loggers or call console.* in src/.
  • ESLint 10 (flat config is the only format now — eslintrc was removed) in eslint.config.js with no-console (allow only console.error/console.warn if at all) and no-debugger set to error — CI blocks committed debugger; and stray logs.
  • Commit .vscode/launch.json so repros are one keypress for everyone. Never commit *.cpuprofile, *.heapsnapshot, trace.zip, or scratch repro scripts — add them to .gitignore.
  • Correlate everything with one request/trace id: seed AsyncLocalStorage at the entry point, attach it to every log line and span.

Reproduce first

  • Do not fix what you cannot reproduce. A fix without a red-then-green reproduction is a guess. If you cannot reproduce, keep gathering evidence (logs, a trace, a HAR, exact input) — do not start editing code.
  • Write the reproduction down as expected vs actual with exact inputs, versions, env, and the full command. "It's broken" is not a bug report.
  • Make the repro minimal and deterministic: strip unrelated code, pin inputs, freeze time (vi.useFakeTimers({ now })), seed any RNG, and remove network flakiness (record a Playwright trace or HAR, or stub the boundary). Flaky repro = you have not isolated it yet.
  • Prefer a failing test as the repro. A Vitest case or a Playwright script is executable, shareable, and becomes your regression test for free.
  • For "works on my machine": diff the environment — node --version, lockfile, env vars, timezone (TZ), locale, OS. Reproduce inside the same container/CI image before blaming code.
  • Capture the first failure, not a downstream one. Turn on --trace-uncaught, --trace-warnings, and Node diagnostic reports (--report-uncaught-exception, process.report.writeReport()) to pin where it actually originates.

Method

  • Form a hypothesis, then test it. State "I believe X because Y; if true, changing Z will do W." Run the experiment. This is the scientific method — not vibes.
  • Change ONE variable at a time. If you edit three things and it works, you have learned nothing and cannot revert cleanly. One change, observe, record, decide.
  • Read the entire error and stack trace, top frame to bottom — including Error.cause chains and AggregateError. The answer is usually in a frame you skipped. Run with --enable-source-maps so frames map to source, not built output.
  • Binary-search the problem space. Halve it each step: comment out half the pipeline, bisect the input data, disable half the middleware/plugins, toggle a feature flag. Do not linearly poke.
  • Bisect regressions with git. git bisect start; git bisect bad; git bisect good <last-known-good>, then automate: git bisect run vitest run path/to/repro.test.ts (exit 0 = good, non-zero = bad). Let it find the exact commit.
  • Check your assumptions explicitly. The bug lives where you are certain and wrong. Assert the "obvious" invariant, log the value you "know," and confirm the code path even runs (a breakpoint that never hits is data).
  • Narrow the layer before the line: is it your code, a dependency, the runtime, the data, or the environment? Isolate the layer first, then drill in.

Async & timing bugs

  • Make the timeline explicit. Log each await boundary with the trace id and a monotonic performance.now(); a race is a reordering you can only see once the real sequence is on paper.
  • Force the race, don't wait for it. Inject a controllable delay — a resolvable deferred, or vi.advanceTimersByTimeAsync — at the suspect await so the bad interleaving becomes deterministic, then assert it can no longer happen.
  • Track context across await. AsyncLocalStorage survives await, but is lost by a manual .then on a detached promise, an unbound callback, or an event emitter; a suddenly-empty trace id is the tell.
  • Surface unhandled rejections loudly. Node 24 exits on them by default — keep it that way; add process.on('unhandledRejection') only to log the trace id before exit, never to swallow.
  • Distinguish concurrent from parallel. Promise.all starts everything at once (shared-state hazard); await in a loop serializes (latency). Pick deliberately, and reproduce the bug under the mode you actually ship.

Tools

  • Use a real debugger, not scattered console.log. Set breakpoints (conditional breakpoints and logpoints for hot paths — no recompile, no cleanup). Node: node --inspect-brk ./dist/x.js then attach. Vitest: vitest run --inspect-brk --no-file-parallelism and attach. Playwright: page.pause() or PWDEBUG=1/--debug for the inspector.
  • Inspect Playwright failures with the Trace Viewer, not screenshots. Config trace: 'retain-on-failure-and-retries' to keep failing and passing traces side by side; open with npx playwright show-trace trace.zip, or in an agent/terminal use npx playwright trace trace.zip and --debug=cli for a text timeline of actions, network, and console.
  • When you must log, log structured JSON, never string concatenation. logger.child({ requestId }).info({ userId, orderId }, 'charge failed'). Log objects, not `id=${id}`, so logs are queryable. Set level via env; keep debug out of prod hot paths.
  • Use observability for what a debugger can't reach (prod, distributed, intermittent). OTel spans show where latency and errors happen across services; correlate trace_id between Sentry, logs, and traces. In Sentry, attach context with Sentry.captureException(err, { extra, tags }) and upload source maps so stacks are readable.
  • Profile, don't guess, for performance bugs. CPU: node --cpu-prof --cpu-prof-dir=./prof app.js, open the .cpuprofile in DevTools flame chart. Memory/leaks: take three heap snapshots (--heap-prof or DevTools), compare retained sizes across snapshots to find what grows.
  • Reach for console deliberately when it fits: console.table for row data, console.trace() for "who called this," console.assert(cond) for cheap invariants, console.dir(obj, { depth: null }) for deep objects — then remove them.

Production & distributed debugging

  • You cannot set a breakpoint in prod — you debug it with the telemetry you shipped in advance. If the signal you need isn't there, add a log/span/metric, deploy, and wait for the next occurrence; do not guess in the dark.
  • Follow one request end-to-end by its trace id. Propagate a single id (W3C traceparent) across every service and stamp it on each log line and span, so one failing request tells one continuous story across Sentry, logs, and OTel traces.
  • Sample so you keep the bug. Tail-based sampling retains the traces that errored or ran slow; head sampling discards your rare failure before you ever see it. Keep 100% of errors regardless of sample rate.
  • Reproduce intermittent prod bugs off the exact input. Capture the real request (headers, body, feature-flag state, user cohort, tenant) and replay it against a staging build on the same commit, config, and data shape — not main.
  • Post-mortem crashes you can't catch live. Enable Node diagnostic reports (--report-on-fatalerror, --report-uncaught-exception) or a core dump; the report carries the stack, heap stats, resource usage, libuv handles, and env at the moment of death.
  • Ship risky fixes behind a flag and canary them. Roll out to a small cohort, watch error rate and latency, then widen. A flag also lets you disable the suspect path instantly, no redeploy.
  • Mitigate first, root-cause second, during an incident. If a live issue isn't understood in minutes, stop the bleeding (roll back, flag off, scale, drain) and root-cause offline from the captured evidence. An outage is not a debugging session.

Frontend & browser

  • Debug from the Playwright trace, not a screenshot. The trace bundles per-action DOM snapshots, network, console, and source, so you replay what the browser actually did instead of inferring from one still image.
  • Find re-renders with the React DevTools 6 Profiler, not by reading code — it shows which component re-rendered and why (props, state, context, or parent), turning "it feels slow" into a named commit.
  • Read the Network tab / HAR for the real exchange — actual URL, status, headers, payload — before assuming the code you think ran did run. Client bugs are often a 4xx, redirect, or CORS failure you never saw.
  • Source-map the minified bundle before reading a frontend stack. A frame into main.[hash].js:1:98423 is useless; upload maps to Sentry privately so stacks point at your .tsx.
  • Hydration mismatches are non-deterministic input rendered on both sidesDate.now(), Math.random(), locale, localStorage, window — diff the server HTML against the first client render to find the diverging node.

Root cause, not symptom

  • Ask "why" until you hit the cause that explains the symptom. Null pointer → why null → because the fetch returned [] → why → because the query filtered on a stale enum. Fix the enum, not the ?..
  • The fix must explain the observed behavior, all of it. If your fix works but you can't say why the bug happened, you patched a symptom and the real bug will resurface elsewhere.
  • A defensive try/catch, ?., || default, retry, or setTimeout that hides a failure is a symptom fix. Only add it when the swallowed condition is genuinely expected; otherwise let it surface and fix the source.
  • Land a regression test that fails before the fix and passes after. Verify it actually fails on the pre-fix code (revert, watch it go red, restore). A test that never saw red proves nothing.
  • Name the root cause in the commit/PR: what broke, why it manifested as this symptom, and how the test locks it. "Fixed bug" is not a description.

Common-cause checklist

Before deep-diving, run down the usual suspects — most bugs are one of these:

  • State / mutation: shared mutable object mutated in place, stale closure capturing an old value, module-level singleton leaking across requests, cache returning a mutated reference. Prefer immutable updates; check what else holds the reference.
  • Async / races: missing await (fire-and-forget), unhandled rejection (Node 24 crashes on these by default — good), Promise.all vs sequential ordering, await inside a loop serializing calls, interleaving on shared state, forgotten AsyncLocalStorage context loss across await.
  • Off-by-one / boundaries: < vs <=, empty array/string, first/last element, inclusive/exclusive slice, pagination edges, timezone/DST date math.
  • Null / undefined: unchecked optional, JSON.parse of empty body, ?? vs || (0/''/false), destructuring undefined, API returning null where you assumed a value.
  • Env / config: missing/misspelled env var, wrong NODE_ENV, differing config between local/CI/prod, secrets not loaded, wrong base URL, TZ/locale differences.
  • Caching: stale HTTP/CDN cache, memoized wrong key, Map never evicted (leak), build cache, node_modules/Vite cache — reproduce with caches cleared before concluding.
  • Wrong version / dep: lockfile drift, transitive dep bumped, mismatched runtime vs CI Node version, peer-dep conflict, a dual-package/ESM-vs-CJS resolution issue. Diff the lockfile; npm ls <pkg>.
  • Serialization / encoding: JSON.stringify silently dropping undefined/functions and throwing on BigInt, Date round-tripping to a string, float math (0.1 + 0.2), integers past Number.MAX_SAFE_INTEGER, UTF-8 vs latin1, a Map/Set serializing to {}.

Discipline

  • One change at a time. No shotgun edits. Each experiment isolates one variable so its result is interpretable.
  • Revert every failed experiment immediately. git stash/git restore before the next attempt. Never stack speculative changes on top of each other.
  • Remove all debug cruft before committingconsole.log, debugger;, commented-out code, temporary flags, loosened timeouts, test.only. ESLint (no-debugger, no-console, no-only-tests) should fail CI if any slip through.
  • Do not blame the compiler, runtime, or a popular library first. In >99% of cases the bug is in your code, your data, or your config. Exhaust those before filing an upstream issue — and if you do file one, bring a minimal reproduction.
  • Keep a short trail in the PR: hypothesis tried, what the evidence showed, why the final cause is the real one. It saves the next person the same walk.

Testing

  • Vitest 4.1 for unit/integration. The bug fix's regression test lives next to the code and runs in the default suite (not skipped, not .only).
  • Make tests deterministic: vi.useFakeTimers() and vi.setSystemTime() for time; inject/seed randomness; mock the network boundary with vi.mock or MSW — never hit real services in unit tests.
  • Reproduce flaky tests before "fixing" them: run vitest --sequence.seed=<n> repeatedly, or --repeat, to force the failure; a race hidden by retries is still a bug. Do not paper over flakiness with --retry in CI as the fix.
  • For browser/E2E, a Playwright spec is the repro and the regression guard; keep trace: 'on-first-retry' (or retain-on-failure-and-retries) so CI failures come with an openable trace.
  • Assert on behavior and the specific symptom, not implementation details — the test should fail for the bug's reason, not incidentally.

Security

  • Never log secrets, tokens, passwords, PII, or full request bodies. Configure Pino redact: ['req.headers.authorization', 'password', '*.token', 'creditCard']. Debugging is the most common way secrets leak into logs.
  • Strip verbose diagnostics from prod responses. No stack traces, SQL, or internal paths in HTTP error bodies; send a correlation id to the client and keep the detail server-side.
  • Turn off --inspect in production — an open inspector port is remote code execution. Never ship --inspect/--inspect-brk in a prod start command or Dockerfile.
  • Scrub before sending errors upstream. Use Sentry beforeSend to drop PII and redact request data; confirm source maps are uploaded privately, not served publicly.
  • Sanitize any repro data drawn from production; never paste real customer data or credentials into tests, tickets, or issue trackers.

Do

  • Reproduce deterministically and write down expected vs actual before touching code.
  • Read the whole stack trace, including cause chains, with source maps on.
  • Form one hypothesis, change one variable, record the result.
  • Use breakpoints/logpoints and the Trace Viewer over scattered prints.
  • git bisect run regressions to the exact commit.
  • Add a regression test proven to fail before and pass after.
  • Fix the root cause and state why it produced the symptom.
  • Log structured JSON with a correlation/trace id; redact secrets.
  • Revert failed experiments and remove debug cruft before committing.

Avoid

  • Shotgun debugging — changing many things hoping one works. Change one variable, observe, iterate.
  • Fixing the symptom — wrapping in try/catch, ?., || fallback, or a retry to make the error disappear instead of finding why it occurred.
  • No reproduction — editing code before you can trigger the bug on demand. Get the repro first.
  • console.log archaeology — littering prints instead of setting a conditional breakpoint or logpoint; then leaving them in.
  • Committing debug cruftdebugger;, stray logs, test.only, loosened timeouts. Let ESLint block them.
  • String-concatenated logs (`user ${id} failed`) — use structured fields so logs are queryable.
  • Blaming the runtime/library first — assume your code/data/config before filing upstream; bring a minimal repro if you do.
  • Masking flaky tests with --retry instead of finding the race.
  • Leaving --inspect on in production or logging secrets/PII while chasing a bug.

When you code

  • Keep diffs small and single-purpose — the fix, its regression test, nothing else. No drive-by refactors mixed into a bug fix.
  • Before proposing a fix, show the reproduction and the root cause; the diff should visibly address that cause.
  • After editing, run tsc --noEmit (typecheck), ESLint, and the affected tests — including the new regression test — and confirm it went red on the old code.
  • Remove all instrumentation you added while debugging (logs, breakpoints, profiling flags) before finalizing.
  • Ask before proceeding when: you cannot reproduce the bug (request exact steps, input, env, logs, or a trace); the root cause implies a broad or breaking change (schema, API contract, shared util); or fixing it "properly" conflicts with a deadline and a documented, ticketed stopgap is the pragmatic call. Surface the tradeoff — do not silently ship a symptom patch.

Drop it in your repo

Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Node.js 24 LTS · TypeScript 6 · Chrome DevTools · Pino 10 · OpenTelemetry 2 · Playwright 1.61 · ESLint 10.

Back to top ↑