AutoCapture · Shadow DOM POC · SDKW-51

Teaching AutoCapture to see
inside Shadow DOM

Why clicks inside modern web components go missing today — and how the proof of concept fixes each gap, step by step.

Status: POC working end-to-end — clicks, form changes, element addresses, and page-structure data all captured inside open Shadow DOM. Off by default; zero change in behavior until switched on.

Navigate with or the arrows below · steps reveal one at a time
🔍 buttons open an optional technical deep dive — skip them unless someone asks
Background · 1 minute

Modern pages are built from sealed components

Teams ship reusable widgets — date pickers, checkout forms, whole design systems. Shadow DOM is the browser feature that puts each widget in its own sealed box, so nothing else on the page can accidentally break it.

a typical page
🔷 Header — regular page content (“light DOM”)
🔷 Text, links, images … all visible to AutoCapture today
SEALED COMPONENT (Shadow DOM)
Pay now ← lives inside the box
OPEN mode

A glass box 🪟

Outsiders can look inside — if they know the special way in. This is what most component frameworks use, and what this POC supports.

CLOSED mode

A vault 🔒

The browser makes it impossible for any page script to look inside. Out of scope — for us and for everyone else.

Why we care

A major customer’s product suite builds its UI almost entirely out of these sealed components.

Background · 30 seconds

How AutoCapture normally works

A reporter standing at the page’s front door. Every click “bubbles up” to it.

📋 AutoCapture listens at the top of the page
⬆️  click travels up  ⬆️
Sign up

1 · Hear the click

Clicks naturally travel from the element up to the top of the page — AutoCapture hears every one.

2 · Ask “what was clicked?”

Is it a button, link, form field — something on the tracking list?

3 · Write the event

What it was, its text, its surroundings (family tree), and its address on the page — so analysts can chart it and label it later.

Works perfectly … as long as everything is in the open. ✅

The problem

Put that button in a sealed box, and four things break

A click inside a component still reaches AutoCapture — but the report is ruined on the way out.

1

The browser hides who was clicked

Outsiders are only told “something in the box” — never the actual button. The click is dropped, or blamed on a nameless box.

2

The family tree hits a wall

We can’t see the element’s surroundings, so we can’t tell which “Pay” button it was.

3

No address that crosses the wall

Page addresses (CSS selectors) can’t point inside a box — so visual labeling can never find the element again.

4

Deaf to what happens inside

Form changes inside the box never leave it at all, and our page watchers can’t see in.

Next: each problem, and how the POC solves it. →

Problem 1 → solved

The browser hides who was clicked

📋 AutoCapture asks: “what was clicked?”
🔷 regular page content — reported accurately
SEALED COMPONENT
Pay now
report: clicked “Pay now” button ✅…a box? 🤷 ❌

The swap is deliberate — a browser privacy screen called retargeting. It protects components from outsiders… including us.

TODAY

“A box was clicked” → dropped

A nameless box isn’t on the tracking list. The click vanishes — or worse, is logged with no text and no meaning.

THE FIX

Ask for the itinerary instead

The browser also keeps the click’s full travel log — composedPath(). For glass (open) boxes, stop 1 is the real button. Session Replay uses the same trick.

  1. 1 “Pay now” button  ←  the truth
  2. 2 wrapper inside the box
  3. 🚪 box boundary
  4. 3 the component box
  5. 4 checkout section
  6. 5 the page
Problem 2 → solved

The family tree hits a wall

Every event includes the element’s surroundings — that’s how analysts tell the checkout “Pay” from the settings “Pay”.

🌐 the page
checkout section
component box 📦
⛔ wall
wrapper
“Pay now” button 🧗
TODAY

The climb stops at the wall

Walking up from the button, the “parent” trail simply ends at the box edge. Everything above — the component, the section, the page — is invisible.

THE FIX

Step through the box’s door

Every glass box knows its owner. At the wall, we step through to the owner and keep climbing — all the way to the top.

The recorded family tree — now complete, with the doorway marked:

Pay now · wrappershadow: true 🚪 · component box · checkout section · page

The 🚪 marker tells our own tools (visual labeling, queries) exactly where the boundary was.

Problem 3 → solved

No address that crosses the wall

Events carry an “address” (a CSS selector) so tools like visual labeling can find the exact element again later. Page addresses can’t point inside a box.

TODAY

“Room 12” … in which building?

button#open-btn → searched on the page, finds nothing — the room is inside a box the address can’t mention.
THE FIX

Two-part addresses: building »»» room

div#open-host >>> button#open-btn

Read as: “find the building on the page, step through its door, then find the room inside.”

Nested boxes? Just add a part.

div#nested-host >>> div#inner-host >>> button#inner-btn

We also ship the resolver that follows these addresses — the piece visual labeling will use.

Problem 4 → solved

Deaf to what happens inside

Clicks escape the box (renamed). Form changes — “typed an email”, “picked an option” — never leave the box at all.

📋 AutoCapture at the front door … hears nothing
SEALED COMPONENT
email address… 🔊 🎙️
“changed!” …bounces off the wall
TODAY

Change events are homebodies

Unlike clicks, “changed” signals stop at the box wall — no listener outside can ever hear them. Zero Element Changed events from components.

THE FIX · part 1

Put a microphone inside the box 🎙️

We can’t hear through the wall — so we listen inside. The moment a user first touches a component (a click or focus travels through the box and reveals it), we place a tiny listener inside. The “changed” that follows is heard.

THE FIX · part 2

Watchers fan out 👀

The page watchers that power zoning & “what’s on screen” get a twin inside every open box — including boxes that appear later.

Built to be safe

Guardrails

  • 🔌Off by default. Nothing changes until an org is switched on — by a config flag or remotely, per customer.
  • 🟰Switched off = identical to today. Locked in by a test suite that fails if even one byte of behavior drifts (487 tests, 100% coverage).
  • Budgets everywhere. How deep we look into nested boxes is capped (default 1, max 10), so huge pages stay fast.
  • 🛡️Can never crash a customer page. Every new code path is wrapped — worst case, one event is skipped.
  • 🕶️Privacy reaches inside. A “mask this” instruction on a component now masks everything within it — at any depth, deliberately unbudgeted.
Proof · live demo results

The same click, before and after

BEFORE — support off
Element Clicked · tag: div · text: (none)
address: div#open-host  ← a nameless box… or nothing at all
AFTER — support on
Element Clicked · tag: button · text: “open button”
address: div#open-host >>> button#open-btn
family tree: button → wrapper 🚪 → box → section → page
ScenarioPOC
Clicks in open components✓ captured
Form changes in open components✓ captured
Boxes inside boxes (nested)✓ captured
Components added after page load✓ captured
Visual-labeling addresses✓ emitted + resolvable
Closed (vault) componentssafely ignored — no errors

Verified live in a browser on the interactive playground, plus 550+ automated tests across the two packages.

Boundaries

What’s in, what’s out

IN SCOPE

Open (glass) Shadow DOM 🪟

The standard mode used by component frameworks — including the customer driving this work. Clicks, changes, addresses, family trees, zoning: covered.

OUT OF SCOPE

Closed (vault) Shadow DOM 🔒

The browser makes the inside unreachable for any page script — not an Amplitude limitation. We keep today’s behavior: the box itself may be logged, never its contents, never an error.

EDGE CASES · documented

Browser autofill into a never-touched component field · components that seal an already-visible element (affects zoning watchers only). Both rare, both no worse than today.

From POC to product

What’s next

  • 🖱️SDK capture side — this POC. Working; needs perf validation on large, real-world pages.
  • 🏷️Visual labeling adoption. The tagging UI & Chrome extension switch to the new address resolver we ship.
  • 🗄️Backend & query support. Charts and audiences need to understand the new two-part addresses and the 🚪 marker.
  • 🚦Rollout decision. Per-customer remote switch is already wired; who flips it, and when.

Try it yourself

pnpm dev → localhost:5173/autocapture/shadow-dom.html

Click the light vs. shadow buttons and watch the events — add ?shadow=off to see today’s behavior.

Technical deep dive · problem 1

Retargeting — and reading the real target from composedPath()

The browser rewrites event.target at every boundary

Retargeting is spec'd behavior, not a quirk: as an event propagates out of a shadow tree, the browser rewrites target to the nearest ancestor that lives in the same tree as the listener. Our listener is on document, so by the time we see the event its target has become the host element. Every listener sees a target appropriate to its own tree — which is exactly the encapsulation guarantee components are sold on.

What a document-level listener actually receives
document.addEventListener('click', (e) => { e.target // <my-widget> ← retargeted to the host e.composedPath()[0] // <button>Pay</button> ← the truth e.composedPath() // [button, div.wrapper, #shadow-root, my-widget, // section.checkout, body, html, document, Window] }, { capture: true });

Note the event reaches document at all only because click is composed: true. Events that are not composed never leave the box — that is problem 4, and it needs a different fix.

Before · the retargeted host is what we resolved from
// packages/plugin-autocapture-browser/src/data-extractor.ts (before) const closestTrackedAncestor = getClosestElement( baseEvent.event.target as HTMLElement, // ← <my-widget>, a bare div selectorAllowlist, ); // <my-widget> matches no allowlist selector → null → the click is DROPPED
After · one gated indirection, used everywhere a target is read
// data-extractor.ts — the whole fix resolveEventTarget = (event: Event): EventTarget | null => { if (!this.isShadowDomEnabled()) { return event.target; // off path: byte-identical to before } const path = event.composedPath?.(); return (path && path.length > 0 ? path[0] : null) ?? event.target; };

Every place that previously read event.target now calls this instead: click enrichment, change enrichment, the cursor-pointer check, and action-click resolution in track-action-click.ts. The optional-chaining and the ?? event.target tail mean an exotic event with no composedPath degrades to today's behavior rather than throwing.

Two details worth having ready for Q&A

Doesn't this leak closed components?

No — the browser enforces it for us. For a closed root, composedPath() omits the internals entirely and the first entry is the host. So resolveEventTarget returns the same host we get today: no new information, no error, no special-casing in our code.

Is this the same trick Session Replay uses?

Yes — rrweb resolves every interaction through a getEventTarget helper built on composedPath()[0]. It lives inside the bundled rrweb dist and isn't exported, so we reimplemented the five lines rather than importing a vendored internal. Same idea, independently gated.

Technical deep dive · problem 2

Why the climb stops — and how we step through

The wall is parentElement returning null

The element at the top of a shadow tree does have a parent — but that parent is the ShadowRoot itself, and a ShadowRoot is a DocumentFragment, not an Element. Since parentElement only returns parents that are Elements, it returns null there. Any loop written as “walk parentElement until null” quietly ends at the boundary.

The DOM in question
<body> <section class="checkout"> <my-widget> ← the "host" (lives in the light DOM) ┌─ #shadow-root (open) ─────┐ │ <div class="wrapper"> │ │ <button>Pay</button> │ └───────────────────────────┘
Walking up from the button
button.parentElement // <div class="wrapper"> ✓ normal step wrapper.parentElement // null ← THE WALL wrapper.parentNode // #shadow-root (nodeType 11, a DocumentFragment — not an Element)
So the old walker dead-ends after wrapper
// packages/plugin-autocapture-browser/src/hierarchy.ts (before) let current = targetEl.parentElement; while (current && current.tagName !== 'HTML') { ancestors.push(current); current = current.parentElement; // null at the shadow-tree top → loop exits } // result: [button, wrapper] — my-widget, section, body are invisible

The door: every shadow tree knows its owner

Two APIs combine into the step-through. getRootNode() hands back the shadow root the element lives in; that root's .host is the element that called attachShadow().

wrapper.getRootNode() // the #shadow-root this element lives in wrapper.getRootNode().host // <my-widget> — the element that called attachShadow()
Packaged as composedParent · packages/element-selector/src/helpers/shadow.ts
export function composedParent(el: Element): Element | null { if (el.parentElement) { return el.parentElement; // normal step within the same tree } const root = el.getRootNode(); if (isShadowRoot(root)) { return root.host; // at the tree top: step through to the host } return null; // genuinely the top (document) — stop for real }
And the new walker uses it only at the wall, on a budget · hierarchy.ts (after)
const nextAncestor = (): Element | null => { if (node.parentElement) { return node.parentElement; // same-tree steps are unchanged } if (crossShadow && crossings < maxShadowDepth) { const host = composedParent(node); // the door: ShadowRoot → host if (host && host !== node) { crossings += 1; return host; } } return null; // gate off / budget spent → today's behavior }; // result: [button, wrapper, my-widget, section, body] — complete

So the walk becomes: button → wrapper → (wall: parentElement is null → getRootNode().host) → my-widget → section → body. getClosestElement in helpers.ts does the identical hop for allowlist matching, with the same crossings < maxShadowDepth budget.

Two details worth having ready for Q&A

Why doesn't this break closed mode?

root.host actually works for closed shadow roots too — if you already hold a reference to an element inside. But from outside we never obtain one: composedPath() refuses to expose closed internals, so the walk never starts inside a closed tree in the first place. The gate is at entry, not at the hop.

Why the budget?

With crossShadow = false (the default) the hop never happens, so the off path is byte-identical to main. maxShadowDepth caps how many boundaries one event may cross (default 1, hard cap 10), keeping traversal cost predictable on deeply nested component trees.

Connection back to the slide

The 🚪 shadow: true marker in the recorded family tree is placed on exactly the node where parentElement was null (wrapper) — it tells downstream consumers “the next entry up is a host; descend through .shadowRoot, not .children, when re-resolving this path.”

Technical deep dive · problem 3

Why CSS can’t pierce — and the segment walk that does

A selector is scoped to one node tree, by design

document.querySelector searches the document tree only. A shadow tree is a separate node tree, so no selector string evaluated against the document can ever match an element inside one. CSS briefly had piercing combinators — /deep/ and a native >>> — and both were removed from the spec and from browsers precisely because they defeated encapsulation. There is no replacement coming.

document.querySelector('button#open-btn') // null — it's in another tree document.querySelector('div#open-host button') // null — descendant combinators stop too document.querySelector('div#open-host') .shadowRoot.querySelector('button#open-btn') // ✓ the only way in: two queries

“Two queries” is the whole insight. If one string can't work, the address becomes a sequence of per-tree strings, joined by a delimiter we own.

Generating: split the outward walk into one segment per tree · element-selector/src/helpers/shadow.ts
export function segmentWalk(el: Element, maxShadowDepth: number) { const innermostFirst = []; let node = el; while (node !== null) { innermostFirst.push({ root: rootOf(node), target: node }); const actualRoot = node.getRootNode(); if (!isShadowRoot(actualRoot)) break; // reached the document node = actualRoot.host; // cross the boundary outward } // …drop trees over the depth budget, then reverse to outermost-first return { segments: kept.reverse(), truncated }; }

Each segment is then handed to the existing selector engine, scoped to that segment's own root, and the results are joined with >>> . The per-tree algorithm is unchanged — we only changed how many times we run it and what scope we run it in.

Resolving: the symmetric inverse, shipped alongside
export function resolveSelector(root: ParentNode, selector: string) { const segments = selector.split(SHADOW_BOUNDARY_DELIMITER); let scope = root, found = null; for (let i = 0; i < segments.length; i++) { if (scope === null) return null; // previous host had no OPEN shadow root found = resolveSegment(scope, segments[i].trim()); if (found === null) return null; if (i < segments.length - 1) { scope = found.shadowRoot; // descend one boundary } } return found; }

One wrinkle: a ShadowRoot is not an element

Inside the document, a positional chain is implicitly anchored by the unique <html> root. Inside a shadow root there is no root element — so a chain like div:nth-of-type(2) > button would match anywhere in that tree, and :scope matches nothing because :scope needs an element. Root-anchored segments therefore carry a :scope > marker and are resolved by strict direct-child descent instead of querySelector.

Two details worth having ready for Q&A

Isn't >>> confusing, given CSS once used it?

The full string is never handed to querySelector — it's split first, and each segment on its own is ordinary, valid CSS. The delimiter is our encoding between our SDK and our tools, chosen to be visually obvious and impossible to confuse with the > child combinator we emit within a segment.

What happens on a closed component?

found.shadowRoot is null, scope becomes null, and the next iteration returns null cleanly. A closed-root address can't round-trip — a documented limitation, not a crash. In practice we never generate one, because capture never gets inside a closed tree to begin with.

Technical deep dive · problem 4

Composed events, and discovering roots we were never told about

Whether an event escapes the box is a per-event-type flag

Each DOM event carries a composed boolean. true means it crosses shadow boundaries and reaches outside listeners (retargeted — problem 1). false means it stops at the shadow root and no outside listener will ever see it, no matter where it's attached.

EventcomposedReaches our document listener?
clicktrueyes — retargeted to the host
focusintrueyes — retargeted to the host
inputtrueyes — retargeted to the host
changefalsenever

So Element Changed can only be captured by a listener placed inside the shadow root itself. Which raises the real problem: there is no API that enumerates a page's shadow roots. document.querySelectorAll can't see them, and nothing fires when one is created. We cannot know the roots up front.

Discovery rides on the composed events we already receive

A user cannot change a control without first interacting with it — and those interactions (focusin, click) are composed, so their composedPath() hands us every open root on the way to the control. We attach a change listener to each root the first time we see it.

observables.ts · the discovery pass, run on focusin and click
const path = event.composedPath(); const roots = path.filter((node) => node instanceof ShadowRoot); // composedPath is innermost-first: with N roots in the path, // roots[i] sits N - i boundary crossings below the document. roots.forEach((root, i) => { const depth = roots.length - i; if (depth <= cfg.maxDepth && !shadowRootsListened.has(root)) { shadowRootsListened.add(root); root.addEventListener('change', shadowChangeHandler, { capture: true }); } });

The same pass prunes roots whose host has left the DOM (!root.host.isConnected), so a component-heavy SPA doesn't accumulate listeners for unmounted trees.

Exactly-once: who owns an event

With listeners in several places, an event could be emitted twice. Ownership is decided by the event's true origincomposedPath()[0]'s root — never by target, which is retargeted per listener and would look local to every one of them.

// per-root listener: only events born in MY tree if (rootOfOrigin(event) === event.currentTarget) observer.next(event); // document listener: defer ONLY if a live per-root listener owns it if (originRoot instanceof ShadowRoot && shadowRootsListened.has(originRoot)) return; observer.next(event);

That second condition matters more than it looks. An earlier version deferred for any shadow origin — which silently dropped composed change events re-dispatched by web components from roots we'd never discovered. That made the feature-on path capture less than feature-off. Caught in adversarial review; the has(originRoot) check is the fix.

The other half: watchers for zoning

A MutationObserver with subtree: true also stops at each boundary. One observer instance is attached to document.body and to every open root found by an initial scan, with each batch's addedNodes re-scanned so late-mounting components get covered too. Exposure seeding unions querySelectorAll across the same roots.

Two details worth having ready for Q&A

Why listen on click too, if focusin precedes a change?

Because it doesn't, on WebKit. Safari (macOS and iOS) deliberately does not focus checkbox / radio controls on click — change fires with no focus event at all. Discovering on click as well closes what would otherwise be a browser-shaped hole in the data.

Why not patch attachShadow like rrweb does?

rrweb replaces Element.prototype.attachShadow globally, which catches every root at creation. That's a page-wide side effect on a method the customer's own framework calls; we chose scan-based discovery instead. The cost is one documented gap — a root attached to an element already in the DOM emits no mutation record, so zoning watchers can miss it (clicks and changes are unaffected, since those paths discover lazily).