Integrate Google Tag Manager with Optimizely Feature Experimentation
TL;DR
Google Tag Manager (GTM) is a tag deployment and data-routing layer, not an analytics store. 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 getting it into GTM's pipeline so it reaches Google Analytics 4 (GA4) — or any other destination GTM manages — tagged with the variation the user actually received. In the browser, that means a window.dataLayer.push() that a GTM Custom Event trigger picks up. On the server, where there is no browser dataLayer, it means sending the event to a GTM server container or directly to GA4 via the Measurement Protocol over HTTP.
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. Optimizely's own GA4 documentation builds this integration around the listener, pushing the decision to the dataLayer for GTM. This guide follows that documented browser path, then extends it to Node.js and Python server-side decisions, where identity and transport become the critical details — the server has no dataLayer and no GA4 client cookie, so it must supply a client_id of its own.
How the Integration Works
The decision notification listener receives the flag key, whether the flag is enabled, the variation key, the rule key, and whether Optimizely dispatched an impression. The integration forwards this to GTM. In the browser, it pushes an optimizely-decision event to window.dataLayer; a GTM Custom Event trigger matches it, Data Layer Variables read the fields, and a GA4 Event tag forwards them to your GA4 property. On the server, the listener instead issues an HTTP POST — to a GTM server container or straight to GA4's Measurement Protocol endpoint.
flowchart LR
A["optimizely.decide('flag')"] --> B[DECISION notification fires]
B --> C{Where does the listener run?}
C -->|Browser| D["window.dataLayer.push({ event: 'optimizely-decision', ... })"]
C -->|Server| E["POST to GTM server container OR GA4 Measurement Protocol"]
D --> F[GTM Custom Event trigger + GA4 Event tag]
E --> G[GA4 property]
F --> G
G --> H[Reports, Explorations, Audiences by variation]
In every path GTM (or GA4) is the consumer; the decision listener is the producer. The browser path is the one Optimizely documents; the server paths exist because a dataLayer only lives in a browser, so a backend decision cannot reach GTM the same way.
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 can inflate your GA4 counts relative to Optimizely's experiment results. Optimizely's own example gates the forward on this field.
dataLayer Contract (Browser)
The browser listener pushes the exact object Optimizely's GA4 documentation specifies:
window.dataLayer.push({
'event': 'optimizely-decision',
'optimizely-flagKey': decisionInfo.flagKey,
'optimizely-ruleKey': decisionInfo.ruleKey,
'optimizely-variationKey': decisionInfo.variationKey
});
GTM reads optimizely-flagKey, optimizely-ruleKey, and optimizely-variationKey into Data Layer Variables. Note that these dataLayer keys are hyphenated, but the GA4 event and parameter names you configure in the GA4 tag must use only alphanumerics and underscores (the tag maps the hyphenated variables to clean parameter names like Flag, Rule, Variation).
Prerequisites
Before starting the integration:
Optimizely Feature Experimentation SDK installed for your platform (JavaScript SDK v6+, Node.js SDK, or Python SDK).
A GTM container with its snippet on the page (browser path), and/or a GTM server container if you route server-side decisions through GTM.
A GA4 property and Web data stream with a Measurement ID (
G-XXXXXXXXXX).A Measurement Protocol API secret (server path) — create one under GA4 Admin > Data Streams > [your stream] > Measurement Protocol API secrets > Create.
HTTP client for server-side use — Node.js 18+ has
fetchbuilt in; Python uses therequestslibrary.A consistent
client_idfor the server path. The browser's GA4 cookie (_ga) holds aclient_idthat ties events to a user/session. A server-side Measurement Protocol call must supply a matchingclient_id(or a stableuser_id) so the server-sent decision joins the same GA4 user as the browser session — otherwise it lands on a separate user.
JavaScript / Browser Implementation
When the Optimizely FX SDK runs in the browser, the GTM container snippet has already created window.dataLayer. Register the DECISION listener and push the decision; GTM does the rest. This is the path Optimizely documents.
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;
// Only forward decisions that produced an Optimizely impression
if (!decisionInfo.decisionEventDispatched) return;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'optimizely-decision',
'optimizely-flagKey': decisionInfo.flagKey,
'optimizely-ruleKey': decisionInfo.ruleKey,
'optimizely-variationKey': decisionInfo.variationKey
});
}
);
const user = optimizely.createUserContext(userId, userAttributes);
const decision = user.decide('checkout_redesign');
console.log('Variation:', decision.variationKey);
});
No fetch and no API keys are needed — window.dataLayer.push() hands the decision to GTM, which is already on the page.
Configuring the GTM Side
The dataLayer push is inert until GTM is configured to read and route it. In your GTM workspace:
Data Layer Variables
Go to Variables > User-Defined Variables > New and create three Data Layer Variables (Variable Type Data Layer Variable, Data Layer Version Version 2):
GTM Variable Name | Data Layer Variable Name |
|---|---|
Optimizely - flagKey |
|
Optimizely - ruleKey |
|
Optimizely - variationKey |
|
Custom Event Trigger
Go to Triggers > New, Trigger Type Custom Event, Event nameoptimizely-decision. (Optimizely suggests naming the GA4 event optimizely-decision-fs for Feature Experimentation and optimizely-decision-web for Web only when you run both products and need to tell the projects apart — the dataLayer event you trigger on is optimizely-decision.)
GA4 Event Tag
Go to Tags > New, Tag Type Google Analytics: GA4 Event, select your GA4 Configuration tag, and set the Event Name (for example optimizely_decision_fs, using underscores to satisfy GA4's naming rules) with these Event Parameters:
Flag→{{Optimizely - flagKey}}Rule→{{Optimizely - ruleKey}}Variation→{{Optimizely - variationKey}}
Set the tag's trigger to the optimizely-decision Custom Event trigger, then Submit > Publish. Verify in GA4's Realtime report.
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
);
});
Node.js Implementation
On the server there is no window.dataLayer. You have two GTM-compatible transports: send the event to a GTM server container (which then forwards to GA4 and any other server-side tags), or send it directly to GA4's Measurement Protocol. Both are HTTP POSTs; the difference is the endpoint and who owns the GA4 tagging logic.
The Measurement Protocol endpoint for a web data stream is:
POST https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=YOUR_API_SECRET
The request body carries a client_id (or user_id) and an events array. The example below posts to GA4's Measurement Protocol; to route through a GTM server container instead, swap the host for your tagging server URL (for example https://gtm.example.com/mp/collect) — a GA4 client on the server container accepts the same Measurement Protocol payload.
import { createInstance, enums } from '@optimizely/optimizely-sdk';
const GA4_MEASUREMENT_ID = process.env.GA4_MEASUREMENT_ID; // G-XXXXXXXXXX
const GA4_API_SECRET = process.env.GA4_API_SECRET;
// To use a GTM server container, point this at your tagging server URL instead.
const MP_ENDPOINT = 'https://www.google-analytics.com/mp/collect';
async function sendToGA4(clientId, eventName, params) {
const url = `${MP_ENDPOINT}?measurement_id=${GA4_MEASUREMENT_ID}&api_secret=${GA4_API_SECRET}`;
const body = {
client_id: clientId, // must match the browser _ga client_id to join the same user
events: [
{
name: eventName, // alphanumeric + underscore, <= 40 chars
params, // each value <= 100 chars (standard GA4)
},
],
};
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
// Measurement Protocol returns 2xx with no body even for invalid payloads.
// Use the /debug/mp/collect endpoint during development to surface validation errors.
if (!res.ok) {
console.error(`GA4 Measurement Protocol failed: ${res.status}`, await res.text());
}
}
const optimizely = createInstance({
sdkKey: process.env.OPTIMIZELY_SDK_KEY,
});
optimizely.onReady().then(() => {
optimizely.notificationCenter.addNotificationListener(
enums.NOTIFICATION_TYPES.DECISION,
async ({ type, userId, attributes, decisionInfo }) => {
if (type !== 'flag') return;
if (!decisionInfo.decisionEventDispatched) return;
// Prefer a GA4 client_id carried in attributes; fall back to userId as user_id upstream.
const clientId = (attributes && attributes.ga_client_id) || userId;
await sendToGA4(clientId, 'optimizely_decision_fs', {
flag: decisionInfo.flagKey,
rule: decisionInfo.ruleKey,
variation: decisionInfo.variationKey,
});
}
);
});
// 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 GA4
return decision;
}
client_id Is the Join Key
The Measurement Protocol attaches each event to a GA4 user by client_id. In the browser, GA4 stores that value in the _ga cookie. To make a server-sent decision land on the same GA4 user as the browser session, read the _ga cookie on the request and pass its client_id portion (the GA1.1.<client_id> substring, parsed to <client_id>) into the Measurement Protocol body. If you cannot obtain the browser client_id, send a stable user_id instead and register user_id in GA4 — but do not invent a random client_id per request, or every decision becomes a new GA4 user and your counts explode.
Server Container vs Direct Measurement Protocol
Routing through a GTM server container keeps your GA4 event configuration (parameter mapping, consent, additional destinations) in GTM's familiar tag/trigger/variable model and lets one server-side decision fan out to multiple destinations. Sending directly to GA4's Measurement Protocol is simpler — no server container to host — but it forwards only to GA4 and bypasses GTM's routing. Use the server container when you already run server-side tagging or need multiple destinations; use direct Measurement Protocol for a GA4-only backend.
Deduplication and Async Handling
If your application calls decide() on every request for the same user, the listener fires repeatedly and sends duplicate events. Keep a per-request or per-session Set of processed flag_key:variation_key combinations and skip the POST when already sent. The Measurement Protocol call is fire-and-forget from Optimizely's perspective: a slow or failed GA4 request must never block the flag decision. Wrap it in a short timeout and, in production, move it off the request path into a queue or background worker.
Python Implementation
The Python SDK uses the same notification listener pattern. Use the requests library to POST to GA4's Measurement Protocol (or your GTM server container). 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
GA4_MEASUREMENT_ID = os.environ['GA4_MEASUREMENT_ID'] # G-XXXXXXXXXX
GA4_API_SECRET = os.environ['GA4_API_SECRET']
# To use a GTM server container, point this at your tagging server URL instead.
MP_ENDPOINT = 'https://www.google-analytics.com/mp/collect'
def send_to_ga4(client_id, event_name, params):
url = f'{MP_ENDPOINT}?measurement_id={GA4_MEASUREMENT_ID}&api_secret={GA4_API_SECRET}'
body = {
'client_id': client_id, # match the browser _ga client_id to join the same user
'events': [
{
'name': event_name, # alphanumeric + underscore, <= 40 chars
'params': params,
}
],
}
try:
# Measurement Protocol returns 2xx with no body even for invalid payloads.
# Use /debug/mp/collect during development to surface validation errors.
res = requests.post(url, json=body, timeout=5)
if not res.ok:
print(f'GA4 Measurement Protocol failed: {res.status_code} {res.text}')
except requests.RequestException as exc:
print(f'GA4 Measurement Protocol error: {exc}')
def on_decision(decision_type, user_id, attributes, decision_info):
if decision_type != 'flag':
return
# Only forward decisions that produced an Optimizely impression
if not decision_info.get('decision_event_dispatched'):
return
flag_key = decision_info.get('flag_key')
rule_key = decision_info.get('rule_key')
variation_key = decision_info.get('variation_key')
# Prefer a GA4 client_id carried in attributes; fall back to user_id.
client_id = (attributes or {}).get('ga_client_id') or user_id
send_to_ga4(
client_id,
'optimizely_decision_fs',
{
'flag': flag_key,
'rule': rule_key,
'variation': variation_key,
},
)
# 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)
As on Node, the GA4 call is fire-and-forget: wrap it in a short timeout (as above) and, in production, push it onto a queue or background worker so analytics latency never affects the user's request.
Analyzing Experiments in GA4
GTM (or the Measurement Protocol) has delivered the decision into GA4 as an event with Flag, Rule, and Variation parameters. GA4 does not surface event parameters in reports until you register them as custom dimensions.
Register Custom Dimensions
In GA4, go to Admin > Custom definitions > Create custom dimension.
Create an event-scoped dimension "Optimizely Variation" with Event parameter
variation.Repeat for
flagandrule.
Custom dimensions are not retroactive — register them before you rely on the data.
Explorations and Funnels by Variation
In Explore > Free-form, add the "Optimizely Flag" and "Optimizely Variation" dimensions as rows and a key-event metric as values to compare conversion across variations.
In a Funnel exploration, add a breakdown by "Optimizely Variation" (or a segment on the
variationparameter) to compare step completion between control and treatment.
Audiences
Build a GA4 Audience with the condition variation = "treatment" (event-parameter scope) to reuse the cohort across reports and remarketing. Because the decision event also carries flag, scope the audience to a specific flag to avoid mixing experiments.
Gotchas
GTM/GA4 Is the Destination — GTM Alone Reports Nothing
GTM and the Measurement Protocol only move the decision into GA4 as an event. No report exists until you register custom dimensions in GA4 and build explorations or audiences there. Treat GTM as plumbing, not analytics.
client_id Mismatch Splits Users
The Measurement Protocol joins server events to a GA4 user by client_id. If the server sends a client_id that does not match the browser's _ga cookie value (or sends a fresh random one each request), the server-side decision and the browser session belong to different GA4 users and never join — and per-request random IDs inflate user counts. Read the real _ga client_id, or standardize on a registered user_id.
Measurement Protocol Fails Silently
POST /mp/collect returns a 2xx with an empty body even when the payload is malformed or the event name is invalid. There is no success signal in the response. During development, post to https://www.google-analytics.com/debug/mp/collect (or /debug/mp/collect on your server container), which returns a validationMessages array describing what was wrong.
GA4 Naming and Size Limits
GA4 event names and parameter names accept only alphanumerics and underscores and are capped at 40 characters; parameter values are capped at 100 characters (500 on GA4 360). A single Measurement Protocol request allows at most 25 events and 25 parameters per event, with a 130 kB body limit. Hyphenated names (as in the raw dataLayer keys) are valid in the dataLayer but must be remapped to underscore names in the GA4 tag or Measurement Protocol body.
decisionEventDispatched and Count Inflation
Forwarding every decision — including flags delivered without an experiment, where decisionEventDispatched is false — inflates GA4 counts relative to Optimizely's experiment results. All examples above gate the forward on this field; keep that gate unless you deliberately want non-experiment decisions in GA4.
Browser dataLayer vs Server HTTP Are Not Interchangeable
window.dataLayer.push() exists only in the browser; the Measurement Protocol POST is server-only. Sending the same decision through both — a client-side push and a server-side POST for one decide() — double-counts. Choose the path that matches where the decision is made.
Timestamp and Ordering
If you forward decisions asynchronously (queued, retried, batched), set timestamp_micros in the Measurement Protocol body to the moment the decision occurred. Omitting it stamps the event with GA4's receive time, which can misorder it relative to the session. Note the Measurement Protocol only backdates events up to 72 hours.
Troubleshooting
Events Not Appearing in GA4
Browser path: Open GTM Preview and confirm the
optimizely-decisionevent appears and the GA4 Event tag fires with resolved Data Layer Variables (notundefined). Then check GA4 Realtime / DebugView.Server path: Post to
/debug/mp/collectand readvalidationMessages. A wrongmeasurement_id, missingapi_secret, or invalid event name is the usual cause.Listener registered too late: Register the DECISION listener before the first
decide()call.Browser blocked: Ad blockers block
googletagmanager.comandgoogle-analytics.com; queueddataLayerpushes never flush.
Server Events Land on a Different GA4 User
The client_id does not match the browser. Parse the _ga cookie's client_id and pass it in the Measurement Protocol body, or standardize on a registered user_id. Until they agree, GA4 treats the surfaces as separate users.
Decision Listener Fires but Data Is Wrong
In Python, confirm you are reading snake_case keys (
flag_key,variation_key,decision_event_dispatched) — camelCase keys returnNone.Confirm you filter on
type === 'flag'; the same listener can receive other decision types depending on SDK and method.
Parameter Empty in GA4 Despite Tag Firing
Custom dimension not registered: Event parameters require a registered custom dimension to appear in reports. Register it; it is not retroactive.
Name mismatch: The Data Layer Variable's name must match the pushed key exactly, including case and hyphens (
optimizely-flagKey). The GA4 parameter name must use underscores.
Data Discrepancies Between Platforms
Counting unit: Optimizely counts unique users; GA4 counts events and resolves identity via its client/user model.
client_id gaps: Server decisions with a missing or mismatched
client_ideither miss the user join or inflate user counts.Non-experiment decisions: Forwarding decisions where
decisionEventDispatchedisfalseinflates GA4 relative to Optimizely.Consent Mode: Denied
analytics_storageconsent can suppress or model browser GA4 hits while thedataLayerpush still occurs.Async failures: Dropped or timed-out Measurement Protocol calls silently lower GA4 counts — log failures so you can quantify the gap.
Expect a 5–15% discrepancy for the same experiment, with a larger gap if consent denials or ad blocking are common in your audience.