Integrate PostHog with Optimizely Feature Experimentation
TL;DR
PostHog is an open-source product analytics platform that pairs event capture and person profiles with feature flags, session replay, and its own experimentation suite. Optimizely Feature Experimentation makes flag and variation decisions that can run in the browser, on your server, or both. Bridging the two means taking each flag decision the SDK reports and forwarding it to PostHog as an event and a person property, so you can break down funnels, trends, and retention by the variation a user actually received. PostHog has its own A/B testing built on feature flags; this article is about using PostHog as the analytics destination for experiments you run in Optimizely, not about running experiments in PostHog.
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 PostHog SDK you call. In the browser you call posthog-js directly (posthog.capture, posthog.setPersonProperties). On the server you call posthog-node (or the Python posthog library), where you must supply a distinctId explicitly — and identity matching becomes the critical detail, because the server has no access to PostHog's browser session.
How the Integration Works
The decision notification listener receives the decision type, the user ID, the user attributes, and a decisionInfo object with the flag key, enabled state, variation key, and rule key for every decision. The integration forwards this to PostHog as a person property (so the profile inherits the experiment context) and an Experiment Viewed event (so it is searchable and chartable).
flowchart LR
A["user.decide('flag')"] --> B[DECISION notification fires]
B --> C{Where does the listener run?}
C -->|Browser| D["posthog.setPersonProperties() + posthog.capture()"]
C -->|Server| E["client.capture distinctId + $set"]
D --> F[PostHog person + event stream]
E --> F
F --> G[Cohorts, funnels, trends, retention by variation]
Decision Notification Data
The DECISION listener's decisionInfo object contains the following fields for a flag-type decision. Browser and Node SDKs expose camelCase keys; the Python SDK exposes snake_case keys.
Field (camelCase / snake_case) | 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: an A/B test or experiment sets it to true, while a targeted delivery (rollout) returns false because no impression event is dispatched. Forwarding decisions where it is false can inflate your PostHog counts relative to Optimizely's experiment results.
Person Property Format
The integration sets a single person 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).
PostHog project API key and the correct host for your region — PostHog Cloud US is
https://us.i.posthog.com, EU ishttps://eu.i.posthog.com, and self-hosted instances use your own host.posthog-json the page for the browser path,posthog-nodeinstalled for Node.js, or theposthogPython library installed for Python.Consistent user identity — the
distinctIdyou send from the server must match the distinct ID PostHog uses for that user in the browser (set viaposthog.identify(distinctId)). Without a matching distinct ID, server-side experiment data lands on a different PostHog person than the browser session, breaking cross-surface analysis.
JavaScript / Browser Implementation
When the Optimizely FX SDK runs in the browser, call posthog-js directly. PostHog is already initialized on the page, so no API keys or HTTP calls are required in the listener.
import { createInstance, enums } from '@optimizely/optimizely-sdk';
const optimizely = createInstance({
sdkKey: '<YOUR_SDK_KEY>',
});
optimizely.onReady().then(() => {
// Register the listener BEFORE any decide() call
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';
// Person property: inherited by the whole profile
window.posthog.setPersonProperties({ [propertyName]: propertyValue });
// Discrete, searchable event
window.posthog.capture('Experiment Viewed', {
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: PostHog resolves identity from its own browser state, so the experiment data automatically attaches to the correct person and any active session replay. setPersonProperties(propertiesToSet, propertiesToSetOnce) is the current posthog-js method (it supersedes posthog.people.set()); use posthog.register() instead if you want the variation stamped onto every event rather than the person profile.
Node.js Implementation
On the server, the browser posthog global does not exist. Use the posthog-node library, which sends events to PostHog's ingestion API over HTTPS.
import { createInstance, enums } from '@optimizely/optimizely-sdk';
import { PostHog } from 'posthog-node';
const posthog = new PostHog(process.env.POSTHOG_API_KEY, {
host: process.env.POSTHOG_HOST || 'https://us.i.posthog.com',
});
const optimizely = createInstance({
sdkKey: process.env.OPTIMIZELY_SDK_KEY,
});
optimizely.onReady().then(() => {
// Register the listener BEFORE any decide() call
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';
// Single capture call carries both the event and the person property.
// $set updates the person profile; the rest are event properties.
posthog.capture({
distinctId: userId,
event: 'Experiment Viewed',
properties: {
flag_key: flagKey,
variation_key: variationKey,
rule_key: ruleKey,
enabled: enabled,
$set: { [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 PostHog
return decision;
}
// On graceful shutdown, flush queued events
process.on('SIGTERM', async () => {
await posthog.shutdown();
});
In posthog-node, person properties are set through the $set (and $set_once) keys inside a capture call's properties, not a separate method, so one capture both records the event and updates the profile. The distinctId is mandatory server-side and must match the browser identity.
Flush Events Before the Process Exits
posthog-node buffers events and flushes them in batches for efficiency. In a long-running server, call await posthog.shutdown() on graceful shutdown (as above) so queued events are not lost. In serverless environments (AWS Lambda, Vercel Functions), the process can be frozen between invocations, so configure the client with flushAt: 1 and flushInterval: 0 and await posthog.shutdown() at the end of each invocation — or use await posthog.captureImmediate({ … }), which sends the event before returning instead of queuing it.
// Serverless configuration
const posthog = new PostHog(process.env.POSTHOG_API_KEY, {
host: process.env.POSTHOG_HOST || 'https://us.i.posthog.com',
flushAt: 1,
flushInterval: 0,
});
// ... after handling the request:
await posthog.shutdown();
Deduplication
If your application calls decide() on every request for the same user, the listener fires repeatedly and sends duplicate Experiment Viewed events. Keep a per-request or per-session Set of processed userId:flagKey:variationKey combinations and skip the capture call when the combination was already sent. Alternatively, only forward the event when decisionInfo.decisionEventDispatched === true so rollouts (which fire on every request) are excluded.
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 posthog Python library to send events. Remember the DECISION callback receives four positional arguments (decision_type, user_id, attributes, decision_info), and decision_info keys are snake_case.
import os
from posthog import Posthog
from optimizely import optimizely
from optimizely.helpers import enums
posthog = Posthog(
os.environ['POSTHOG_API_KEY'],
host=os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com'),
)
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'
# Event name is the first positional argument; distinct_id and properties
# are keyword arguments. $set updates the person profile.
posthog.capture(
'Experiment Viewed',
distinct_id=user_id,
properties={
'flag_key': flag_key,
'variation_key': variation_key,
'rule_key': rule_key,
'enabled': enabled,
'$set': {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)
# Flush queued events before the process exits
posthog.shutdown()
In the current PostHog Python SDK (v3+), capture takes the event name as the first positional argument with distinct_id and properties as keyword arguments — posthog.capture('Experiment Viewed', distinct_id=user_id, properties={...}). The older positional form capture(distinct_id, event, properties) is from earlier versions; use the keyword form shown above. Person properties are set through the $set key inside properties, the same as the Node library.
The PostHog call is fire-and-forget from Optimizely's perspective: a slow or failed PostHog request should never block your flag decision. The library buffers and sends events on a background thread, so call posthog.shutdown() before the process exits to flush the queue. In serverless or short-lived processes, either call posthog.shutdown() at the end of each invocation or initialize the client with sync_mode=True so each capture sends synchronously.
Analyzing Experiments in PostHog
Cohorts
Build a cohort per variation: persons whose property [Optimizely] Your Flag equals a specific variation, or who performed Experiment Viewed where flag_key equals your flag AND variation_key equals that variation. Save each ("Checkout Redesign — treatment") and reuse them across funnels, trends, and retention.
Funnels by Variation
Create a funnel in Product analytics > New insight > Funnel, then use Breakdown on the person property [Optimizely] Your Flag (or filter to a variation cohort) and compare step conversion between variations. Because the variation is stored as a person property, it flows through every funnel step without extra instrumentation.
Trends and Retention
Use a Trends insight to track a key event broken down by variation_key to watch each variation's effect over time. For Retention, set the start event to Experiment Viewed filtered by flag, set the returning event to your engagement metric, and break down by variation to compare retention curves between control and treatment.
Gotchas
Identity Matching Is Critical
PostHog attaches data by distinctId. If your server sends distinctId = "user_123" but the browser identified the PostHog person with a different value (or never called posthog.identify(...) at all), the server-side experiment data and the browser session belong to two different PostHog persons and never join. Standardize on one distinct ID across both surfaces before relying on cross-surface analysis.
Browser SDK vs Server SDK Are Not Interchangeable
posthog.setPersonProperties() and the implicit browser identity exist only in posthog-js; posthog-node and the Python library require an explicit distinctId and set person properties through $set. Use the browser SDK when the decision happens client-side and the server SDK when it happens on your backend. Sending the same decision through both produces duplicate events.
decisionEventDispatched and Count Inflation
A targeted delivery (rollout) returns decisionEventDispatched: false and fires on every evaluation. Forwarding those decisions inflates PostHog counts relative to Optimizely's experiment results. If you only want experiment impressions, gate the forward on decisionInfo.decisionEventDispatched === true (or the snake_case key in Python).
Always Flush Before the Process Exits
posthog-node and the Python library buffer events and send them in batches. A server that exits — or a serverless function that freezes — before the buffer flushes silently drops events. Call await posthog.shutdown() (Node) or posthog.shutdown() (Python) on shutdown, and in serverless use flushAt: 1/flushInterval: 0 (Node) or sync_mode=True (Python).
Anonymous Persons and person_profiles
If your posthog-js init sets person_profiles: 'identified_only', anonymous browser events do not create person profiles, so a browser-side setPersonProperties() before identification has no durable target. Server-side captures always create a person for the supplied distinctId. Be consistent about when you identify users so browser and server data land on the same profile.
Rate Limits and Batching
PostHog Cloud enforces per-project ingestion rate limits. A high-throughput service calling decide() on every request can approach them; dedupe decisions (see above) or rely on the libraries' built-in batching rather than forcing an immediate send on every event.
Troubleshooting
Events Not Appearing in PostHog
Wrong host or key: Confirm the
hostmatches your region (us.i.posthog.comvseu.i.posthog.com) and the project API key is correct. A wrong host silently drops events.Buffer never flushed: In server or serverless code, confirm
shutdown()(orcaptureImmediate/sync_mode) runs before the process exits.Listener registered too late: Register the DECISION listener before the first
decide()call.Browser blocked: For the browser path, ad blockers block PostHog ingestion; a reverse proxy mitigates this.
Server Events Not Merging with Browser Session
The distinctId does not match. Verify the value passed to the server SDK equals the distinct ID used in the browser's posthog.identify(...). Until both agree, PostHog treats them as separate persons.
Decision Listener Fires but Data Is Wrong
In Python, confirm you are reading snake_case keys (
flag_key,variation_key) — camelCase keys returnNone.Confirm the Python
capturecall uses the keyword formcapture('Experiment Viewed', distinct_id=..., properties=...); passing the event name where the SDK expectsdistinct_idmislabels every event.Confirm you are filtering on
type === 'flag'; the same listener can receive other decision types depending on the SDK and method used.
Data Discrepancies Between Platforms
Counting unit: Optimizely counts unique users; PostHog counts events and resolves identity through its own model.
Non-experiment decisions: Forwarding decisions where
decisionEventDispatchedisfalseinflates PostHog relative to Optimizely.Dropped async events: Buffered events lost to an early exit or timeout lower PostHog counts; flush on shutdown and log failures so you can quantify the gap.
Ad blockers: Browser-path events are blocked more often than server-path events, skewing the split between the two.
Expect a 5–15% discrepancy for the same experiment, with a larger gap if browser-path events are heavily ad-blocked.