Event bus
avsb.on('event', callback) calls your function every time A vs B records an experiment event: a visitor being shown a variation, a goal firing, a purchase, or a recommendation being seen or clicked.
Use it to send experiment data somewhere A vs B does not talk to directly, or to watch what is happening while you build.
window.avsb.onEvent no longer exists. It held one callback, so a second integration silently replaced the first. avsb.on takes as many subscribers as you like and hands each one an unsubscribe function. If you have window.avsb.onEvent = fn anywhere, change it to avsb.on('event', fn). Nothing else about the data changes.
Subscribing
const unsubscribe = avsb.on?.('event', function (event) { console.log(event.event_name, event.experiment_name, event.variation_name)})Call the returned function to stop receiving events:
unsubscribe?.()on is declared optional in @avsbhq/snippet-types because the preview and edit bypass runtimes build a cut-down avsb object without it. Every normal page has it, so plain avsb.on('event', fn) runs fine. The optional call is what keeps TypeScript happy, and it costs nothing in plain JavaScript.
Subscribing more than once is fine. Each subscriber gets every event, and unsubscribing one leaves the others alone.
Subscribe early enough to catch the first event
The most important event usually fires within milliseconds of the snippet loading, so where you subscribe decides whether you see it.
- Project JS: subscribing inside your project's JavaScript is the simplest reliable option. It runs before any experiment is evaluated, so you catch everything.
- Your own inline script: this works too, as long as the install stub sits above it. The stub holds early
avsb.oncalls and replays them the moment the snippet installs, so nothing is missed.
The stub is the first of the two tags you already paste from Project Settings > Snippet:
<script>window.avsb=window.avsb||{};window.avsb.q=window.avsb.q||[];window.avsb.ready=window.avsb.ready||function(f){window.avsb.q.push(f)};window.avsb.on=window.avsb.on||function(n,f){var t=['on',n,f];window.avsb.q.push(t);return function(){t[0]=null}};window.avsb.consent=window.avsb.consent||{set:function(s){window.avsb.q.push(['consent.set',s])},get:function(){return{}}};window.avsb.track=window.avsb.track||{};['event','segment','purchase','cart'].forEach(function(m){window.avsb.track[m]=window.avsb.track[m]||function(a,b){window.avsb.q.push(['track.'+m,a,b])}});(function(){window.avsb._t0=Date.now();window.avsb._df=fetch('https://cdn.avsb.cloud/YOUR_SNIPPET_KEY/datafile.json').catch(function(){});var d=document,e=d.documentElement,s=d.currentScript,a=s&&s.getAttribute('data-avsb-timeout'),t=a==null?3000:+a;if(!(t>0)||window.avsb.version)return;e.style.opacity='0';e.style.pointerEvents='none';window.avsb._t=setTimeout(function(){e.style.removeProperty('opacity');e.style.removeProperty('pointer-events')},t)})();</script><script src="https://cdn.avsb.cloud/snippet.js?id=YOUR_SNIPPET_KEY" data-avsb="YOUR_SNIPPET_KEY" async></script>With that in place, subscribe from any script below it:
<script> avsb.on('event', function (event) { // your integration });</script>Callbacks passed to avsb.ready() run after experiments have been evaluated, so a subscription made there misses the experiment views that already fired. Use Project JS, or subscribe below the stub.
Older installs have a stub without the avsb.on line, and early subscriptions are dropped on those pages. Re-copy the snippet from Project Settings > Snippet to fix it. Subscribing from Project JS works either way.
What your callback receives
Every event is the same record, whatever kind it is.
| Name | Type | Always present | Description |
|---|---|---|---|
experiment_id | string | Yes | The experiment's ID, the same one shown in A vs B. |
experiment_name | string | Yes | The experiment's name. |
variation_id | string | Yes | The ID of the variation the visitor saw. |
variation_name | string | Yes | The name of that variation, such as "Control" or "Variant B". |
event_type | string | Yes | One of exposure, goal, purchase, rec_impression, rec_click, custom, test. Branch on this rather than on the name. |
event_name | string | Yes | The name this event is being sent under. |
event_id | string | Yes | A unique ID for this event, for lining records up between systems. |
revenue | number or null | Yes | The revenue for this event, or null when there is none. |
value | number or null | Yes | The generic value for this event, such as load time or scroll depth, or null. |
currency | string | No | Purchases only, when you supplied one. |
transaction_id | string | No | Purchases only: your order ID. |
goal_key | string | No | Conversions and your own tracked events: which goal or event key fired. |
visitor_id | string | Yes | The A vs B visitor ID. |
page_url | string | Yes | The page the event happened on. |
timestamp | number | Yes | When it happened, in milliseconds. |
sdk_version | string | Yes | The snippet version that sent it. |
attributes | object | No | Recommendation events only: recipe, item, position, and similar. |
source | string | No | Present only for preview or internal traffic. Absent means an ordinary visitor. |
event_name changes if you rename events on the Integrations tab. event_type never does, so use it for anything conditional.
The bus sees more than your analytics tools do
Two differences are worth knowing, because they are deliberate:
- Preview and internal traffic reaches the bus. When you are previewing a variation or your traffic is marked internal, nothing is sent to your analytics tools, but the bus still fires so you can watch it while testing. Those records carry
source. Check for it if you want to ignore them. - Recommendation events outside an experiment reach the bus too. Recommendations shown outside any experiment arrive with the experiment and variation fields empty. Analytics tools do not receive those.
Events that a visitor's consent choice has suppressed do not reach the bus at all.
Examples
Only experiment views:
avsb.on?.('event', function (event) { if (event.event_type !== 'exposure') return myAnalytics.track('Experiment Viewed', { experiment: event.experiment_name, variation: event.variation_name, })})Ignore preview and internal traffic:
avsb.on?.('event', function (event) { if (event.source) return // preview or internal QA myAnalytics.track(event.event_name, { ...event })})Purchases with revenue:
avsb.on?.('event', function (event) { if (event.event_type !== 'purchase') return myAnalytics.revenue({ orderId: event.transaction_id, amount: event.revenue, currency: event.currency, variation: event.variation_name, })})Two integrations, kept separate:
declare function sendToWarehouse(event: AvsbIntegrationRecord): voidconst stopWarehouse = avsb.on?.('event', sendToWarehouse)const stopConsole = avsb.on?.('event', function (event) { console.log('[A vs B]', event.event_type, event.experiment_name)})// Later, stop just the logging.stopConsole?.()If you write your Project JS in TypeScript, the record passed to your callback is fully typed, with autocomplete for every field above.
Your function runs while A vs B is processing the event. Avoid anything blocking, such as a synchronous network request. Analytics calls like gtag, analytics.track(), and mixpanel.track() are non blocking and safe here. If your subscriber throws, A vs B carries on and every other subscriber still runs.
For the nine tools on the Integrations tab, switching a card on does all of this for you, with the right event shape for each tool. Reach for the bus when you want something those cards do not cover.