Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Add: add debug logging for rule application#220

Merged
twschiller merged 11 commits into
mainfrom
feat/debug-trace-rule-application
Jun 8, 2026
Merged

Add: add debug logging for rule application#220
twschiller merged 11 commits into
mainfrom
feat/debug-trace-rule-application

Conversation

@twschiller

@twschiller twschiller commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a dev-mode debug trace for rule application, addressing @jim_jeffers's Product Hunt FR:

  • Per-rule counts (popup, always on): the existing PerRuleCountsSection list — "4 Hide Reviews, 3 Strip Trackers, …" — surfaces what disappeared at a glance.
  • Dev mode (popup toggle, off by default): structured trace with selectors, rule ids, and full before/after outerHTML for 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 in subtree-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 the outerHTML serialization cost). Stamps a monotonic segmentId on every event.
  • lib/trace-mutation.ts — single chokepoint every rule routes its DOM mutation through; captures before/after outerHTML around 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 shared createSubtreeWatcher subscriber filtered on the modal selector, and a new setBurstFlushObserver hook in subtree-watcher.ts).
  • lib/debug-trace-store.ts — persists trace events to extension-origin IndexedDB via jakearchibald/idb. Survives MV3 service-worker restarts; capped at 2000 events/tab with FIFO prune; cleared on tab close / navigation alongside the existing tabRuleCounts lifecycle.
  • 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) and selector-hide-rule.ts (in-place hides) — plus a shared traceMutation wrapper 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 via traceMutation; their counters surface in the per-rule counts list via the existing plumbing. CSS-first hides (chat-widget-hide) emit cssOnly: true trace events from the rule-count.ts sweep — there's no element-level write to snapshot.

Test plan

  • Unit tests: 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)
  • Manual smoke (still needed, requires loading the unpacked extension):
    • Open a news site with cookie banners + trackers, confirm per-rule counts list reflects what was stripped
    • Enable the dev-mode toggle, reload, confirm the live count + byte size readout updates in the popup
    • SPA-navigate inside the GitHub web UI; confirm route-change events appear in the export
    • Open a modal dialog (e.g. GitHub's "New repository" form); confirm modal-open markers in the export
    • Click "Export" → confirm a .jsonl file downloads with one event per line, parseable with jq -c

Post-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.ts now route through traceMutation like every other rule (the wrapper landed late and left those two as stragglers).
  • Three duplicate tag#id.class describers consolidated into lib/element-describe.ts.
  • Extracted __test-mocks__/debug-trace-stub.ts — four tests stop re-implementing the same chrome.runtime.sendMessage stub + reset boilerplate.
  • Dropped the Refresh button from DebugTraceSection (live polling added in a later round made it dead-weight) and the corresponding loading/reload plumbing from useTabDebugTrace.
  • Dropped the dead parent === null branch in svg-sprite-strip (parent is guaranteed by the prior isConnected check plus querySelectorAll scoping).
  • Trimmed past-iteration framing from module headers (ring-buffer vs IDB, doc_idle drop history), dropped pruneTab's unused maxCount parameter, renamed clearAll__clearAllForTesting.

Net -111 lines (262 deletions / 217 insertions for the two new shared helpers); preflight stays green.

Notes / known limitations

  • Trace toggle defaults off. Captured outerHTML can 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."
  • 200-event ring buffer pattern was replaced with IDB during review per direct request; the storage layer is bounded at 2000 events/tab with FIFO eviction (~10 MB worst-case per tab at 5 KB/event).
  • Modal detection is narrow on purpose ([role="dialog"][aria-modal="true"], [role="alertdialog"]). Non-modal dialogs and modals fronted by closed shadow roots will surface as mutation-burst markers instead.
  • The popup reads IDB directly (extension origin), so no extra background round-trip for the debug-trace query.
  • chat-widget-hide (the only CSS-first rule) emits cssOnly: true trace events instead of real before/after diffs. The injected stylesheet hides matches declaratively — there's no element-level write to snapshot — so beforeHtml === 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 the rule-count.ts sweep with a per-registration WeakSet so 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

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]>
@vercel

vercel Bot commented Jun 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-browser-shield-demo-site Ready Ready Preview, Comment Jun 8, 2026 7:54pm

Request Review

@unblocked unblocked Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread extension/src/background.ts Outdated
if (
typeof tabId === "number" &&
typeof frameId === "number" &&
typeof entry === "object"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
typeof entry === "object"
typeof entry === "object" &&
entry !== null

@twschiller twschiller Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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}
  />
))}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]>
@twschiller twschiller changed the title Add: two-tier debug logging for rule application Add: add debug logging for rule application Jun 8, 2026
@twschiller twschiller merged commit b19d111 into main Jun 8, 2026
7 checks passed
@twschiller twschiller deleted the feat/debug-trace-rule-application branch June 8, 2026 20:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant