Integrate Snowplow with Optimizely Feature Experimentation

Loading...·8 min read

Snowplow is a behavioural data pipeline rather than an analytics product. You define the shape of your events as JSON schemas, the pipeline validates every event against them, and the ones that pass land in your warehouse as strongly typed tables you own. Optimizely Feature Experimentation decides which variation of a flag each user receives. Forwarding those decisions into Snowplow means the variation travels with the data itself — not as a property bolted onto one event, but as a validated entity attached to every event a bucketed user produces.

That last distinction is what makes this integration different from every other one in this section. Most tools give you a user property and a tracked event, and the analysis afterwards is a join you have to remember to write. Snowplow lets you attach an entity to the event stream, so a user's experiment membership is present on their page views, their clicks, their form submissions and their transactions, automatically, without any downstream join at all.

Feature Experimentation exposes no Custom Analytics Integration UI, so the bridge is a decision notification listener: a callback that fires every time decide() resolves a flag.

How the Integration Works

The listener does two things. It tracks a self-describing event recording that the decision happened, and — more importantly — it registers a global context so the experiment entity rides along on everything tracked afterwards.

flowchart LR
    A["user.decide('checkout_redesign')"] --> B[DECISION notification fires]
    B --> C[Track experiment_viewed event]
    B --> D[Add global context entity]
    C --> E[Collector]
    D --> F[Every subsequent event carries the entity]
    F --> E
    E --> G{Schema validation}
    G -->|Passes| H[(Warehouse: typed columns)]
    G -->|Fails| I[Failed events stream]

Decision Notification Data

The listener's decision info object carries these fields for a flag-type decision. The browser and Node SDKs expose camelCase keys; the Python SDK exposes snake_case keys.

Field (camelCase / snake_case)

Type

Description

flagKey / flag_key

string

The flag key that was evaluated

enabled / enabled

boolean

Whether the flag is on for this user

variationKey / variation_key

string

The delivered variation key

ruleKey / rule_key

string

The experiment or delivery rule that matched

decisionEventDispatched / decision_event_dispatched

boolean

Whether Optimizely sent an impression for this decision

Event or Entity

Snowplow models these as two different things, and choosing correctly is most of the design work.

Self-describing event

Entity (context)

Represents

Something that happened

Something that was true at the time

Lands in

Its own table

A column on every event that carried it

Answers

"When was the user bucketed?"

"Which variation was this click made under?"

Downstream cost

A join per analysis

None

Experiment membership is a state, not an occurrence, so the entity is the primary modelling choice and the event is a useful record of first exposure. Track both. The entity is what makes every subsequent analysis trivial; the event is what lets you build an exposure cohort with a timestamp.

The Schemas

Nothing can be tracked until its schema exists in your Iglu registry, because the pipeline validates against it. Two schemas are needed.

{
  "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#",
  "description": "An Optimizely Feature Experimentation flag decision delivered to a user",
  "self": {
    "vendor": "com.example",
    "name": "experiment",
    "format": "jsonschema",
    "version": "1-0-0"
  },
  "type": "object",
  "properties": {
    "flag_key": { "type": "string", "maxLength": 255 },
    "variation_key": { "type": ["string", "null"], "maxLength": 255 },
    "rule_key": { "type": ["string", "null"], "maxLength": 255 },
    "enabled": { "type": "boolean" },
    "event_dispatched": { "type": "boolean" }
  },
  "required": ["flag_key", "enabled"],
  "additionalProperties": false
}

The second schema, experiment_viewed, describes the exposure event and can carry the same properties. Publishing both before you deploy the listener is not optional: an event whose schema cannot be resolved does not disappear, it goes to the failed events stream. That is the single most important operational fact in this article, and the reason "no data arrived" and "the data is in bad rows" are different problems with the same symptom.

Note "variation_key": { "type": ["string", "null"] }. A disabled flag has no variation, and a schema that requires a string will reject every decision where the flag is off — turning a normal condition into a stream of validation failures.

Prerequisites

  • Optimizely Feature Experimentation SDK for your platform — the JavaScript SDK v6 or later, the Node.js SDK, or the Python SDK.

  • A running Snowplow pipeline: a collector endpoint, an enrichment stage, and a warehouse loader.

  • Write access to your Iglu schema registry, and both schemas above published to it.

  • The relevant Snowplow tracker installed@snowplow/browser-tracker, @snowplow/node-tracker, or the snowplow-tracker Python package.

  • A monitor on the failed events stream. Without one, this integration can be completely broken for a week while every dashboard shows a plausible-looking absence of data.

Browser Implementation

import {
  newTracker,
  setUserId,
  trackSelfDescribingEvent,
  addGlobalContexts,
} from '@snowplow/browser-tracker';
import { createInstance, enums } from '@optimizely/optimizely-sdk';

newTracker('sp', '<YOUR_COLLECTOR_ENDPOINT>', { appId: 'web' });

const EXPERIMENT_SCHEMA = 'iglu:com.example/experiment/jsonschema/1-0-0';
const VIEWED_SCHEMA = 'iglu:com.example/experiment_viewed/jsonschema/1-0-0';

const optimizely = createInstance({ sdkKey: '<YOUR_SDK_KEY>' });

optimizely.onReady().then(() => {
  optimizely.notificationCenter.addNotificationListener(
    enums.NOTIFICATION_TYPES.DECISION,
    ({ type, userId, decisionInfo }) => {
      if (type !== 'flag') return;

      const { flagKey, enabled, variationKey, ruleKey, decisionEventDispatched } = decisionInfo;

      const entity = {
        schema: EXPERIMENT_SCHEMA,
        data: {
          flag_key: flagKey,
          variation_key: enabled ? variationKey : null,
          rule_key: ruleKey || null,
          enabled,
          event_dispatched: decisionEventDispatched,
        },
      };

      setUserId(userId);

      // Rides along on every event tracked from here on — no join needed later
      addGlobalContexts([entity]);

      // A discrete record of first exposure, with its own timestamp
      trackSelfDescribingEvent({
        event: { schema: VIEWED_SCHEMA, data: entity.data },
        context: [entity],
      });
    },
  );

  const user = optimizely.createUserContext('user_123', { plan: 'pro' });
  const decision = user.decide('checkout_redesign');
  console.log('Variation:', decision.variationKey);
});

Global contexts attach to events tracked after they are added, which makes ordering matter in the same way it does for a page view. If your application tracks its page view on load and resolves flags a moment later, that page view carries no entity. Resolve the flags first, or accept that the entry page view of a session is unattributed and analyse from the next event onward.

Note that variation_key is written as null rather than the string 'off' used elsewhere in this section. Snowplow is typed: a nullable column expresses "no variation" precisely, and inventing a sentinel value would make a legitimate absence look like a variation named off in your warehouse.

Server-Side Node.js Implementation

The Node tracker has no notion of a global context, because a server process has no single user. Every event carries its entity explicitly.

const { tracker, gotEmitter, buildSelfDescribingEvent } = require('@snowplow/node-tracker');
const optimizelySdk = require('@optimizely/optimizely-sdk');
const { enums } = require('@optimizely/optimizely-sdk');

const emitter = gotEmitter('<YOUR_COLLECTOR_HOST>', 'https', 443, 'POST', 10);
const snowplow = tracker([emitter], 'backend', 'api', false);

const EXPERIMENT_SCHEMA = 'iglu:com.example/experiment/jsonschema/1-0-0';
const VIEWED_SCHEMA = 'iglu:com.example/experiment_viewed/jsonschema/1-0-0';

const optimizely = optimizelySdk.createInstance({ sdkKey: process.env.OPTIMIZELY_SDK_KEY });

optimizely.onReady().then(() => {
  optimizely.notificationCenter.addNotificationListener(
    enums.NOTIFICATION_TYPES.DECISION,
    ({ type, userId, decisionInfo }) => {
      if (type !== 'flag') return;

      const { flagKey, enabled, variationKey, ruleKey, decisionEventDispatched } = decisionInfo;

      const data = {
        flag_key: flagKey,
        variation_key: enabled ? variationKey : null,
        rule_key: ruleKey || null,
        enabled,
        event_dispatched: decisionEventDispatched,
      };

      snowplow.setUserId(userId);
      snowplow.track(
        buildSelfDescribingEvent({ event: { schema: VIEWED_SCHEMA, data } }),
        [{ schema: EXPERIMENT_SCHEMA, data }],
      );
    },
  );
});

The emitter buffers — the 10 above is the buffer size — so a process that exits with a partial buffer loses those events. In a serverless function or a short-lived worker, either set the buffer size to 1 and accept one request per event, or flush explicitly before the process ends.

setUserId on a shared tracker instance is process-global state, which is safe in a single-threaded handler serving one request and unsafe in a concurrent server. Where requests interleave, create a tracker per request, or pass the user identity as event-level data rather than tracker state.

Python Implementation

The Python SDK's DECISION callback takes four positional arguments and exposes snake_case keys in decision_info — which, unusually, is exactly the naming Snowplow schemas want, so no translation is needed on this path.

import os

from optimizely import optimizely
from optimizely.helpers import enums
from snowplow_tracker import Emitter, SelfDescribingJson, Tracker

EXPERIMENT_SCHEMA = 'iglu:com.example/experiment/jsonschema/1-0-0'
VIEWED_SCHEMA = 'iglu:com.example/experiment_viewed/jsonschema/1-0-0'

emitter = Emitter(os.environ['SNOWPLOW_COLLECTOR'], protocol='https')
snowplow = Tracker(emitters=emitter, namespace='backend', app_id='api')


def on_decision(decision_type, user_id, attributes, decision_info):
    if decision_type != 'flag':
        return

    enabled = decision_info.get('enabled')

    data = {
        'flag_key': decision_info.get('flag_key'),
        'variation_key': decision_info.get('variation_key') if enabled else None,
        'rule_key': decision_info.get('rule_key'),
        'enabled': enabled,
        'event_dispatched': decision_info.get('decision_event_dispatched'),
    }

    entity = SelfDescribingJson(EXPERIMENT_SCHEMA, data)

    snowplow.set_user_id(user_id)
    # snowplow-tracker 0.x spelling. Version 1.0 renames this to
    # tracker.track(SelfDescribing(event=SelfDescribingJson(...)), ...).
    snowplow.track_self_describing_event(
        event_json=SelfDescribingJson(VIEWED_SCHEMA, data),
        context=[entity],
    )


optimizely_client = optimizely.Optimizely(sdk_key=os.environ['OPTIMIZELY_SDK_KEY'])
optimizely_client.notification_center.add_notification_listener(
    enums.NotificationTypes.DECISION, on_decision
)

user = optimizely_client.create_user_context('user_123', {'plan': 'pro'})
decision = user.decide('checkout_redesign')
print('Variation:', decision.variation_key)

Check which major version of snowplow-tracker your project pins before copying this. The tracking call was renamed between the 0.x and 1.x lines, and the older spelling raises an attribute error rather than warning you.

Verifying the Integration

Snowplow's failure mode is quiet, so verify in this order.

  1. Confirm both schemas resolve from your Iglu registry before deploying anything.

  2. Trigger a decision and watch the collector receive the request — in the browser, the network tab; on the server, the emitter's logs.

  3. Query the failed events stream or table for the window around your test. Do this before checking the good events. A validation failure here is a schema problem, not a tracking problem, and looking at the warehouse first will send you debugging the wrong layer.

  4. Query the atomic events table for the experiment_viewed event and confirm its fields.

  5. Track any other event in the same session and confirm the experiment entity is attached to it. That is the global context working.

  6. Repeat with the flag off and confirm variation_key is null and the event still validates.

Analyzing Experiments in the Warehouse

Because the entity rides along, the analysis you want is usually a WHERE, not a join:

SELECT experiment.variation_key,
       COUNT(DISTINCT events.domain_userid) AS users,
       COUNT(*)                             AS events
FROM atomic.events AS events,
     UNNEST(events.contexts_com_example_experiment_1) AS experiment
WHERE experiment.flag_key = 'checkout_redesign'
  AND events.collector_tstamp >= '2026-08-01'
GROUP BY experiment.variation_key
ORDER BY experiment.variation_key;

The exact column name and unnesting syntax depend on your warehouse and loader — the entity's schema name and version are encoded into the column, so a bump to 1-0-1 produces a new one. Plan for that: schema versions are how Snowplow gives you safety, and they are also how a dashboard silently stops finding data after a change.

For a proper exposure cohort, take the first experiment_viewed per user and join outcomes that occurred after it, exactly as you would with any warehouse-based experiment analysis. The entity makes the breakdown free; it does not make the cohorting free.

Troubleshooting

Symptom

Likely cause

Fix

No events at all

Collector endpoint wrong, or tracker never initialized

Check the network request reaches the collector

Events arrive but the table is empty

Schema failed validation

Read the failed events stream first, always

Every decision fails validation when a flag is off

variation_key typed as a required string

Allow null in the schema

Entity missing from most events

Global context added after those events were tracked

Resolve flags before tracking the page view

Entity missing on the server

Node tracker has no global contexts

Pass the entity explicitly with every event

Users mixed up under load

setUserId on a shared tracker in a concurrent server

Tracker per request, or user as event data

Events lost on deploys

Emitter buffer discarded at exit

Flush before shutdown, or reduce the buffer size

Warehouse column disappeared

Schema version bumped

Query the new versioned column; migrate deliberately

Related Reading