Integrate Google Tag Manager with Optimizely Web Experimentation

Loading...·15 min read

Google Tag Manager (GTM) is not an analytics destination — it is a tag deployment and data-routing layer that sits between your site and the tools that actually store data, most commonly Google Analytics 4 (GA4). Integrating Optimizely Web Experimentation with GTM means pushing each experiment decision into the browser's dataLayer, where a GTM trigger picks it up and a GA4 (or other destination) tag forwards it. The payoff is that you can segment GA4 reports, explorations, and audiences by the variation a visitor actually saw — without hand-editing tracking code on every page — and reuse the same dataLayer signal to fan the decision out to any other tag GTM manages.

This guide covers two integration methods. The first is Optimizely's officially documented "GA4 with Google Tag Manager" path, a built-in integration you enable in Optimizely settings that pushes an experience_impression event to the dataLayer for GTM to capture. The second is a Custom Analytics Integration (a JSON plugin) whose track_layer_decision callback performs an explicit window.dataLayer.push(...) with a payload you control, giving you custom event and property names, holdback handling, and the freedom to route the decision to destinations beyond GA4. A recurring theme throughout: GTM moves data; it does not report on it. Every claim about "analysis" in this article ultimately happens in GA4 or whatever destination your GTM container forwards to.

How the Integration Works

When Optimizely buckets a visitor into an experiment or personalization campaign, it fires a decision. Both integration methods turn that decision into a dataLayer.push(). GTM listens to the dataLayer: a Custom Event trigger matches on the pushed event value, Data Layer Variables read the experiment and variation fields out of the same push, and a GA4 Event tag (bound to that trigger) sends an event carrying those values to your GA4 property. From GA4, the variation becomes a dimension you can filter and compare.

flowchart LR
    A[Visitor lands on page] --> B[Optimizely makes bucketing decision]
    B --> C["dataLayer.push({ event, experiment, variation })"]
    C --> D[GTM Custom Event trigger matches event name]
    D --> E[Data Layer Variables read experiment/variation]
    E --> F[GA4 Event tag fires]
    F --> G[GA4 property: event + parameters]
    G --> H[Reports, Explorations, Audiences by variation]
    C --> I[Other GTM tags: Ads, Floodlight, Pinterest, ...]

The architectural point is that GTM is a router. The dataLayer.push() is the contract; everything downstream (GA4, Google Ads, a server container) is a consumer of the same signal. This is why a custom integration that pushes a clean, well-named payload to the dataLayer is more flexible than one wired directly to a single analytics SDK — any number of GTM tags can subscribe to one decision.

Custom Integration dataLayer Payload

Method 2's track_layer_decision callback pushes the following object to window.dataLayer:

Key

Type

Description

event

string

The Custom Event name GTM triggers on (optimizely_decision)

optimizely_experiment

string

Human-readable experiment name (or campaign ID fallback)

optimizely_variation

string

Human-readable variation name, or "holdback"

optimizely_campaign_id

string

The campaign (layer) ID in Optimizely

optimizely_experiment_id

string

The experiment ID within the campaign

optimizely_variation_id

string

The assigned variation ID

optimizely_is_holdback

boolean

Whether the visitor is in the holdback group

GTM Data Layer Variables read these keys by name. Because GA4 event parameter names allow only alphanumerics and underscores (and are capped at 40 characters), the payload uses underscore-delimited keys rather than the hyphenated keys Optimizely's documented integration emits — see the Gotchas.

Prerequisites

Before configuring either integration method, confirm the following:

  • A GTM container is created and its container snippet (the <script> plus <noscript> pair) is installed on your site. See Google's setup instructions.

  • A GA4 property and Web data stream exist, with a Measurement ID (format G-XXXXXXXXXX). This is the destination GTM forwards to.

  • A GA4 Configuration / Google tag is set up in the container so GA4 receives a base configuration before event tags fire.

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

  • You are a Project Owner (or above) in Optimizely Web Experimentation to enable the built-in integration (Method 1).

  • Mask descriptive names is disabled (Optimizely Settings > Privacy) if you want readable experiment and variation names instead of numeric IDs in GA4.

Load Order

Optimizely's documentation is explicit about this: place the GTM container <script>as close to the Web Experimentation snippet as possible in your <head>, and place the GTM <noscript> immediately after the opening <body> tag. Co-locating the two scripts so they execute at roughly the same time is what prevents data discrepancies — if GTM initializes long after Optimizely has already made and pushed a decision, the dataLayer push can be missed.

A typical <head> configuration looks like this:

<head>
  <!-- 1. Google Tag Manager container snippet.
       Copy the complete snippet from GTM > Admin > Install Google Tag Manager.
       It hardcodes your GTM-XXXXXXX container ID and initializes window.dataLayer. -->
  <script>
    (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
    new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
    j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
    'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
    })(window,document,'script','dataLayer','GTM-XXXXXXX');
  </script>

  <!-- 2. Optimizely Web snippet (placed as close to the GTM script as possible) -->
  <script src="https://cdn.optimizely.com/js/YOUR_PROJECT_ID.js"></script>
</head>
<body>
  <!-- Google Tag Manager (noscript) — immediately after the opening <body> tag -->
  <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
    height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
  <!-- ... -->
</body>

Copy the full container snippet from GTM > Admin > Install Google Tag Manager — do not reconstruct it by hand, as it embeds your container ID. The only thing the integration depends on is that window.dataLayer exists (the GTM snippet creates it) before a decision is pushed; the GTM snippet's own array shim queues pushes made before gtm.js finishes loading.

Choosing an Integration Method

Optimizely's Documented GA4-via-GTM Integration

Custom JSON Integration

Setup

Toggle in Optimizely Settings > Integrations

Paste a JSON plugin, build GTM trigger/variables/tag

dataLayer event

experience_impression

optimizely_decision (customizable)

Fields pushed

exp_variant_string, Holdback

Separate experiment, variation, IDs, holdback

Destination

GA4 (the integration is GA4-specific)

Any GTM tag (GA4, Ads, server container, ...)

Holdback handling

Holdback flag only

Explicit "holdback" value + boolean

Control over naming

Fixed

Full

GTM-side work

Variables + trigger + GA4 tag

Variables + trigger + GA4 tag

Both methods require you to build the GTM-side trigger, Data Layer Variables, and GA4 tag yourself — enabling the Optimizely toggle only handles the dataLayer push. Use Method 1 when GA4 is your only destination and the fixed field format is acceptable. Use Method 2 when you want clean, separate experiment/variation fields, holdback tracking, or routing to destinations other than GA4.

Method 1: Optimizely's Documented GA4-via-GTM Integration

This is the integration exactly as published in Optimizely's developer documentation. Enabling it makes Optimizely push an experience_impression event to the dataLayer; you then build the GTM container objects to consume it.

Enable the integration in Optimizely

  1. In Optimizely, go to Settings > Integrations.

  2. Click Google Analytics 4 - Report Generation.

  3. Toggle the integration on and click Accept.

  4. (Optional) Select Enable the Google Analytics 4 integration by default for all new experiments.

  5. Select Enable integrating using GTM by default for existing and new experiments. This is the checkbox that routes the data through GTM (the dataLayer push) rather than the direct GA4 path.

  6. Click Save.

  7. For experiments not covered by the "by default" options, open each experiment's Integrations tab and select the Tracked checkbox, then Save.

With this enabled, Optimizely pushes the following to the dataLayer when a visitor is bucketed:

window.dataLayer.push({
  event: 'experience_impression',
  exp_variant_string: '...',  // experiment/variation identifier string
  Holdback: false             // case-sensitive key
});

Create the Data Layer Variables in GTM

In your GTM workspace, go to Variables > User-Defined Variables > New and create two variables:

  1. Name exp_variant_string — Variable Type Data Layer Variable, Data Layer Variable Name exp_variant_string, Data Layer Version Version 2.

  2. Name Holdback — Variable Type Data Layer Variable, Data Layer Variable Name Holdback (case-sensitive), Data Layer Version Version 2.

Create the Custom Event trigger

Go to Triggers > New, choose Trigger Type Custom Event, and set Event name to experience_impression. This fires whenever Optimizely pushes the impression event.

Create the GA4 Event tag

Go to Tags > New, choose Tag Type Google Analytics: GA4 Event, select your GA4 Configuration/Google tag, and set:

  • Event Name: experience_impression

  • Event Parameters:

    • exp_variant_string → value: the exp_variant_string variable

    • Holdback → value: the Holdback variable

  • Triggering: the experience_impression Custom Event trigger.

Save, then Submit > Publish the container. Per Optimizely's documentation, data starts syncing immediately and you should see the experience_impression event in GA4's Realtime report within 30 minutes of events firing.

Method 2: Custom JSON Integration

The custom JSON integration uses Optimizely's Custom Analytics Integration (a JSON plugin) and the track_layer_decision callback to push a payload of your own design to window.dataLayer. This gives you separate, cleanly named experiment and variation fields, holdback handling, and a dataLayer event you can fan out to any GTM tag — not just GA4.

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": "Google Tag Manager (Custom)",
  "form_schema": [],
  "description": "Pushes Optimizely experiment decisions to window.dataLayer as an optimizely_decision custom event for GTM to route to GA4 or other destinations",
  "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;\n\nwindow.dataLayer = window.dataLayer || [];\nwindow.dataLayer.push({\n  event: 'optimizely_decision',\n  optimizely_experiment: expName,\n  optimizely_variation: variationValue,\n  optimizely_campaign_id: String(campaignId),\n  optimizely_experiment_id: String(experimentId),\n  optimizely_variation_id: String(variationId),\n  optimizely_is_holdback: isHoldback\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 Integrations tab and enable the "Google Tag Manager (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) {}
}

Handling holdbacks: A visitor in the campaign holdback group is recorded with the variation value "holdback", and the boolean optimizely_is_holdback is also pushed so you can filter holdbacks in GA4.

var variationValue = isHoldback ? 'holdback' : varName;

Pushing to the dataLayer: Unlike a direct analytics SDK integration, the callback does not call GA4 at all — it pushes a plain object onto window.dataLayer. The event: 'optimizely_decision' key is the signal GTM's Custom Event trigger matches on; the remaining keys become Data Layer Variables. No waitUntil is needed because window.dataLayer is a plain array created by the GTM snippet and pushes are safe before GTM finishes loading — GTM replays queued pushes once it initializes.

window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
  event: 'optimizely_decision',
  optimizely_experiment: expName,
  optimizely_variation: variationValue,
  optimizely_campaign_id: String(campaignId),
  optimizely_experiment_id: String(experimentId),
  optimizely_variation_id: String(variationId),
  optimizely_is_holdback: isHoldback
});

Configuring the GTM Side

The dataLayer push is inert until GTM is told how to read and route it. Create the following in your GTM workspace.

Data Layer Variables

Go to Variables > User-Defined Variables > New and create one Data Layer Variable per field you want in GA4. For each: Variable Type Data Layer Variable, Data Layer Version Version 2.

GTM Variable Name

Data Layer Variable Name

DLV - optimizely_experiment

optimizely_experiment

DLV - optimizely_variation

optimizely_variation

DLV - optimizely_is_holdback

optimizely_is_holdback

Add variables for optimizely_campaign_id, optimizely_experiment_id, and optimizely_variation_id if you want the raw IDs in GA4 as well.

Custom Event Trigger

Go to Triggers > New, Trigger Type Custom Event, and set Event name to optimizely_decision (the exact value pushed in the callback). Leave it firing on All Custom Events that match this name.

GA4 Event Tag

Go to Tags > New, Tag Type Google Analytics: GA4 Event, select your GA4 Configuration/Google tag, and set:

  • Event Name: optimizely_decision (this becomes the GA4 event name; it satisfies GA4's alphanumeric-plus-underscore rule).

  • Event Parameters:

    • optimizely_experiment{{DLV - optimizely_experiment}}

    • optimizely_variation{{DLV - optimizely_variation}}

    • optimizely_is_holdback{{DLV - optimizely_is_holdback}}

  • Triggering: the optimizely_decision Custom Event trigger.

Submit > Publish the container. To route the same decision to another destination — Google Ads, a Floodlight tag, or a server container — add another tag bound to the same optimizely_decision trigger. That fan-out is the reason to push to the dataLayer rather than call one SDK directly.

Verifying the Integration

After enabling either method, verify the data path end to end: the dataLayer push, the GTM trigger/tag firing, and arrival in GA4.

Console Verification (the dataLayer push)

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

// Confirm the GTM dataLayer exists
console.log('dataLayer present:', Array.isArray(window.dataLayer));

// Inspect what has been pushed — look for your decision event
console.log(window.dataLayer.filter(function (m) {
  return m && (m.event === 'optimizely_decision' || m.event === 'experience_impression');
}));

// Check Optimizely's own state for the active decision
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 push a test event to confirm GTM receives it
window.dataLayer.push({ event: 'optimizely_decision', optimizely_experiment: 'Test', optimizely_variation: 'Test' });

GTM Preview Mode (the trigger and tag)

  1. In GTM, click Preview and enter your site URL to open Tag Assistant.

  2. Browse a page with an active experiment.

  3. In the Tag Assistant timeline, look for the optimizely_decision (or experience_impression) event in the left-hand event list.

  4. Click it and confirm your GA4 Event tag appears under Tags Fired.

  5. Open the tag and verify the Data Layer Variables resolved to the expected experiment and variation values — not undefined.

GA4 Realtime and DebugView (the destination)

  1. In GA4, open Reports > Realtime and confirm the optimizely_decision (or experience_impression) event appears.

  2. For parameter-level inspection, enable GTM Preview (which sets debug mode) and open GA4 Admin > DebugView; click the event and confirm optimizely_experiment / optimizely_variation carry the right values.

Custom event parameters are not automatically queryable in standard reports. To analyze them, register each as a custom dimension (next section).

Analyzing Experiments in GA4

GTM has delivered the data; all analysis happens in GA4. The variation arrives as an event parameter, which GA4 does not expose in reports until you register it as a custom dimension.

Register Custom Dimensions

  1. In GA4, go to Admin > Custom definitions > Create custom dimension.

  2. Create an event-scoped dimension named, for example, "Optimizely Variation" with Event parameter optimizely_variation.

  3. Repeat for optimizely_experiment and optimizely_is_holdback.

Custom dimensions are not retroactive — they only populate from the time you create them, so register them before you rely on the data.

Explorations (Free-form)

  1. Go to Explore > Free-form.

  2. Add your "Optimizely Variation" and "Optimizely Experiment" custom dimensions as rows.

  3. Add metrics such as Conversions, Event count, or a key-event count.

  4. Compare conversion rate and engagement across variations side by side.

Funnel Explorations

  1. Create a Funnel exploration with your conversion sequence (e.g., view_item → add_to_cart → begin_checkout → purchase).

  2. Add a breakdown by the "Optimizely Variation" dimension, or apply a segment where optimizely_variation equals a specific value.

  3. Compare step-by-step completion rates between control and treatment.

Audiences and Comparisons

  1. Build a GA4 Audience with the condition optimizely_variation = "Variation A" (event parameter condition) to reuse the cohort across reports and remarketing.

  2. In standard reports, add a Comparison filtering on the variation dimension to split any report by variation without building an exploration.

Note that Optimizely's built-in GA4 integration can also push experiment variations to GA4 as audiences directly (its "Report Generation" feature, limited to four variations per experiment because a GA4 report supports at most four audiences). That is a separate Optimizely-managed path and does not require the GTM tag described above.

Gotchas

GTM Is a Router, Not a Reporting Tool

The single most common misconception: enabling the integration and publishing the GTM container does not, by itself, produce any report. GTM only moves the decision into GA4 as an event. Until you register custom dimensions in GA4 and build explorations or audiences there, the variation data exists but is invisible in reporting. All "analysis" in this article happens in GA4 (or whatever destination consumes the dataLayer), never in GTM.

GA4 Parameter Naming Rules Constrain Your dataLayer Keys

GA4 event names and parameter names accept only alphanumeric characters and underscores, must start with a letter, and are capped at 40 characters. The custom integration therefore uses underscore-delimited keys (optimizely_experiment) rather than the hyphenated style. Hyphenated GA4 event or parameter names are silently rejected by GA4 even though GTM will happily pass them through — a frequent cause of "the tag fired but the parameter is empty in GA4."

Mask Descriptive Names Sends Numeric IDs

If Mask descriptive names is enabled in Optimizely's privacy settings, the state.getDecisionObject() lookup returns 16-digit numeric IDs instead of experiment and variation names, and those IDs flow into GA4. Disable it for readable names — but note that doing so exposes your variation names in client-side source code.

Load-Order Discrepancies

If the GTM container script loads well after Optimizely, a decision pushed before window.dataLayer exists is lost. The GTM snippet creates window.dataLayer and an array shim early, which is why Optimizely's documentation insists on placing the two snippets adjacently in the <head>. A 5–15% gap between Optimizely decision counts and GA4 event counts is normal; a larger gap usually points to load order or ad blocking.

Consent Mode Can Suppress GA4 Tags

If you use Google Consent Mode, GA4 tags may be held or run in a cookieless mode until the visitor grants analytics_storage consent. The dataLayer push still happens, and the GTM trigger still fires, but the GA4 tag may not send (or may send pinged/modeled data) depending on consent state. Verify your consent configuration if decisions appear in GTM Preview but not in GA4.

One Decision, Many Tags — Watch for Double Counting

Because any tag can subscribe to the optimizely_decision trigger, it is easy to accidentally fire two GA4 tags (for example, both Method 1's experience_impression tag and Method 2's optimizely_decision tag) for the same visit. Pick one method per destination, or namespace the events deliberately, to avoid double-counting impressions in GA4.

16-Digit IDs and PII Redaction

Optimizely's experiment and variation IDs are 16-digit numbers, which some downstream redaction tooling flags as credit-card-like. The callback sends IDs as strings paired with readable names; prefer the name fields for analysis and keep raw IDs as a secondary parameter.

Troubleshooting

No Event Appears in GTM Preview

  • Integration not enabled: Confirm the integration is toggled on in Settings > Integrations and enabled (Tracked) for the specific experiment.

  • Visitor not bucketed: The decision fires only when the visitor meets the experiment's audience conditions. Confirm an active campaign in window.optimizely.get('state').getCampaignStates({ isActive: true }).

  • dataLayer missing: If window.dataLayer is not an array at decision time, the GTM snippet has not loaded. Check snippet placement in the <head>.

Tag Fires but GA4 Parameter Is Empty

  • Variable name mismatch: The Data Layer Variable's "Data Layer Variable Name" must match the pushed key exactly, including case (optimizely_variation, Holdback).

  • Hyphenated parameter name: GA4 rejects hyphens in event/parameter names. Use underscores.

  • Wrong Data Layer Version: Use Version 2 for these variables.

Event in Realtime but Not in Standard Reports

  • Custom dimension not registered: Event parameters require a registered custom dimension to appear in reports and explorations. Register it and wait for data to accumulate — dimensions are not retroactive.

  • Processing latency: Standard GA4 reports can lag up to 24–48 hours; use Realtime and DebugView for immediate verification.

Names Are Numeric in GA4

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

Data Discrepancies Between Platforms

Differences between Optimizely decision counts and GA4 event counts are expected:

  • Counting unit: Optimizely counts unique cookie-based visitors; GA4 counts events and resolves identity via its own client/user model.

  • Load order: Decisions pushed before GTM initializes are lost. Co-locate the snippets.

  • Consent Mode: Denied analytics_storage consent can suppress or model GA4 hits while the dataLayer push still occurs.

  • Ad blockers: Privacy extensions block googletagmanager.com and google-analytics.com independently of optimizely.com, skewing counts in either direction.

  • Preview/QA traffic: Optimizely Preview Mode fires the decision callback, so QA pushes reach production GA4 unless filtered.

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

Related guides