Integrate June with Optimizely Feature Experimentation
TL;DR
June is product analytics built around companies rather than users. Where most analytics tools ask "how many people did this", June asks "how many accounts did this, and which ones" — which is the question a B2B product team actually has. Optimizely Feature Experimentation decides which variation of a flag each user receives. Forwarding those decisions into June lets you read adoption, retention and feature usage per variation and per account, which is where B2B experiments live or die.
The distinction matters more than it first appears. In a consumer product, a user is a unit of analysis and 10,000 users is a healthy experiment. In B2B, a hundred accounts of wildly different sizes share one product, and a variation that delights five seat-heavy enterprise workspaces while annoying two hundred solo users can look like a win or a loss depending entirely on which unit you counted. June is one of the few tools that will let you count both without exporting anything, and this integration is what puts the variation in front of it.
Feature Experimentation exposes no Custom Analytics Integration UI, so the bridge is a decision notification listener: a callback that fires every time decide() resolves a flag.
How the Integration Works
June follows the familiar identify / group / track shape. The integration writes the variation into all three: onto the user as a trait, onto the company as a group trait, and as a timestamped event.
flowchart LR
A["user.decide('bulk_import')"] --> B[DECISION notification fires]
B --> C["analytics.identify — user trait"]
B --> D["analytics.group — company trait"]
B --> E["analytics.track — Experiment Viewed"]
C --> F[June profile]
D --> G[June company]
E --> H[Event stream]
F --> I[Audiences and reports by variation]
G --> I
H --> I
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 |
User Trait, Group Trait, or Both
User trait | Group trait | Event | |
|---|---|---|---|
Answers | Which users saw the variation | Which accounts are in the experiment | When exposure happened |
Right when | The flag is bucketed per user | The flag is bucketed per account | Always |
Reporting unit | Users | Companies | Either |
Risk | Says nothing about account-level rollout | Wrong if users in one account differ | Cheap, no risk |
The rule is to match the trait to your bucketing key. If you call createUserContext(userId) and Optimizely buckets each user independently, the user trait is the truth and a group trait would be a lie — the same account will contain both arms. If you bucket on the account, by passing the workspace ID as the Optimizely user ID or by using an account attribute in your targeting, the group trait is the truth and the user trait is derived from it. Writing both is only correct when both are actually true.
Getting this backwards produces a specific, recognisable symptom: reports that show every company in both variations at once, with numbers that never separate no matter how long the experiment runs.
Prerequisites
Optimizely Feature Experimentation SDK for your platform — the JavaScript SDK v6 or later, the Node.js SDK, or the Python SDK.
A June workspace and its write key. The browser SDK is
@june-so/analytics-next; the server SDK is@june-so/analytics-node.A consistent user ID and company ID. June's company reports depend on
groupcalls carrying the samegroupIdyour application uses for a workspace, and the sameuserIdyou pass tocreateUserContext().A decision about the bucketing key, per the table above, before you write the listener.
June's HTTP tracking API if you forward from Python, since the maintained SDKs are JavaScript.
Browser Implementation
import { AnalyticsBrowser } from '@june-so/analytics-next';
import { createInstance, enums } from '@optimizely/optimizely-sdk';
const analytics = AnalyticsBrowser.load({ writeKey: '<YOUR_JUNE_WRITE_KEY>' });
const optimizely = createInstance({ sdkKey: '<YOUR_SDK_KEY>' });
optimizely.onReady().then(() => {
optimizely.notificationCenter.addNotificationListener(
enums.NOTIFICATION_TYPES.DECISION,
({ type, userId, attributes, decisionInfo }) => {
if (type !== 'flag') return;
const { flagKey, enabled, variationKey, ruleKey, decisionEventDispatched } = decisionInfo;
const value = enabled ? variationKey : 'off';
const companyId = (attributes || {}).company_id;
// The user saw this variation
analytics.identify(userId, { [`flag_${flagKey}`]: value });
// The account is in this arm — only correct when bucketing is per account
if (companyId) {
analytics.group(companyId, { [`flag_${flagKey}`]: value });
}
analytics.track('Experiment Viewed', {
flag_key: flagKey,
variation_key: value,
rule_key: ruleKey,
event_dispatched: decisionEventDispatched,
});
},
);
const user = optimizely.createUserContext('user_123', {
plan: 'pro',
company_id: 'acct_9f2',
});
const decision = user.decide('bulk_import');
console.log('Variation:', decision.variationKey);
});
Carrying the company ID through the Optimizely user attributes, as above, is convenient: the attribute is already being passed for targeting, and the listener receives it without any additional plumbing. It is not required — any request-scoped context works — but it keeps the listener a pure function of what Optimizely already knows.
enabled ? variationKey : 'off' matters as usual: variationKey is null for a disabled flag, and a null trait is indistinguishable from a broken integration when you come to build the audience.
Server-Side Node.js Implementation
June's Node SDK batches and flushes in the background, which is what you want in a long-running service and not what you want in a short one.
const { Analytics } = require('@june-so/analytics-node');
const optimizelySdk = require('@optimizely/optimizely-sdk');
const { enums } = require('@optimizely/optimizely-sdk');
const analytics = new Analytics(process.env.JUNE_WRITE_KEY);
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 { flagKey, enabled, variationKey, ruleKey } = decisionInfo;
const value = enabled ? variationKey : 'off';
const companyId = (attributes || {}).company_id;
analytics.identify({ userId, traits: { [`flag_${flagKey}`]: value } });
if (companyId) {
analytics.group({ userId, groupId: companyId, traits: { [`flag_${flagKey}`]: value } });
}
analytics.track({
userId,
event: 'Experiment Viewed',
properties: { flag_key: flagKey, variation_key: value, rule_key: ruleKey },
});
},
);
});
// Serverless and short-lived processes exit before the batch is delivered
process.on('SIGTERM', async () => {
await analytics.closeAndFlush();
});
The closeAndFlush() call is the difference between an integration that works locally and one that loses most of its data in production. In a Lambda, a Cloud Run instance, or any process that can be reclaimed between requests, a batched client that is never flushed drops whatever it was holding — and the losses cluster on deploys and scale-downs, which is exactly when your traffic mix is unusual.
Python Implementation
June's maintained SDKs are JavaScript, so a Python service posts to the HTTP tracking API directly. The Python SDK's DECISION callback takes four positional arguments and exposes snake_case keys in decision_info.
import os
import requests
from optimizely import optimizely
from optimizely.helpers import enums
JUNE_WRITE_KEY = os.environ['JUNE_WRITE_KEY']
JUNE_API = 'https://api.june.so/api'
HEADERS = {'Authorization': f'Bearer {JUNE_WRITE_KEY}', 'Content-Type': 'application/json'}
def post(path, payload):
try:
response = requests.post(f'{JUNE_API}/{path}', json=payload, headers=HEADERS, timeout=2)
if response.status_code >= 400:
print(f'june {path} rejected: {response.status_code} {response.text}')
except requests.RequestException as error:
print(f'june {path} 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')
rule_key = decision_info.get('rule_key')
value = variation_key if enabled else 'off'
attributes = attributes or {}
company_id = attributes.get('company_id')
post('identify', {'userId': user_id, 'traits': {f'flag_{flag_key}': value}})
if company_id:
post(
'group',
{
'userId': user_id,
'groupId': company_id,
'traits': {f'flag_{flag_key}': value},
},
)
post(
'track',
{
'userId': user_id,
'event': 'Experiment Viewed',
'properties': {
'flag_key': flag_key,
'variation_key': value,
'rule_key': rule_key,
},
},
)
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', 'company_id': 'acct_9f2'}
)
decision = user.decide('bulk_import')
print('Variation:', decision.variation_key)
Three synchronous HTTP calls inside a notification listener is fine for a worker and wrong on a request path. In a web service, push the payloads onto a queue and let a background consumer post them; the flag decision has already been made by the time the listener runs, so nothing downstream needs to wait.
Verifying the Integration
Trigger a decision for a test user who belongs to a known company.
In June, open the user's profile and confirm the trait
flag_bulk_importshows the expected variation.Open the company and confirm the group trait is set — or confirm it is absent, if you deliberately bucket per user.
Confirm Experiment Viewed appears in the event stream with its properties.
Build an audience on the trait and confirm it returns the expected users or companies.
Repeat with the flag off and confirm the trait reads
off.
If traits appear on users but never on companies, the group call is missing a groupId — June cannot infer the company from the user.
Analyzing Experiments in June
Report per company, not only per user. Create the report for the feature you changed and split it by the variation trait. In B2B the number that matters is how many accounts adopted the feature, and June computes that natively rather than making you approximate it with a distinct count.
Watch the weight of your arms. Before reading any result, compare the accounts in each arm by size. Random assignment across a hundred accounts routinely produces arms of unequal total seats, and a difference driven by one large workspace landing in the treatment is not an experiment result. This check is quick in June and impossible in most tools without an export.
Use audiences to follow up. Build an audience of accounts in the losing arm and hand it to customer success, or to a targeted in-product message. An experiment that ends with a list of affected accounts is worth more than one that ends with a percentage.
Take the significance question elsewhere. June describes; it does not run a controlled-experiment test. Read the outcome from Optimizely's results page, or compute it from your own data, and use June for the account-level texture that a results page cannot show.
Troubleshooting
Symptom | Likely cause | Fix |
|---|---|---|
Traits on users, nothing on companies |
| Pass the company ID into the listener |
Every company appears in both arms | Bucketing is per user but a group trait is being written | Write the user trait only, or bucket on the account |
Events missing from short-lived processes | Batched client never flushed | Call |
Trait is null for some users | Flag disabled, so | Send |
Trait present but audience is empty | Audience filtering on the event rather than the trait | Audiences read traits; check which you targeted |
Counts exceed Optimizely's exposed users | Rollout decisions forwarded alongside experiment decisions | Filter on |
Request latency rose after launch | Synchronous HTTP inside the listener | Queue the payloads and post from a worker |
Company report splits one account in two | Two different | Normalise the company identifier at one place in your code |
Related Reading
Integrate Segment with Optimizely Feature Experimentation — the same identify, group and track shape, fanned out to many destinations at once.
Integrate Pendo with Optimizely Feature Experimentation — another account-aware product analytics tool, with guides layered on top.
Integrate PostHog with Optimizely Feature Experimentation — when the unit of analysis is the user rather than the account.