Integrate June with Optimizely Feature Experimentation

Loading...·8 min read

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

flagKey / flag_key

string

The flag key that was evaluated

enabled / enabled

boolean

Whether the flag is on for this user

variationKey / variation_key

string

The delivered variation key

ruleKey / rule_key

string

The experiment or delivery rule that matched

decisionEventDispatched / decision_event_dispatched

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 group calls carrying the same groupId your application uses for a workspace, and the same userId you pass to createUserContext().

  • 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

  1. Trigger a decision for a test user who belongs to a known company.

  2. In June, open the user's profile and confirm the trait flag_bulk_import shows the expected variation.

  3. Open the company and confirm the group trait is set — or confirm it is absent, if you deliberately bucket per user.

  4. Confirm Experiment Viewed appears in the event stream with its properties.

  5. Build an audience on the trait and confirm it returns the expected users or companies.

  6. 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

group never called, or called without a groupId

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 closeAndFlush() on shutdown

Trait is null for some users

Flag disabled, so variationKey is null

Send 'off' explicitly

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 decisionEventDispatched

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 groupId values for the same workspace

Normalise the company identifier at one place in your code

Related Reading