Integrate FullStory with Optimizely Feature Experimentation
TL;DR
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 |
|---|---|---|
| string | The flag key that was evaluated |
| boolean | Whether the flag is enabled for this user |
| string | The delivered variation key |
| string | The experiment or delivery rule that matched |
| 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
fetchbuilt in; Python uses therequestslibrary.Consistent user identity — the
uidyou send to FullStory's Server API must match the identity FullStory uses for that user in the browser (set viaFS('setIdentity', { uid })). Without a matchinguid, 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
propertiesvalue, or an eventnameover 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; theFSfunction 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 returnNone.Confirm you are filtering on
type === 'flag'; the same listener also receivesab-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
decisionEventDispatchedisfalseinflates 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.