Skip to main content
Ask your AI
Source code Package

Browser source

Captures user interactions directly from the DOM using data-elb-* attributes. Extracts tracking data from markup, tracks pageviews automatically or manually, exposes an enhanced elb function with walker init support, and provides a trigger system covering click, submit, load, hover, scroll, and visible.

Where this fits

The browser source is a source in the walkerOS flow:

It reads `data-elb-*` attributes from your HTML and generates events. The Collector routes these to your configured destinations.

Installation​

Install the packages:

npm install @walkeros/collector @walkeros/web-source-browser @walkeros/web-source-session

Configure in your code:

import { startFlow } from '@walkeros/collector';
import { sourceBrowser } from '@walkeros/web-source-browser';
import { sourceSession } from '@walkeros/web-source-session';

const { collector, elb } = await startFlow({
  sources: {
    browser: { code: sourceBrowser },
    session: { code: sourceSession }, // Add for session tracking
  },
});

Configuration​

This source uses the standard source config wrapper (consent, data, env, id, ...). For the shared fields see source configuration. Package-specific fields live under config.settings and are listed below.

Settings​

PropertyTypeDescriptionMore
prefixstringPrefix for data attributes (default: data-elb)
scopestringDOM scope for event tracking (default: document)
pageviewbooleanEnable automatic pageview tracking
capturebooleanRead click/submit triggers in the capture phase so tagged elements are resolved at click time before app handlers run (set false for the previous bubble-phase behavior)
historybooleanSingle-page apps: send a page view on every route change (pushState, replaceState with a new path, back and forward) and pick up tagged elements as the new route renders
elbstring | booleanName for global elb function, or false to install none
namestringCustom name for source instance
elbLayerboolean | string | anyEnable elbLayer for async command queuing (boolean, string, or Elb.Layer)

Mapping​

This package does not define custom rule-level settings. For the standard rule fields (consent, condition, data, batch, name, policy) see mapping.

Examples

Click event

A button click with walker data attributes is captured as an entity action event with the mapped label data.

Event
"<button data-elb=\"cta\" data-elb-cta=\"label:Sign Up\" data-elbaction=\"click:click\">Sign Up</button>"
Out
elb({
  "name": "cta click",
  "entity": "cta",
  "action": "click",
  "data": {
    "label": "Sign Up"
  },
  "context": {},
  "globals": {},
  "nested": [],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "click"
})

Context and globals

Walker context and globals attributes attach ambient metadata to events emitted by the browser source.

Event
"<div data-elbcontext=\"test:engagement_flow\" data-elbglobals=\"language:en;plan:premium\"><div data-elb=\"cta\" data-elb-cta=\"label:Try Now\" data-elbaction=\"click:signup\">Try Now</div></div>"
Out
elb({
  "name": "cta signup",
  "entity": "cta",
  "action": "signup",
  "data": {
    "label": "Try Now"
  },
  "context": {
    "test": [
      "engagement_flow",
      0
    ]
  },
  "globals": {
    "language": "en",
    "plan": "premium"
  },
  "nested": [],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "click"
})

Data attribute types

Walker data attributes parse scalar, boolean, and array values into typed fields on the emitted event.

Event
"<div data-elb=\"product\" data-elb-product=\"price:99.99;available:true;colors[]:red;colors[]:blue\" data-elbaction=\"click:select\"></div>"
Out
elb({
  "name": "product select",
  "entity": "product",
  "action": "select",
  "data": {
    "price": 99.99,
    "available": true,
    "colors": [
      "red",
      "blue"
    ]
  },
  "context": {},
  "globals": {},
  "nested": [],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "click"
})

Impression event

An intersection observer impression on a div with walker attributes emits a banner view event.

Event
"<div data-elb=\"banner\" data-elb-banner=\"type:promotional;position:sidebar\" data-elbaction=\"impression:view\"></div>"
Out
elb({
  "name": "banner view",
  "entity": "banner",
  "action": "view",
  "data": {
    "type": "promotional",
    "position": "sidebar"
  },
  "context": {},
  "globals": {},
  "nested": [],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "impression"
})

Nested entities

A page load produces a page view plus a product view whose nested size entity is captured on the product event.

Event
"<div data-elb=\"product\" data-elb-product=\"id:SKU-42;name:Sneakers\" data-elbaction=\"load:view\"><div data-elb=\"size\" data-elb-size=\"selected:large;inStock:true\"></div></div>"
Out
elb({
  "name": "page view",
  "data": {
    "domain": "example.com",
    "title": "",
    "referrer": "",
    "id": "/"
  },
  "context": {},
  "globals": {},
  "trigger": "load",
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  }
});

elb({
  "name": "product view",
  "entity": "product",
  "action": "view",
  "data": {
    "id": "SKU-42",
    "name": "Sneakers"
  },
  "context": {},
  "globals": {},
  "nested": [
    {
      "entity": "size",
      "data": {
        "selected": "large",
        "inStock": true
      },
      "context": {},
      "nested": []
    }
  ],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "load"
})

Page view

A page load trigger captures the current URL, title, and referrer as a walker page view event.

Event
""
Out
elb({
  "name": "page view",
  "data": {
    "domain": "example.com",
    "title": "Documentation",
    "referrer": "https://example.com/",
    "id": "/docs"
  },
  "context": {},
  "globals": {},
  "trigger": "load",
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/docs",
    "referrer": "https://example.com/"
  }
})

Route change

With history enabled, a single-page app route change sends a page view for the new URL, with the previous page as its referrer.

Event
""
Out
elb({
  "name": "page view",
  "data": {
    "domain": "example.com",
    "title": "",
    "referrer": "",
    "id": "/docs"
  },
  "context": {},
  "globals": {},
  "trigger": "load",
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/docs",
    "referrer": ""
  }
});

elb({
  "name": "page view",
  "data": {
    "domain": "example.com",
    "title": "",
    "referrer": "https://example.com/docs",
    "id": "/pricing"
  },
  "context": {},
  "globals": {},
  "trigger": "load",
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/pricing",
    "referrer": ""
  }
})

Submit event

A form submit with walker data attributes produces a signup complete event carrying the selected plan.

Event
"<form data-elb=\"signup\" data-elb-signup=\"plan:premium\" data-elbaction=\"submit:complete\"></form>"
Out
elb({
  "name": "signup complete",
  "entity": "signup",
  "action": "complete",
  "data": {
    "plan": "premium"
  },
  "context": {},
  "globals": {},
  "nested": [],
  "source": {
    "type": "browser",
    "platform": "web",
    "url": "https://example.com/",
    "referrer": ""
  },
  "trigger": "submit"
})

Enhanced elb​

With a browser source in sources, the elb that startFlow returns is the browser source's own, and the source installs it as window.elb as well. It takes the same partial event as the collector's elb:

// The event also gets source.url, source.referrer and the page's globals
await elb({ name: 'product view', data: { id: 'P123' } });

// A DOM command: register tagged elements added after the page loaded
await elb('walker init', document.querySelector('#app'));

Browser source elb adds:

  • Page context: the tagged globals on every event, plus source.url and source.referrer unless the event brings its own source
  • DOM commands: walker init for asynchronous DOM element loading
  • elbLayer integration: automatic processing of queued commands

See Commands for complete API documentation.

Single-page apps​

With history: true the source follows route changes by itself. walker.js has it on; in your own bundle, set it on the browser source:

"browser": {
  "package": "@walkeros/web-source-browser",
  "config": {
    "settings": { "history": true }
  }
}

What counts as a route change. A pushState or a back or forward navigation that changes the path or the query, and a replaceState that changes the path. A replaceState that only rewrites the query (filters, search boxes) and hash-only changes do not count. Each comparison is against the URL right before that change.

What a route change sends. A new run starts at once: a page view for the URL at the moment of the change, with the previous page view's URL (without its fragment) as data.referrer. source.referrer stays the document's referrer, for attribution. Route changes never start a new session. Every page view after the first carries the previous URL this way, also when you call elb('walker run') yourself, so a URL redaction rule for personal data in query strings must cover data.referrer too.

Tagged elements. A route change does not re-scan the page. The source watches the document and registers tagged elements whenever they are added, one task after they appear, so a route's load events follow its page view. On the first page load the page's load elements fire before the page view.

Migrating. Remove every route-change elb('walker run') call and every GTM History Change tag that calls it. With history on, they make every navigation send two page views and fire the page's load elements again.

Reused route components. The source watches added and removed elements, not attribute changes. A router that reuses a component when only a parameter changes patches its attributes in place, so its tagged elements do not fire again. Remount the component per route: React key={id}, Vue <RouterView :key="route.fullPath">, Svelte {#key}, Next.js template.tsx. Values read with #innerText on a reused element do not fire again either.

Elements that stay across routes fire load and impression once per page load, and their scroll, wait and pulse triggers are not re-armed. A promo in a persistent layout counts one impression per page load. For per-route counts, tag elements inside the route's content, or use visible.

Page title, page attributes and globals are read when the run reaches the page view. Routers that render or set the title later (React Router, Next.js Pages Router, TanStack Router, Vue with unhead, Nuxt) report the previous route's values. When you need exact values, keep history: false and call elb('walker run') from the router's post-render hook:

RouterHook
React RouteruseLayoutEffect on location.key
TanStack RouteronRendered
Vue RouterafterEach plus nextTick (title: unhead dom:rendered)
Nuxtpage:finish (title: unhead dom:rendered)
AngularNavigationEnd plus afterNextRender
SvelteKitafterNavigate
Astroastro:page-load
Next.js App Routerno reliable hook

Consent. A destination that waits for consent holds the events from before the visitor's answer, and every run clears that buffer. A route change is a run, so when the visitor navigates before answering the banner, the landing page view is lost, and so is its session start unless the session source itself waits for consent. A multi-page site loses the same when the visitor follows a link.

One per page. Run one browser source with history on a page.

Not covered: hash routers, same-origin iframes, HTML-over-the-wire routers (Turbo, htmx, Livewire), tagged content rendered later inside shadow roots, and data-elbobserve containers inside shadow roots while history is on. Next.js 16 cacheComponents reveals cached routes on revisit without adding elements, so those revisits send a page view but no load events; use visible instead.

With history: false (the default) route changes are not followed, data-elbobserve containers work as before, and elb('walker run') re-scans the page.

Multiple sources on one page​

Any number of browser sources can run on one page. Each keeps its own triggers, its own element registry, and its own visibility state, so nothing one source wires, times, or tears down reaches another.

Two page-level resources are the exception, because each is a single slot:

ResourceSettingRule
The writer installed at window[elb]elbThe first source to install the name keeps it.
The command queue at window[elbLayer]elbLayerThe first source to adopt the array keeps it.

A source that asks for a name another source already holds is refused that one resource and logs an error. Everything else keeps working: its scope is still scanned, its triggers still fire, and its own push still resolves. A page's own bootstrap, the elb stub function and pre-filled elbLayer array defined before the source loads, belongs to no source and is adopted as usual.

The DOM is not owned. Any number of sources may scan the same node, document included. That is how two flows run on one page: give each its own elb name and its own elbLayer name (or false), and let them share the markup.

A vendor flow beside the page's own​

Distinct names on both single-slot resources mean nothing is refused on either side. pageview: false leaves the page view event to the walker the page already runs, and the scope narrows the vendor's delegation to the subtree it cares about.

import { startFlow } from '@walkeros/collector';
import { sourceBrowser } from '@walkeros/web-source-browser';

const { collector, elb } = await startFlow({
  sources: {
    browser: {
      code: sourceBrowser,
      config: {
        settings: {
          elb: 'vendorElb',
          elbLayer: 'vendorLayer',
          scope: document.body,
          pageview: false,
        },
      },
    },
  },
});

An embedded overlay with no window footprint​

elb: false installs no window property at all and elbLayer: false creates no queue, so the source adds nothing to the page window and reaches its collector through the elb that startFlow returns. Pair it with an explicit scope and the overlay captures only its own markup.

import { startFlow } from '@walkeros/collector';
import { sourceBrowser } from '@walkeros/web-source-browser';

const host = document.createElement('div');
document.body.appendChild(host);
const overlay = host.attachShadow({ mode: 'closed' });

const { collector, elb } = await startFlow({
  sources: {
    browser: {
      code: sourceBrowser,
      config: {
        settings: {
          elb: false,
          elbLayer: false,
          scope: overlay,
          pageview: false,
        },
      },
    },
  },
});

One elbLayer belongs to one walker​

A queue is drained once. The source that adopts a named layer reads every entry, and a command like walker run has exactly one addressee, so a second source on the same array would starve the first rather than join it. Parallel flows use separate layer names or elbLayer: false, and share instrumentation through the data-elb attributes rather than through a queue.

Overlapping scopes fire once per source​

Two sources scanning the same tagged element wire it twice. click, hover, pulse, scroll, wait, impression, and visible each fire once per source, into that source's own pipeline, and an observed element carries one dwell timer per source.

The prefix setting decides whether that is what you want:

  • Same prefix is deliberate dual collect. One set of attributes feeds both pipelines, and each collector sees the interaction once.
  • Different prefix isolates the two. Each source only sees elements tagged for it, so overlapping scopes cost nothing.
note

Give overlapping sources the same scope root. A source discovers data-elbobserve containers inside its own scope only. When two sources share a prefix and the container sits outside one of their scopes, only the source that discovered it reaps elements the container removes. For the other one that element's teardown never runs: its pulse intervals keep firing, its wait timeouts stay armed, and its hover and visibility registrations stay in place against a node that has left the document, which also keeps that node referenced until the source is torn down. A shared scope root means both sources see the same containers.

Known limits​

  • Version skew. A page running an older release of the package leaves no ownership mark, so a source injected later reads that layer as free and adopts it. This only matters when both use the same layer name, which is what the contract above tells parallel flows not to do.
  • The same script included twice. Each copy scans the same markup into its own pipeline, so every event is collected twice. That is a page misconfiguration rather than something the source can resolve, and it is diagnosable: both refusals log at initialization.

Next steps​

💡 Need implementation support?
elbwalker offers hands-on support: setup review, measurement planning, destination mapping, and live troubleshooting. Book a 2-hour session (€399)