Integrate Matomo with Optimizely Feature Experimentation
TL;DR
Matomo is web analytics you host yourself. The data stays in your database, which is why it turns up in organisations where sending visitor behaviour to a third party is a legal question rather than a procurement one. Optimizely Feature Experimentation decides which variation of a flag each user receives. Forwarding those decisions into Matomo lets you segment every report you already have — visits, goals, funnels, page performance — by the variation a visitor was actually served, without any of that analysis leaving your infrastructure.
Matomo sells an A/B testing plugin of its own, and this article is not about it. It is about using Matomo as the analytics destination for experiments you run in Optimizely, which is the situation you are in when Optimizely already owns targeting, bucketing and results, and Matomo is where your organisation's reporting lives.
Feature Experimentation exposes no Custom Analytics Integration UI, so the bridge is a decision notification listener: a callback that fires on every decide(). Where it runs determines whether you push onto Matomo's JavaScript queue or post to its HTTP Tracking API.
How the Integration Works
Matomo's primitive for "an attribute of this visit" is a custom dimension, and a custom dimension is what every segment and report filter can read. The event is the complement — it puts the exposure on the visitor log with a timestamp.
flowchart LR
A["user.decide('checkout_redesign')"] --> B[DECISION notification fires]
B --> C{Where does the listener run?}
C -->|Browser| D["_paq.push setCustomDimension"]
C -->|Server| E["GET matomo.php with dimensionN"]
D --> F[Visit carries the dimension]
E --> F
F --> G[Segment every report by variation]
F --> H[Goals and funnels per arm]
Decision Notification Data
The listener's decision info object carries these fields for a flag-type decision. The 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 on 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 |
Custom Dimensions Are Not Free-Form
This is the detail that catches everyone on their first Matomo integration: a custom dimension must be created in the Matomo admin before it can be used, and it is addressed by an integer, not a name. Push a value to a dimension index that does not exist and Matomo discards it silently.
Scope | Attached to | Right for |
|---|---|---|
Visit | The whole visit; last value written wins | The variation a visitor was served |
Action | One page view or event | Something that changes within a visit |
Use visit scope for the variation. A visitor is bucketed once and keeps that variation for the visit, which is exactly what visit scope models, and visit-scoped dimensions are the ones Matomo offers in the segment editor next to visit-level metrics.
Note the index down when you create it. Every code sample below uses 1, and yours will be whatever the admin assigned.
Prerequisites
Optimizely Feature Experimentation SDK for your platform — the JavaScript SDK v6 or later, the Node.js SDK, or the Python SDK.
A Matomo instance — self-hosted or Matomo Cloud — and the site ID for the property you are tracking.
A visit-scoped custom dimension created under Administration > Websites > Custom Dimensions, and its index.
The Matomo JavaScript tracker installed, so
window._paqexists before the listener fires.An
token_authvalue with write access if you intend to track server-side, plus a way to carry the visitor ID from the browser to your backend.
Browser Implementation
Matomo's tracker is a command queue: pushing onto _paq before the tracker script has loaded is safe, because the queue is drained on load. That makes ordering easy — but only for the queue, not for the request.
import { createInstance, enums } from '@optimizely/optimizely-sdk';
const EXPERIMENT_DIMENSION_ID = 1; // the index Matomo assigned; not a name
window._paq = window._paq || [];
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 } = decisionInfo;
const value = enabled ? variationKey : 'off';
window._paq.push(['setUserId', userId]);
// Visit-scoped: attaches to this visit and every subsequent request in it
window._paq.push(['setCustomDimension', EXPERIMENT_DIMENSION_ID, `${flagKey}:${value}`]);
// Event: puts the exposure on the visitor log with a timestamp
window._paq.push(['trackEvent', 'Experiment', 'Viewed', `${flagKey}:${value}`]);
},
);
const user = optimizely.createUserContext('user_123', { plan: 'pro' });
const decision = user.decide('checkout_redesign');
// Track the page view AFTER the decision, so the dimension is on it
window._paq.push(['trackPageView']);
});
The last line is the whole trick. A custom dimension is attached to requests sent after it is set, never to requests already sent. The standard Matomo snippet calls trackPageView immediately, so a decision resolved a moment later misses the very page view you most want it on. Either defer the page view until after the decision, as above, or accept that the first page view of a visit carries no variation and analyse from the second onward. The first option is almost always what you want; the second is a legitimate choice if deferring the page view would delay your tracking beyond what you are willing to lose.
Encoding the value as flagKey:variation in a single dimension is deliberate. Matomo instances have a finite number of custom dimension slots, and one slot per flag exhausts them quickly. A single "experiment" dimension holds any flag, and the segment for one arm is a "contains" match rather than an equals. If you have slots to spare and run few flags, one dimension per flag gives cleaner segments.
Server-Side Node.js Implementation
Matomo's HTTP Tracking API accepts the same fields as a query string. The critical parameter is _id: the 16-character hexadecimal visitor ID. Without it, Matomo invents a visitor per request and your visit counts inflate to the number of requests.
const optimizelySdk = require('@optimizely/optimizely-sdk');
const { enums } = require('@optimizely/optimizely-sdk');
const MATOMO_URL = process.env.MATOMO_URL; // e.g. https://analytics.example.com/matomo.php
const SITE_ID = process.env.MATOMO_SITE_ID;
const TOKEN_AUTH = process.env.MATOMO_TOKEN_AUTH;
const EXPERIMENT_DIMENSION_ID = 1;
async function trackDecision(visitorId, userId, decisionInfo) {
const { flagKey, enabled, variationKey } = decisionInfo;
const value = enabled ? variationKey : 'off';
const params = new URLSearchParams({
idsite: SITE_ID,
rec: '1',
apiv: '1',
_id: visitorId, // 16 hex characters, taken from the browser's Matomo cookie
uid: userId,
e_c: 'Experiment',
e_a: 'Viewed',
e_n: `${flagKey}:${value}`,
[`dimension${EXPERIMENT_DIMENSION_ID}`]: `${flagKey}:${value}`,
token_auth: TOKEN_AUTH,
});
try {
const response = await fetch(`${MATOMO_URL}?${params.toString()}`);
if (!response.ok) console.error('matomo tracking rejected', response.status);
} catch (error) {
console.error('matomo tracking failed', error);
}
}
const optimizely = optimizelySdk.createInstance({ sdkKey: process.env.OPTIMIZELY_SDK_KEY });
optimizely.onReady().then(() => {
optimizely.notificationCenter.addNotificationListener(
enums.NOTIFICATION_TYPES.DECISION,
({ type, userId, attributes, decisionInfo }) => {
if (type !== 'flag') return;
const visitorId = (attributes || {}).matomo_visitor_id;
if (!visitorId) return; // without it, every request becomes a new visitor
trackDecision(visitorId, userId, decisionInfo);
},
);
});
Read the visitor ID from the Matomo first-party cookie in the browser and send it up with your requests, or set it yourself and pass it to the tracker. Do not generate a fresh one server-side per request: that is precisely the failure mode the parameter exists to prevent, and it is invisible until somebody notices the visit count is impossible.
token_auth grants tracking rights, so keep it server-side. It must never reach the browser.
Python Implementation
The Python SDK's DECISION callback takes four positional arguments and exposes snake_case keys in decision_info. Reading decision_info.get('flagKey') returns None silently, which here produces a dimension value of None:None on every visit.
import os
import requests
from optimizely import optimizely
from optimizely.helpers import enums
MATOMO_URL = os.environ['MATOMO_URL']
SITE_ID = os.environ['MATOMO_SITE_ID']
TOKEN_AUTH = os.environ['MATOMO_TOKEN_AUTH']
EXPERIMENT_DIMENSION_ID = 1
def track_decision(visitor_id, user_id, flag_key, value):
params = {
'idsite': SITE_ID,
'rec': 1,
'apiv': 1,
'_id': visitor_id,
'uid': user_id,
'e_c': 'Experiment',
'e_a': 'Viewed',
'e_n': f'{flag_key}:{value}',
f'dimension{EXPERIMENT_DIMENSION_ID}': f'{flag_key}:{value}',
'token_auth': TOKEN_AUTH,
}
try:
response = requests.get(MATOMO_URL, params=params, timeout=2)
if response.status_code >= 400:
print(f'matomo tracking rejected: {response.status_code}')
except requests.RequestException as error:
print(f'matomo tracking failed: {error}')
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')
attributes = attributes or {}
visitor_id = attributes.get('matomo_visitor_id')
if not visitor_id:
return
track_decision(visitor_id, user_id, flag_key, variation_key if enabled else 'off')
optimizely_client = optimizely.Optimizely(sdk_key=os.environ['OPTIMIZELY_SDK_KEY'])
optimizely_client.notification_center.add_notification_listener(
enums.NotificationTypes.DECISION, on_decision
)
user = optimizely_client.create_user_context(
'user_123', {'plan': 'pro', 'matomo_visitor_id': '1a2b3c4d5e6f7a8b'}
)
decision = user.decide('checkout_redesign')
print('Variation:', decision.variation_key)
Privacy Considerations
Self-hosting removes the third-party transfer, not the obligation. Two decisions are worth making explicitly.
Do not put a user identifier in the dimension. The dimension should hold the flag key and the variation, both of which are internal labels. uid is the field for identity, and it is governed by whatever consent regime you already apply to Matomo.
Decide whether the variation is tracked before consent. Under a consent-required regime, a visitor who has not opted in should not be tracked at all, which means the listener must check the same consent state your Matomo snippet does. Matomo's own consent commands are the right place to hang that check — if tracking is disabled, the queued commands are simply not sent, and your listener needs no special case.
Verifying the Integration
Trigger a decision in a browser you can identify.
In Matomo, open Visitors > Visits Log and find the visit — it is near-real-time, unlike the aggregated reports.
Confirm the visit shows your custom dimension with
checkout_redesign:treatment.Confirm the Experiment / Viewed event appears in the same visit.
Confirm the page view that follows the decision also carries the dimension. If it does not, the page view was tracked before the decision resolved.
Build a segment on the dimension and confirm the visit is returned.
Aggregated reports are processed on a schedule, so a segment that returns nothing an hour after your test may simply not have been archived yet. The visits log is the surface to debug against.
Analyzing Experiments in Matomo
Create a segment per variation — the dimension contains checkout_redesign:treatment — and save it. Every Matomo report accepts a segment, so one saved segment gives you the whole product filtered to one arm.
Compare goal conversion between segments. This is the closest Matomo gets to an experiment result, and it is genuinely useful for cross-checking Optimizely's own numbers against a system that counted independently. A large discrepancy usually means the two tools disagree about who was exposed, not about what happened.
Read the funnel per arm. If your instance has funnels configured, applying the two segments shows where the arms diverge, which a single conversion number cannot.
Do not treat the comparison as a test. Matomo reports counts and percentages without an interval or a significance calculation. Take the result from Optimizely, and use Matomo for the surrounding behaviour — page performance, entry pages, devices — that a results page does not carry.
Troubleshooting
Symptom | Likely cause | Fix |
|---|---|---|
Dimension never appears | The index does not exist in the admin | Create the dimension and use the integer it is given |
Dimension missing on the first page view |
| Defer the page view until after |
Dimension appears on events but not the visit | Dimension created with action scope | Recreate it with visit scope |
Visit count far exceeds real visitors |
| Send the browser's visitor ID with every server call |
Nothing recorded server-side |
| Use a token with write access, kept server-side only |
Reports empty but visits log is fine | Archiving has not run for that period | Wait, or trigger archiving |
Dimension slots exhausted | One dimension per flag | Encode |
Numbers disagree with Optimizely | Rollout decisions forwarded as experiment arms | Filter on |
Related Reading
Integrate Google Tag Manager with Optimizely Feature Experimentation — the same decision listener when a tag manager owns your analytics tags.
Integrate Adobe Analytics with Optimizely Feature Experimentation — the enterprise counterpart, with eVars in place of custom dimensions.
Integrate Snowplow with Optimizely Feature Experimentation — for teams that want to own the pipeline as well as the reporting.