Integrate FullStory with Optimizely Feature Experimentation

Loading...·9 min read

FullStory captures and replays the browser session — every click, scroll, and frustration signal a real user produces. Optimizely Feature Experimentation makes flag and variation decisions that can happen in the browser, on your server, or both. Bridging the two means taking each flag decision the SDK reports and attaching it to the FullStory session so you can replay, segment, and run funnels against the variation a user actually received.

Unlike Web Experimentation, Feature Experimentation has no Custom Analytics Integration UI. The bridge is a decision notification listener: a callback you register on the Optimizely client that fires every time decide() resolves a flag. Where that callback runs determines which FullStory API you call. In the browser, you call FullStory's V2 Browser API directly (FS('trackEvent', …)). On the server, the browser FS function does not exist, so you call FullStory's Server API V2 over HTTP — and identity matching becomes the critical detail, because the server has no access to the FullStory session cookie.

How the Integration Works

The decision notification listener receives the flag key, whether the flag is enabled, the variation key, and the rule key for every decision. The integration forwards this to FullStory as a user property (so the session inherits the experiment context) and an "Experiment Viewed" event (so it is searchable and chartable).

flowchart LR
    A["optimizely.decide('flag')"] --> B[DECISION notification fires]
    B --> C{Where does the listener run?}
    C -->|Browser| D["FS('setProperties') + FS('trackEvent')"]
    C -->|Server| E["POST /v2/users + POST /v2/events"]
    D --> F[FullStory session]
    E --> F
    F --> G[OmniSearch, segments, replays, funnels by variation]

Decision Notification Data

The DECISION listener's decisionInfo object contains the following fields for a flag-type decision:

Field

Type

Description

flagKey

string

The flag key that was evaluated

enabled

boolean

Whether the flag is enabled for this user

variationKey

string

The delivered variation key

ruleKey

string

The experiment or delivery rule that matched

decisionEventDispatched

boolean

Whether Optimizely sent an impression for this decision

decisionEventDispatched is worth checking: when it is false, Optimizely did not count an impression (for example, a flag delivered without an experiment), so forwarding that decision to FullStory may inflate your experiment counts relative to Optimizely's results.

User Property Format

The integration sets a single user property per flag, keyed by flag and valued by variation:

[Optimizely] checkout_redesign = treatment

This mirrors the convention used across Optimizely's own analytics integration examples and keeps multiple concurrent flags from colliding, since each flag writes to its own key.

Prerequisites

Before starting the integration:

  • Optimizely Feature Experimentation SDK installed for your platform (JavaScript SDK v6+, Node.js SDK, or Python SDK).

  • FullStory org ID (browser) — found under Settings > FullStory Setup — and/or a FullStory Server API key (server) — created under Settings > Integrations & API Keys with at least Standard permission.

  • HTTP client for server-side use — Node.js 18+ has fetch built in; Python uses the requests library.

  • Consistent user identity — the uid you send to FullStory's Server API must match the identity FullStory uses for that user in the browser (set via FS('setIdentity', { uid })). Without a matching uid, server-side experiment data lands on a different FullStory user than the browser session, breaking replay-level analysis.

JavaScript / Browser Implementation

When the Optimizely FX SDK runs in the browser, call FullStory's V2 Browser API directly. The FullStory snippet is already on the page, so no HTTP calls or API keys are required.

import { createInstance, enums } from '@optimizely/optimizely-sdk';

// Resolve the FullStory global (respect a custom namespace)
const FS = window[window._fs_namespace || 'FS'];

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 } = decisionInfo;

      const propertyName = `[Optimizely] ${flagKey}`;
      const propertyValue = enabled ? variationKey : 'off';

      // User property: inherited by the whole session
      FS('setProperties', {
        type: 'user',
        properties: { [propertyName]: propertyValue },
      });

      // Discrete, searchable event
      FS('trackEvent', {
        name: 'Experiment Viewed',
        properties: {
          flag_key: flagKey,
          variation_key: variationKey,
          rule_key: ruleKey,
          enabled: enabled,
        },
      });
    }
  );

  const user = optimizely.createUserContext(userId, userAttributes);
  const decision = user.decide('checkout_redesign');
  console.log('Variation:', decision.variationKey);
});

This is the simplest and most reliable path: FullStory resolves identity from its own session cookie, so the experiment data automatically attaches to the correct session and replay.

Node.js Implementation

On the server, the browser FS function does not exist. Use FullStory's Server API V2 over HTTP. There is no official FullStory server SDK for Node; call the REST endpoints directly with fetch.

import { createInstance, enums } from '@optimizely/optimizely-sdk';

const FS_API_KEY = process.env.FULLSTORY_API_KEY; // format: <data center>.<token>
const FS_BASE = 'https://api.fullstory.com';

async function sendToFullStory(uid, eventName, eventProperties, userProperties) {
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Basic ${FS_API_KEY}`,
  };

  // Upsert user properties — POST /v2/users
  if (userProperties && Object.keys(userProperties).length > 0) {
    const userRes = await fetch(`${FS_BASE}/v2/users`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ uid, properties: userProperties }),
    });
    if (!userRes.ok) {
      console.error(`FullStory user upsert failed: ${userRes.status}`, await userRes.text());
    }
  }

  // Create an event — POST /v2/events
  const eventRes = await fetch(`${FS_BASE}/v2/events`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      user: { uid },
      name: eventName,
      timestamp: new Date().toISOString(),
      properties: eventProperties,
    }),
  });
  if (!eventRes.ok) {
    console.error(`FullStory event failed: ${eventRes.status}`, await eventRes.text());
  }
}

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

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

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

      const propertyName = `[Optimizely] ${flagKey}`;
      const propertyValue = enabled ? variationKey : 'off';

      await sendToFullStory(
        userId,
        'Experiment Viewed',
        { flag_key: flagKey, variation_key: variationKey, rule_key: ruleKey, enabled },
        { [propertyName]: propertyValue }
      );
    }
  );
});

// In your request handler
async function handleRequest(userId, userAttributes) {
  const user = optimizely.createUserContext(userId, userAttributes);
  const decision = user.decide('checkout_redesign');
  // The DECISION listener fires and forwards data to FullStory
  return decision;
}

Provide Only One Form of Identification

FullStory's V2 events endpoint accepts exactly one identifier object — user, session, or anonymous. Supplying more than one returns HTTP 400. Server-side you have the Optimizely user ID but not a FullStory session ID, so send only user: { uid } (as the example above does). That uid must match the browser-side FS('setIdentity', { uid }).

Deduplication

If your application calls decide() on every request for the same user, the listener fires repeatedly and sends duplicate events. FullStory's Server API supports idempotency via the Idempotency-Key request header — set it to a stable value such as uid:flag_key:variation_key so retried or repeated decisions collapse to one event.

headers['Idempotency-Key'] = `${uid}:${eventProperties.flag_key}:${eventProperties.variation_key}`;

Alternatively, keep a per-request or per-session Set of processed decisions and skip the HTTP call when the combination was already sent.

Listener Registration Timing

The notification listener must be registered before any decide() call. The Optimizely SDK does not replay past decisions to newly registered listeners.

// CORRECT: register the listener before decide()
optimizely.onReady().then(() => {
  optimizely.notificationCenter.addNotificationListener(
    enums.NOTIFICATION_TYPES.DECISION,
    callback
  );
  const user = optimizely.createUserContext(userId, attributes);
  const decision = user.decide('flag_key'); // Listener fires
});

// WRONG: registering after decide() misses the decision
optimizely.onReady().then(() => {
  const user = optimizely.createUserContext(userId, attributes);
  const decision = user.decide('flag_key'); // Decision is lost
  optimizely.notificationCenter.addNotificationListener(
    enums.NOTIFICATION_TYPES.DECISION,
    callback // Too late
  );
});

Python Implementation

The Python SDK uses the same notification listener pattern. Use the requests library to call FullStory's Server API V2. Remember the DECISION callback receives four positional arguments (decision_type, user_id, attributes, decision_info), and decision_info keys are snake_case.

import os
import requests
from optimizely import optimizely
from optimizely.helpers import enums

FS_API_KEY = os.environ['FULLSTORY_API_KEY']  # format: <data center>.<token>
FS_BASE = 'https://api.fullstory.com'

HEADERS = {
    'Content-Type': 'application/json',
    'Authorization': f'Basic {FS_API_KEY}',
}


def send_to_fullstory(uid, event_name, event_properties, user_properties):
    # Upsert user properties — POST /v2/users
    if user_properties:
        user_res = requests.post(
            f'{FS_BASE}/v2/users',
            headers=HEADERS,
            json={'uid': uid, 'properties': user_properties},
            timeout=5,
        )
        if not user_res.ok:
            print(f'FullStory user upsert failed: {user_res.status_code} {user_res.text}')

    # Create an event — POST /v2/events (only one form of identification: user.uid)
    event_res = requests.post(
        f'{FS_BASE}/v2/events',
        headers=HEADERS,
        json={
            'user': {'uid': uid},
            'name': event_name,
            'properties': event_properties,
        },
        timeout=5,
    )
    if not event_res.ok:
        print(f'FullStory event failed: {event_res.status_code} {event_res.text}')


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

    flag_key = decision_info.get('flag_key')
    enabled = decision_info.get('enabled')
    variation_key = decision_info.get('variation_key')
    rule_key = decision_info.get('rule_key')

    property_name = f'[Optimizely] {flag_key}'
    property_value = variation_key if enabled else 'off'

    send_to_fullstory(
        user_id,
        'Experiment Viewed',
        {
            'flag_key': flag_key,
            'variation_key': variation_key,
            'rule_key': rule_key,
            'enabled': enabled,
        },
        {property_name: property_value},
    )


# Initialize Optimizely
optimizely_client = optimizely.Optimizely(sdk_key=os.environ['OPTIMIZELY_SDK_KEY'])

# Register the listener BEFORE any decide() calls
optimizely_client.notification_center.add_notification_listener(
    enums.NotificationTypes.DECISION, on_decision
)

# Make a decision — the listener fires automatically
user = optimizely_client.create_user_context('user_123', {'plan': 'pro'})
decision = user.decide('checkout_redesign')
print('Variation:', decision.variation_key)

The Server API call is fire-and-forget from Optimizely's perspective: a slow or failed FullStory request should never block your flag decision. Wrap the HTTP calls in a short timeout (as above) and, in production, move them off the request path into a queue or background worker so analytics latency never affects the user.

Analyzing Experiments in FullStory

Segments and OmniSearch

Build an OmniSearch query for users who fired Experiment Viewed where flag_key equals your flag AND variation_key equals a specific variation, then save it as a segment ("Checkout Redesign — treatment"). Repeat per variation and reuse the segments across replays, funnels, and metrics.

Funnels by Variation

Create a funnel in Metrics > Funnels, apply a variation segment or filter on the variation_key property, and compare step conversion between variations. Click any drop-off to watch the sessions where users in that variation abandoned.

Frustration Signals and Replay

Filter rage clicks, dead clicks, and error clicks by variation segment to catch friction a conversion metric hides — then watch a sample of replays per variation to understand the behavior behind the numbers. This qualitative layer is the main reason to pair FullStory with Feature Experimentation rather than a chart-only analytics tool.

Gotchas

Identity Matching Is Critical

The Server API attaches data by uid. If your server sends uid = "user_123" but the browser identified the FullStory user with a different value (or never called FS('setIdentity', …) at all), the server-side experiment data and the browser session belong to two different FullStory users and never join. Standardize on one uid across both surfaces before relying on cross-surface analysis.

Browser API vs Server API Are Not Interchangeable

FS('trackEvent', …) exists only in the browser; POST /v2/events exists only server-side. Use the browser API when the decision happens client-side and the Server API when it happens on your backend. Sending the same decision through both produces duplicate events.

Only One Identifier Per Server Request

The events endpoint accepts exactly one of three identifier objects — user, session, or anonymous. Including more than one returns HTTP 400. Server-side you have the Optimizely user ID but not a FullStory session ID, so send user: { uid } alone.

Set the Event Timestamp

If you omit timestamp, FullStory stamps the event with its server receive time. For decisions forwarded asynchronously (queued, retried, or batched), that can misorder events relative to the session. Set timestamp to the moment the decision occurred whenever you can.

decisionEventDispatched and Count Inflation

Forwarding every decision — including flags delivered without an experiment, where decisionEventDispatched is false — can inflate FullStory counts relative to Optimizely's experiment results. If you only want experiment impressions, gate the forward on decisionInfo['decisionEventDispatched'] === true.

Server API Rate and Payload Limits

The Server API enforces per-account rate limits and a maximum payload size per event. High-throughput services calling decide() on every request can exceed these limits; batch with the Create Events batch endpoint or sample/dedupe before sending.

Troubleshooting

Events Not Appearing in FullStory

  • Auth: Confirm the Authorization: Basic <key> header uses the full <data center>.<token> key. A 401 means the key is wrong or lacks permission.

  • 400 Bad Request: Usually more than one identifier on the event, a malformed properties value, or an event name over 250 characters.

  • Listener registered too late: Register the DECISION listener before the first decide() call.

  • Browser blocked: For the browser path, ad blockers block fullstory.com; the FS function never initializes and queued calls never flush.

Server Events Not Merging with Browser Session

The uid does not match. Verify the value passed to the Server API equals the uid used in the browser's FS('setIdentity', …). Until both agree, FullStory treats them as separate users.

Decision Listener Fires but Data Is Wrong

  • In Python, confirm you are reading snake_case keys (flag_key, variation_key) — camelCase keys return None.

  • Confirm you are filtering on type === 'flag'; the same listener also receives ab-test, feature-test, and other decision types depending on SDK and method used.

Data Discrepancies Between Platforms

  • Counting unit: Optimizely counts unique users; FullStory counts sessions and resolves identity through its own model.

  • Sampling: Depending on your FullStory plan, not every session is captured, lowering FullStory counts.

  • Non-experiment decisions: Forwarding decisions where decisionEventDispatched is false inflates FullStory relative to Optimizely.

  • Async failures: Dropped or timed-out Server API calls silently lower FullStory counts; log failures so you can quantify the gap.

Expect a 5–15% discrepancy for the same experiment, with a larger gap if your FullStory plan samples sessions.

Related guides