Add: add debug logging for rule application#220
Conversation
Splits rule-application visibility into a quiet human-mode summary
("Stripped 24 items: 4 hidden text blocks, 3 trackers…") in the popup,
and an opt-in dev-mode trace that captures before/after outerHTML for
every block-placeholder, inline-placeholder, and in-place hide. The
trace is segmented by initial-load, route-change, modal-open, and
mutation-burst so SPA debugging can pinpoint which transition caused
an aggressive strip.
Trace persists in the extension-origin IndexedDB (via the `idb`
package) so it survives MV3 service-worker restarts; capped per tab,
cleared on tab close / navigation. Toggle is off by default — when
off, recordRuleApplication() is a no-op that doesn't even pay the
outerHTML serialization cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
2 issues found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| if ( | ||
| typeof tabId === "number" && | ||
| typeof frameId === "number" && | ||
| typeof entry === "object" |
There was a problem hiding this comment.
In JavaScript, typeof null === "object" evaluates to true. If a malformed message arrives with entry: null, this check passes, and appendDebugTraceEvent(tabId, frameId, null) stores a null entry in IDB. When the popup later iterates the stored entries and accesses entry.type, it will throw a TypeError, crashing the debug-trace panel.
| typeof entry === "object" | |
| typeof entry === "object" && | |
| entry !== null |
There was a problem hiding this comment.
Good catch — fixed in 27c752e. Added the explicit entry !== null guard and widened entry to unknown at the access point so the runtime narrow is type-meaningful (the previous DebugTraceEventMessage cast was hiding the null-shape risk from TypeScript). After the guards pass, it is cast back to DebugTraceEntry before being handed to appendDebugTraceEvent.
— Claude Code, on behalf of @twschiller
| <ul className="segment__events"> | ||
| {group.events.map((event) => ( | ||
| <RuleApplicationItem | ||
| key={`${event.segmentId}:${event.timestamp}:${event.ruleId}`} |
There was a problem hiding this comment.
The key ${event.segmentId}:${event.timestamp}:${event.ruleId} will collide whenever a rule processes multiple elements synchronously in the same segment (e.g., pii-redact iterating over a querySelectorAll result set). All those events share the same segmentId, the same Date.now() millisecond, and the same ruleId. Duplicate keys cause React rendering bugs (incorrect reuse of component state, misordered items).
Add the array index as a disambiguator:
| key={`${event.segmentId}:${event.timestamp}:${event.ruleId}`} | |
| key={`${event.segmentId}:${event.timestamp}:${event.ruleId}:${group.events.indexOf(event)}`} |
Or, preferably, use the map callback index directly:
{group.events.map((event, index) => (
<RuleApplicationItem
key={`${event.segmentId}:${event.ruleId}:${index}`}
event={event}
/>
))}There was a problem hiding this comment.
Good catch — the collision is real (pii-redact is the canonical example, since it iterates a querySelectorAll synchronously within a single ms). Fixed in 27c752e.
One wrinkle worth flagging: both the suggested form (...:${index}) and the alternative (group.events.indexOf(event)) trip the project's noArrayIndexKey biome rule. I went with the indexed form (segmentId:index) and added a one-line biome-ignore justifying it — trace entries within a segment are append-only, so the list never reorders or splices and the index is semantically a stable identifier here. If you'd prefer a synthetic per-event sequence id stamped at the source (recordRuleApplication) so the suppression goes away, happy to follow up — but it would touch the wire format and the existing trace tests, so I held off unless there's appetite.
— Claude Code, on behalf of @twschiller
- background.ts: guard against `entry: null` slipping past the `typeof entry === "object"` check (typeof null is "object"), which would have stored a null entry in IDB and crashed the debug-trace panel on read. Widen `entry` to `unknown` so the runtime narrow is type-meaningful, then cast back to `DebugTraceEntry` after the guards. - DebugTraceSection.tsx: switch React keys to use the map index instead of `segmentId:timestamp:ruleId`, which would collide for any rule that mutates multiple elements in the same millisecond (the failure mode flagged by unblocked[bot]). Trace entries within a segment are append-only, so the index is a stable identifier. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The friendly summary line added cognitive overhead without giving agent-builders information the per-rule list didn't already convey. Renders PerRuleCountsSection in its place. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A popup user can reach the options page directly via "Configure rules", so surfacing the on-page-button toggle in both surfaces was redundant. The options-page section (already present) is now the sole control. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Per-event/per-segment HTML inspection is awkward in a 280px popup — the listing is hidden for now and the popup just shows how much trace has piled up for this tab. The Refresh / Copy JSON / Clear buttons are preserved. Adds `getTabStats(tabId)` — a cursor-walk that counts rule-application entries and sums on-disk byte size without transferring the full `outerHTML` payloads. The popup polls it once a second while open so the developer sees fresh data accumulate during page interactions; Copy JSON still fetches the complete entries on demand. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Before this commit only the placeholder + selector-hide paths emitted
debug-trace events, so 22 rules — strip/sanitize/flag/annotate — left
no trail when they touched the page. Reproducing a false positive from
a user report required guessing which rule fired.
`lib/trace-mutation.ts` is the new chokepoint: each rule wraps its
synchronous mutation in `traceMutation({ ruleId, kind, target }, () =>
{ … })` and the wrapper snapshots `outerHTML` before and after. The
toggle gates capture at source — when off the wrapper is a thin
pass-through and pays no serialization cost.
`captureFrom` lets sibling-add patterns (link-spoof, trust-badge,
roach-motel, webdriver-probe, etc.) snapshot the parent element so the
appended chip shows up in the after-HTML. Comment-data scrubs in
html-comment-strip target the parent for the same reason.
`RuleApplicationKind` now mirrors the verbs in the user-facing docs and
rule labels:
hide Hide / Remove (block placeholder or display:none)
mask Mask / Redact (inline text replacement)
strip Strip (textContent / children blanked)
sanitize Sanitize / Scrub / (in-place attr/text edit)
Clear / Neutralize
flag Flag / Annotate (chip appended)
embed Embed (helper landmark injected)
Migrated rules (the previously-uninstrumented set):
- strip: noscript-strip, html-comment-strip, hidden-text-strip,
meta-injection-strip, svg-sprite-strip, svg-text-strip,
unicode-invisibles-strip
- sanitize: attribute-injection-sanitize, json-ld-sanitize,
schema-trust-sanitize, checkout-checkbox-sanitize,
confirmshame-sanitize, hidden-affiliate-sanitize
- flag: link-spoof-annotate, trust-badge-annotate,
roach-motel-annotate, webdriver-probe-annotate,
closed-shadow-root-annotate, cart-addon-annotate,
hidden-fee-annotate, form-prefill-annotate
- embed: search-url-helper
placeholder.ts and selector-hide-rule.ts switched to the new kinds
(`block-placeholder`/`inline-placeholder`/`hide-in-place` →
`hide`/`mask`/`hide`) but keep their existing emit sites — the helper
is for rules that didn't have one. disguised-ad-flag already goes
through placeholder.ts, so it inherits trace coverage there. CSS-first
hides (chat-widget-hide) and the LLM-driven irrelevant-sections-redact
remain on their existing paths; the latter also routes through
placeholder.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
There's no element-level write for `traceMutation` to wrap — the stylesheet hides matches declaratively. Documented in the rule file itself, the `trace-mutation` helper's header so a future contributor sees it where they'd look, and the PR description's known-limitations list so a reviewer sees it too. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
chat-widget-hide is the only CSS-first rule — its stylesheet hides matches declaratively, so `traceMutation` had no element-level write to snapshot and the rule never appeared in the trace. The user specifically called this out as a false-positive triage gap: when chat-widget-hide accidentally matches a login button, the most important debugging artifact is "which element matched which selector," and that's exactly the information we were dropping. `rule-count.ts` already QSAs each registered union on every 250 ms sweep. Each registration now carries a `WeakSet<Element>` of nodes already traced; when the trace toggle is on, every newly-matched element produces a `rule-application` event with `cssOnly: true`, `beforeHtml === afterHtml === element.outerHTML`, and `selector` set to the union string. The WeakSet dedupes across the throttle-driven recount cadence, so an element is reported exactly once per registration regardless of how many sweeps see it. The `cssOnly` flag is added to `RuleApplicationEvent` (and the recorder input) so a future viewer can render these events as "matched, not mutated" instead of trying to highlight a non-existent diff. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…pped The lazy storage round-trip in `ensureSubscribed` left `enabled` at its default `false` for the first few microtasks on page reload, so the entire synchronous document_idle rule-apply burst (and the initial-load segment marker) emitted into a closed gate. Per-rule counters surfaced the fires via the marker-attribute tally, but the trace buffer captured only the later async mutations — popup showed e.g. 20 fires / 4 events. Replace `ensureSubscribed` with an exported `initDebugTrace()` that returns a cached promise, and have `start()` await it alongside the other startup storage loads. `startSegmentTracker` defers its initial-load marker behind the same promise. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
A JSON array on the clipboard is hard to handle when the trace runs to thousands of multi-KB outerHTML snippets — paste truncation, no streaming through jq, no grep-by-rule-id. JSONL on disk lets the developer pipe the file through standard line-oriented tooling and diff two captures side by side. Uses file-saver for the anchor-click download (no chrome.downloads permission needed since the popup runs at the extension origin). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sweep the leftover scaffolding from the rounds of restructuring (ring buffer → IDB, button toggle move, JSON clipboard → JSONL, traceMutation wrapper) so the final shape doesn't carry two parallel emit paths and duplicate test boilerplate. - Route placeholder block-hide + selector-hide removeEntirely through traceMutation, dropping the bespoke isDebugTraceEnabled guards and the duplicate describeForTrace helper. - Consolidate three "tag#id.class" describers into element-describe.ts; modal selectors now also include class lists. - Drop the unused parent === null branch in svg-sprite-strip (the parent is guaranteed by the prior isConnected check + querySelectorAll scoping). - Extract __test-mocks__/debug-trace-stub.ts so four tests stop re-implementing the chrome.runtime.sendMessage stub + reset boilerplate. - Drop the Refresh button from DebugTraceSection — the 1s poll added in a later round made it dead-weight, and the loading/reload plumbing in useTabDebugTrace falls out with it. - Trim past-iteration framing in module headers (ring-buffer vs IDB, document_idle drop history), drop pruneTab's unused maxCount parameter, rename clearAll → __clearAllForTesting, drop the now- redundant clearAll wipes-every-tab test. bun run preflight green: 1877 tests, 88.49% statements / 79.55% branches. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Summary
Adds a dev-mode debug trace for rule application, addressing @jim_jeffers's Product Hunt FR:
PerRuleCountsSectionlist — "4 Hide Reviews, 3 Strip Trackers, …" — surfaces what disappeared at a glance.outerHTMLfor every block-placeholder, inline-placeholder, and in-place hide. Lets agent-builder teams reproduce false positives offline.Trace events are segmented by initial-load, route-change (reuses
route-change.ts), modal-open (role="dialog"[aria-modal="true"]), and mutation-burst (hook insubtree-watcher.ts) so SPA debugging can pinpoint which transition caused an aggressive strip — addressing the "agent got confused after the third dynamic update" failure mode from the FR thread.Architecture
lib/debug-trace.ts— content-script-side recorder. Toggle off ⇒ no-op (doesn't pay theouterHTMLserialization cost). Stamps a monotonicsegmentIdon every event.lib/trace-mutation.ts— single chokepoint every rule routes its DOM mutation through; captures before/afterouterHTMLaround the mutator when the toggle is on. Includes the placeholder block-hide and selector-hide removeEntirely paths.lib/segment-tracker.ts— emits segment markers from existing hooks (subscribeRouteChange, a sharedcreateSubtreeWatchersubscriber filtered on the modal selector, and a newsetBurstFlushObserverhook insubtree-watcher.ts).lib/debug-trace-store.ts— persists trace events to extension-origin IndexedDB viajakearchibald/idb. Survives MV3 service-worker restarts; capped at 2000 events/tab with FIFO prune; cleared on tab close / navigation alongside the existingtabRuleCountslifecycle.popup/DebugTraceSection.tsx— live count + byte size of the trace piling up in IDB; Export (JSONL) + Clear actions.Instrumentation strategy
Centralized at the two places where rules already stamp marker attributes —
placeholder.ts(block/inline placeholders) andselector-hide-rule.ts(in-place hides) — plus a sharedtraceMutationwrapper that every other rule routes its DOM write through. Rules that mutate without marker attributes (noscript-strip,html-comment-strip,hidden-text-strip) still emit trace events viatraceMutation; their counters surface in the per-rule counts list via the existing plumbing. CSS-first hides (chat-widget-hide) emitcssOnly: truetrace events from therule-count.tssweep — there's no element-level write to snapshot.Test plan
debug-trace.test.ts(toggle gate, segment id monotonicity, swallowed SW rejections),trace-mutation.test.ts(gate short-circuit, before/after capture, captureFrom override, selector override),segment-tracker.test.ts(initial-load once, route-change emit, modal-open detection + throttle, idempotent start),debug-trace-store.test.ts(append, per-tab read, clearTab isolation, prune via test-only injectable cap),rule-count.test.ts(cssOnly trace emission + per-element dedupe)bun run preflight— biome + eslint + tsc + knip + 1877 tests with coverage (88.49% statements / 79.55% branches, both well above thresholds)bun run build— extension builds cleanly, background.js purity canary check passes (no rule files leaked into SW).jsonlfile downloads with one event per line, parseable withjq -cPost-review cleanup (commit 233b24e)
After the rounds of iteration, swept the leftover scaffolding so the final shape doesn't carry two parallel emit paths or duplicate test boilerplate:
placeholder.ts+selector-hide-rule.tsnow route throughtraceMutationlike every other rule (the wrapper landed late and left those two as stragglers).tag#id.classdescribers consolidated intolib/element-describe.ts.__test-mocks__/debug-trace-stub.ts— four tests stop re-implementing the samechrome.runtime.sendMessagestub + reset boilerplate.Refreshbutton fromDebugTraceSection(live polling added in a later round made it dead-weight) and the correspondingloading/reloadplumbing fromuseTabDebugTrace.parent === nullbranch insvg-sprite-strip(parent is guaranteed by the priorisConnectedcheck plusquerySelectorAllscoping).pruneTab's unusedmaxCountparameter, renamedclearAll→__clearAllForTesting.Net -111 lines (262 deletions / 217 insertions for the two new shared helpers); preflight stays green.
Notes / known limitations
outerHTMLcan contain PII (form values, addresses) — keeping it opt-in means the user is consciously enabling capture on their own browser. UI carries a one-line disclosure: "Captures DOM snippets of removed content for debugging. Stored only in this browser."[role="dialog"][aria-modal="true"], [role="alertdialog"]). Non-modal dialogs and modals fronted by closed shadow roots will surface as mutation-burst markers instead.chat-widget-hide(the only CSS-first rule) emitscssOnly: truetrace events instead of real before/after diffs. The injected stylesheet hides matches declaratively — there's no element-level write to snapshot — sobeforeHtml === afterHtml. The matched element's outerHTML is what false-positive triage actually needs, and the flag lets the (future) viewer render these as "matched, not mutated" rather than highlighting a non-existent diff. Emission is driven by therule-count.tssweep with a per-registrationWeakSetso each element is traced exactly once.Design plan
The full plan I followed is in
/Users/tschiller/.claude/plans/giggly-mapping-gosling.md.🤖 Generated with Claude Code