Integrate PostHog with Optimizely Web Experimentation

Loading...·11 min read

PostHog is an open-source product analytics platform that combines event capture, feature flags, session replay, and its own experimentation suite in a single tool. Integrating PostHog with Optimizely Web Experimentation attaches experiment and variation data to the events PostHog already captures, so you can break down funnels, retention curves, and trends by the Optimizely variation a visitor actually saw — without standing up a second analytics pipeline or rebuilding your reports.

This guide covers the integration as a custom analytics integration. Optimizely does not publish an official PostHog connector for Web Experimentation, so the bridge is a custom JSON integration that runs PostHog's browser SDK (posthog-js) inside Optimizely's track_layer_decision callback. The callback sends a discrete Experiment Viewed event and sets a persistent person property keyed by experiment, giving you experiment context on every downstream PostHog event for that user. PostHog has its own A/B testing and feature flag product; this article is specifically about using PostHog as the analytics destination for experiments you run in Optimizely, not about running experiments in PostHog.

How the Integration Works

When Optimizely buckets a visitor into an experiment or personalization campaign, it fires a decision callback. The custom integration captures this callback and sends data to PostHog in two forms: a discrete posthog.capture('Experiment Viewed', …) event carrying campaign metadata, and a person property set via posthog.setPersonProperties() so the experiment context follows the identified user across future sessions. PostHog then indexes this data, making it possible to filter insights, funnels, and trends by experiment name and variation.

flowchart LR
    A[Visitor lands on page] --> B[Optimizely makes bucketing decision]
    B --> C[Decision callback fires]
    C --> D["posthog.setPersonProperties() sets experiment context"]
    C --> E["posthog.capture('Experiment Viewed')"]
    D --> F[Person property attaches to the profile]
    E --> G[PostHog event stream]
    F --> H[PostHog: insights, cohorts, funnels]
    G --> H
    H --> I[Break down funnels and trends by variation]

Because the person property is keyed by experiment, multiple concurrent experiments write to different keys and never collide. The discrete Experiment Viewed event gives you an explicit entry point you can start funnels from, while the person property gives you a durable dimension for cohorts and breakdowns.

Custom Integration Event Properties

The following properties are sent with the Experiment Viewed event in the custom integration:

Property

Type

Description

campaign_id

string

The campaign (layer) ID in Optimizely

experiment_id

string

The experiment ID within the campaign

experiment_name

string

Human-readable experiment name

variation_id

string

The assigned variation ID

variation_name

string

Human-readable variation name or "holdback"

is_holdback

boolean

Whether the visitor is in the holdback group

The experiment_name/variation_name pair is also set as a person property (keyed [Optimizely] ExperimentName) via setPersonProperties(), so it is available for cohorts and breakdowns and persists on the user across sessions.

Prerequisites

Before configuring the integration, confirm the following:

  • PostHog JavaScript snippet (posthog-js) is installed and initialized on your site with a valid project API key and host. PostHog Cloud uses https://us.i.posthog.com (US) or https://eu.i.posthog.com (EU); self-hosted instances use your own host.

  • Optimizely Web Experimentation snippet is deployed on the same pages.

  • Mask descriptive names is disabled (Optimizely Settings > Privacy), otherwise PostHog receives numeric IDs instead of readable experiment and variation names.

  • You have access to the PostHog application to verify incoming events under Activity and Insights.

  • You have admin access to your Optimizely project (Settings > Integrations) to create the custom JSON integration.

Load Order

The track_layer_decision callback fires immediately when Optimizely makes a bucketing decision, which can happen before posthog-js has finished initializing. The integration uses window['optimizely'].get('utils').waitUntil() to defer the PostHog calls until window.posthog is ready, so even if PostHog loads after the Optimizely snippet, the decision is captured once PostHog initializes. PostHog's snippet also defines a method-queue stub that buffers early calls, but waitUntil guards against referencing an undefined global if the script is blocked or delayed entirely.

A typical <head> configuration looks like this:

<head>
  <!-- 1. PostHog snippet (loads first).
       Copy the full snippet from PostHog > Settings > Project, which
       defines the window.posthog method-queue stub and calls posthog.init. -->
  <script>
    !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){
      // ... remainder of PostHog's official loader snippet (the method-queue
      //     stub) follows here — paste it verbatim from your project settings ...
    })}(document,window.posthog||[]);
    posthog.init('YOUR_PROJECT_API_KEY', { api_host: 'https://us.i.posthog.com' });
  </script>

  <!-- 2. Optimizely Web snippet (loads second) -->
  <script src="https://cdn.optimizely.com/js/YOUR_PROJECT_ID.js"></script>
</head>

Copy the full snippet — including the loader stub shown above only in outline — from PostHog > Settings > Project; it already contains your project API key and host. Do not reconstruct the snippet by hand. Replace YOUR_PROJECT_API_KEY with your project API key and set api_host to match your PostHog region (us.i.posthog.com, eu.i.posthog.com, or your self-hosted host).

Method 1: PostHog's Built-in Optimizely Support

Unlike Heap, PostHog's browser SDK does not auto-read Optimizely's state object, and unlike FullStory, Optimizely publishes no official PostHog connector for Web Experimentation. There is no zero-code path: PostHog's first-party Optimizely material covers its own feature-flag and experiment product, not ingesting decisions from Optimizely Web Experimentation. As a result, the custom JSON integration in Method 2 is the supported way to forward Optimizely Web decisions to PostHog. If you only need experiment context attached to events and are willing to write the property yourself, you can also call posthog.register() once per page after reading Optimizely state — but the JSON integration is the cleaner, decision-driven approach and is what the rest of this guide uses.

Method 2: Custom JSON Integration

The custom JSON integration gives you full control over how experiment data is structured in PostHog. It uses Optimizely's Custom Analytics Integration (JSON plugin) with the track_layer_decision callback to call the PostHog SDK directly at decision time.

Creating the JSON Integration

  1. In your Optimizely project, go to Settings > Integrations.

  2. Click Create Analytics Integration > Using JSON.

  3. Paste the following configuration:

{
  "plugin_type": "analytics_integration",
  "name": "PostHog (Custom)",
  "form_schema": [],
  "description": "Sends Optimizely experiment decisions to PostHog as a person property and a discrete Experiment Viewed event",
  "options": {
    "track_layer_decision": "var state = window['optimizely'].get('state');\nvar expName = String(campaignId);\nvar varName = String(variationId);\n\nif (state) {\n  try {\n    var decisionObj = state.getDecisionObject({ campaignId: campaignId });\n    if (decisionObj) {\n      expName = decisionObj.experiment || expName;\n      varName = decisionObj.variation || varName;\n    }\n  } catch (e) {}\n}\n\nvar variationValue = isHoldback ? 'holdback' : varName;\nvar propertyKey = '[Optimizely] ' + expName;\n\nvar utils = window['optimizely'].get('utils');\nutils.waitUntil(function() {\n  return typeof window.posthog !== 'undefined' && typeof window.posthog.capture === 'function';\n}).then(function() {\n  var personProps = {};\n  personProps[propertyKey] = variationValue;\n  window.posthog.setPersonProperties(personProps);\n\n  window.posthog.capture('Experiment Viewed', {\n    campaign_id: String(campaignId),\n    experiment_id: String(experimentId),\n    experiment_name: expName,\n    variation_id: String(variationId),\n    variation_name: variationValue,\n    is_holdback: isHoldback\n  });\n});\n"
  }
}
  1. Click Save Integration.

  2. Toggle the integration to Enabled in Settings > Integrations.

  3. Optionally check Enable for all new experiments.

  4. For existing experiments, open each experiment's Manage Experiments > Integrations tab and enable the "PostHog (Custom)" integration.

What the Callback Does

The options.track_layer_decision callback fires each time Optimizely makes a bucketing decision. Here is a breakdown of the logic:

Retrieving human-readable names: state.getDecisionObject({ campaignId }) looks up experiment and variation names from Optimizely's state API. If Mask descriptive names is enabled, the lookup returns numeric IDs and the callback falls back to them.

var state = window.optimizely.get('state');
var expName = String(campaignId);
var varName = String(variationId);
if (state) {
  try {
    var decisionObj = state.getDecisionObject({ campaignId: campaignId });
    if (decisionObj) {
      expName = decisionObj.experiment || expName;
      varName = decisionObj.variation || varName;
    }
  } catch (e) {}
}

Waiting for PostHog to be ready: optimizely.get('utils').waitUntil() defers execution until window.posthog.capture is a function. This handles the common case where PostHog loads asynchronously after Optimizely — the decision data is held until PostHog initializes, then sent.

var utils = window['optimizely'].get('utils');
utils.waitUntil(function() {
  return typeof window.posthog !== 'undefined' && typeof window.posthog.capture === 'function';
}).then(function() {
  // PostHog API calls run here once PostHog is ready
});

Setting the person property: posthog.setPersonProperties() attaches the experiment context to the identified person profile. The property is keyed [Optimizely] ExperimentName with the variation name (or "holdback") as the value. Because each experiment writes to its own key, concurrent experiments do not overwrite each other.

var personProps = {};
personProps['[Optimizely] ' + expName] = variationValue;
window.posthog.setPersonProperties(personProps);

setPersonProperties(propertiesToSet, propertiesToSetOnce) is the current posthog-js method for updating person properties (it supersedes the older posthog.people.set()). If you want the experiment context attached to every event as an event property rather than only to the person, use posthog.register() instead — it adds super properties to all subsequent events in the session. This guide uses the person property because it survives across sessions and keeps the event schema clean.

Capturing the event: posthog.capture('Experiment Viewed', { … }) records a discrete event with full campaign metadata. This event is what PostHog's insights and funnels index, enabling funnels that start at experiment entry and trends filtered by experiment.

window.posthog.capture('Experiment Viewed', {
  campaign_id: String(campaignId),
  experiment_id: String(experimentId),
  experiment_name: expName,
  variation_id: String(variationId),
  variation_name: variationValue,
  is_holdback: isHoldback
});

Verifying the Integration

After enabling the integration, verify that data reaches PostHog before treating results as reliable.

Console Verification

Open your browser's developer console on a page with an active experiment:

// Check if PostHog is loaded
console.log('PostHog loaded:', typeof window.posthog !== 'undefined' && typeof window.posthog.capture === 'function');

// Check Optimizely experiment state
var state = window.optimizely && window.optimizely.get('state');
if (state) {
  var campaigns = state.getCampaignStates({ isActive: true });
  for (var id in campaigns) {
    var c = campaigns[id];
    console.log('Campaign ' + id + ':', {
      experimentId: c.experiment && c.experiment.id,
      variationId: c.variation && c.variation.id,
      isHoldback: c.isInCampaignHoldback
    });
  }
}

// Manually fire a test event to confirm the SDK is wired up
window.posthog.capture('Integration Test', { source: 'console' });

PostHog Activity View

  1. In the PostHog application, open the Activity tab (the live event stream).

  2. Browse a page with an active experiment in a separate tab to generate events.

  3. Look for an Experiment Viewed event and expand it.

  4. Confirm its properties include experiment_name, variation_name, and campaign_id, and that the values match the variation you were assigned.

PostHog Person Profile

To verify the person property is set:

  1. Go to People > Persons in PostHog.

  2. Find the person who visited a page with an active experiment during the verification session (search by distinct ID if you identified the user).

  3. Open their profile and look for a property named [Optimizely] Your Experiment Name.

  4. Confirm the value matches your assigned variation.

Analyzing Experiments in PostHog

Cohorts

PostHog cohorts let you define reusable groups of users based on properties or behavior. Create one cohort per variation:

  1. Go to People > Cohorts > New cohort.

  2. Add a condition: persons whose property [Optimizely] Your Experiment Name equals "Variation A" — or who performed Experiment Viewed where experiment_name equals your experiment AND variation_name equals "Variation A".

  3. Save it as "Checkout Redesign — Variation A".

  4. Repeat for the control.

Apply these cohorts across insights, funnels, and retention to compare behavior between groups without rebuilding filters each time.

Funnels

Funnels broken down by variation are the most direct way to measure experiment impact on a conversion sequence:

  1. Go to Product analytics > New insight > Funnel.

  2. Define your funnel steps (for example, Pageview → Add to Cart → Checkout → Purchase).

  3. Use the Breakdown control and break down by the person property [Optimizely] Your Experiment Name, or filter to a variation cohort.

  4. Compare step-by-step conversion rates between control and treatment.

Because the variation is stored as a person property, the breakdown flows through every funnel step without extra tracking on downstream pages.

Trends

Use a Trends insight to track any event (such as Purchase or a key engagement event) broken down by [Optimizely] Your Experiment Name. This shows the variation's effect over time and is useful for spotting novelty effects that fade after the first few days.

Retention

Retention analysis measures whether a variation produces a lasting change in engagement:

  1. Go to Product analytics > New insight > Retention.

  2. Set the start event to Experiment Viewed filtered by your experiment name.

  3. Set the returning event to your key engagement metric.

  4. Break the cohort down by variation_name (or apply your variation cohorts) and compare retention curves between control and treatment.

Gotchas

No Official PostHog Connector — Use the Custom Integration

Optimizely does not publish an official PostHog analytics integration for Web Experimentation, and posthog-js does not auto-detect Optimizely the way Heap does. The custom JSON integration in Method 2 is the supported path. Do not expect a zero-code toggle; if you find PostHog documentation referencing Optimizely, confirm it is about PostHog's own experiment product before relying on it.

Anonymous vs Identified Persons

setPersonProperties() applies to PostHog's current person — anonymous until you call posthog.identify(). If the visitor is anonymous when the decision fires, the property attaches to the anonymous person; once you identify the user, PostHog merges the anonymous and identified profiles, but only if identification happens in the same browser. If you never identify users, experiment data lives on anonymous persons and will not join to a known user across devices.

16-Digit IDs Can Be Misread

Optimizely's experiment and variation IDs are 16-digit numbers, which some downstream tooling flags as credit card numbers and strips. The callback sends IDs as strings via String(...) and always pairs them with human-readable names, which avoids the problem. Keep the names in your properties; do not send bare 16-digit numeric values.

Mask Descriptive Names Hides Readable Values

If Mask descriptive names is enabled in Optimizely's privacy settings, PostHog receives numeric IDs instead of experiment and variation names, making breakdowns hard to read. Disable it — but note that doing so exposes your variation names in client-side source code.

setPersonProperties vs register

setPersonProperties() writes to the person profile (durable, cross-session). register() writes super properties that attach to every event for the rest of the session only. They are not interchangeable: choose person properties for cohorts and long-lived breakdowns, super properties if you specifically need the variation stamped onto every event. Do not set both with the same key, or you will have two copies of the variation in different scopes.

Preview Mode Sends Real Data

When using Optimizely's Preview Mode to QA an experiment, the track_layer_decision callback fires normally and Experiment Viewed events reach your production PostHog project. Use a separate PostHog project for QA, or filter test sessions out of your insights, so QA traffic does not pollute results.

Troubleshooting

No Events Appear in PostHog

  • Integration not enabled: Confirm the integration is toggled on in Settings > Integrations and enabled for the specific experiment under Manage Experiments > Integrations.

  • PostHog not initialized: Verify window.posthog exists and window.posthog.capture is a function in the console. If PostHog fails to load, waitUntil waits indefinitely and nothing is sent.

  • Ad blockers: Privacy extensions frequently block PostHog's ingestion domains. Check the network tab for blocked requests to your api_host. Configuring a reverse proxy for PostHog mitigates this.

  • Visitor not bucketed: The callback fires only on an active bucketing decision. If the visitor does not meet the experiment's audience conditions, no decision fires and no event is sent.

Events Appear but Names Are Numeric

Mask descriptive names is enabled. Disable it in Optimizely Settings > Privacy, then generate a fresh session — existing events are not retroactively renamed.

Person Property Not Showing on the Profile

  • setPersonProperties() applies to the current person. If the visitor is anonymous and never identified, the property attaches to the anonymous person record for that session and will not merge with a known user profile until posthog.identify() is called.

  • Person profiles update asynchronously; allow a short delay and refresh the Persons view.

  • Confirm person_profiles is not set to 'identified_only' in your posthog.init() config if you expect anonymous persons to carry properties — in that mode, anonymous events do not create person profiles.

Data Discrepancies Between Platforms

Differences between Optimizely visitor counts and PostHog counts are expected:

  • Counting unit: Optimizely counts unique cookie-based visitors; PostHog counts events and resolves identity through its own anonymous-then-identified model.

  • Ad blockers: These may block PostHog or Optimizely independently, skewing counts in either direction. A PostHog reverse proxy reduces blocked ingestion.

  • SPA navigation: In single-page apps, Optimizely may fire multiple decisions on client-side navigation while PostHog captures pageviews differently. Keep your SPA tracking model consistent.

  • Preview and QA sessions: Optimizely Preview Mode fires the decision callback, so QA traffic appears in production PostHog unless filtered.

Expect a 5–15% discrepancy between Optimizely visitor counts and PostHog counts for the same experiment. Investigate further if the gap exceeds 20%.

Related guides