Integrate LogRocket with Optimizely Feature Experimentation

Loading...·8 min read

LogRocket records what happened in a user's browser: a replayable session, the network requests behind it, the console output, the Redux or state transitions, and the performance timings. Optimizely Feature Experimentation decides which variation of a flag that user was served. Connect the two and a replay stops being an anecdote — you can filter to the sessions of users who got the new checkout and watch the twelve of them who rage-clicked their way out of it.

That is a different kind of evidence from an experiment result. A results page tells you conversion fell by four percent and cannot tell you why. Twenty minutes of replay filtered to the losing arm usually can, and the answer is frequently something nobody instrumented: a validation message that appears off-screen, a spinner that never resolves on slow connections, a button that moved under the thumb on small phones.

Feature Experimentation has no Custom Analytics Integration UI, so the bridge is a decision notification listener — a callback that fires on every decide() — writing the variation into LogRocket as a user trait and a tracked event.

How the Integration Works

LogRocket records in the browser, so the browser is where the variation must be attached. Everything else in this article exists to get a server-side decision back to the browser session it belongs to.

flowchart LR
    A["user.decide('checkout_redesign')"] --> B[DECISION notification fires]
    B --> C{Where was the decision made?}
    C -->|Browser| D["LogRocket.identify traits"]
    C -->|Server| E[Variation sent to the client]
    E --> D
    D --> F["LogRocket.track event"]
    F --> G[Sessions filterable by variation]
    G --> H[Replay the losing 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

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

Traits Versus Track Events

LogRocket gives you two places to put the variation, and the integration below uses both because they are filtered in different parts of the product.

User trait

Tracked event

Set with

LogRocket.identify(id, traits)

LogRocket.track(name)

Scope

The whole session, from the moment it is set

A point in the session timeline

Filters

Session search, saved session filters

Event-based conditions and alerts

Best for

"Show me every session in the treatment arm"

"Show me where in the session they entered it"

Traits are the workhorse. Track events matter when a user can enter an experiment part-way through a session, because a trait set at minute nine still describes the whole recording, and a timeline marker is the only thing that tells you the first eight minutes happened under the old experience.

Prerequisites

  • Optimizely Feature Experimentation SDK for your platform — the JavaScript SDK v6 or later in the browser, and the Node.js or Python SDK if decisions are made server-side.

  • LogRocket initialized in the browser with your app ID, before the Optimizely listener fires. A trait set before LogRocket.init() is lost.

  • A stable user identifier shared by both tools. The ID passed to LogRocket.identify() should be the same one passed to createUserContext(), or the sessions you filter will not be the users the experiment bucketed.

  • A route for server-side decisions to reach the browser if your flags are evaluated on the server — typically the variation embedded in the page's bootstrap payload or returned by an API the client already calls.

Browser Implementation

import LogRocket from 'logrocket';
import { createInstance, enums } from '@optimizely/optimizely-sdk';

LogRocket.init('<YOUR_ORG>/<YOUR_APP>');

const optimizely = createInstance({ sdkKey: '<YOUR_SDK_KEY>' });

// Traits are merged, not replaced, so accumulate them for concurrent flags
const experimentTraits = {};

optimizely.onReady().then(() => {
  optimizely.notificationCenter.addNotificationListener(
    enums.NOTIFICATION_TYPES.DECISION,
    ({ type, userId, decisionInfo }) => {
      if (type !== 'flag') return;

      const { flagKey, enabled, variationKey, ruleKey } = decisionInfo;
      const value = enabled ? variationKey : 'off';

      experimentTraits[`flag_${flagKey}`] = value;

      // Session-wide: this is what session search filters on
      LogRocket.identify(userId, experimentTraits);

      // Timeline marker: this is when the user entered the experiment
      LogRocket.track('Experiment Viewed');

      // Optional: correlate the replay with your own logs and error reports
      LogRocket.getSessionURL((sessionURL) => {
        console.log('[optimizely]', flagKey, value, ruleKey, sessionURL);
      });
    },
  );

  const user = optimizely.createUserContext('user_123', { plan: 'pro' });
  const decision = user.decide('checkout_redesign');
  console.log('Variation:', decision.variationKey);
});

Two details are load-bearing. Traits are merged into the session rather than replacing what is there, but only what you pass in a given call is written — so accumulating them in experimentTraits is what keeps a second flag from appearing to erase the first in your own reasoning about the code. And enabled ? variationKey : 'off' keeps a disabled flag legible: variationKey is null when a flag is off, and a null trait tells you nothing about whether the user was excluded or the integration broke.

Server-Side Decisions

LogRocket ingests from the browser. There is no server-side event API to post a decision to, so a decision made in Node or Python has to reach the client to be recorded — and the shape that works is to send the variation down with whatever the page already fetches.

const express = require('express');
const optimizelySdk = require('@optimizely/optimizely-sdk');

const optimizely = optimizelySdk.createInstance({ sdkKey: process.env.OPTIMIZELY_SDK_KEY });
const app = express();

app.get('/api/bootstrap', async (req, res) => {
  await optimizely.onReady();

  const context = optimizely.createUserContext(req.user.id, { plan: req.user.plan });
  const decisions = context.decideForKeys(['checkout_redesign', 'new_pricing_table']);

  // Only what the client needs to record: flag key and delivered variation
  const experiments = Object.values(decisions).reduce((accumulator, decision) => {
    accumulator[decision.flagKey] = decision.enabled ? decision.variationKey : 'off';
    return accumulator;
  }, {});

  res.json({ user: { id: req.user.id, plan: req.user.plan }, experiments });
});

On the client, apply whatever arrives:

async function applyServerDecisions() {
  const { user, experiments } = await fetch('/api/bootstrap').then((response) => response.json());

  const traits = {};
  for (const [flagKey, variation] of Object.entries(experiments)) {
    traits[`flag_${flagKey}`] = variation;
  }

  LogRocket.identify(user.id, traits);
  LogRocket.track('Experiment Viewed');
}

Send only the flag key and the variation. It is tempting to forward the whole decision object, including user attributes, and those attributes routinely contain the plan, the country, sometimes an email — all of which then sit in a session recording that a support agent can open.

Python Implementation

A Python backend cannot call LogRocket either, so its listener does two useful things instead: it records the decision in your own logs against the LogRocket session URL the client sent up, and it makes the variation available to the response the client will act on.

import logging
import os

from optimizely import optimizely
from optimizely.helpers import enums

logger = logging.getLogger('experiments')

# Request-scoped store; in a real service this is your framework's request
# context, not a module global.
current_request = {}


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')
    dispatched = decision_info.get('decision_event_dispatched')

    value = variation_key if enabled else 'off'

    # The client posts LogRocket.getSessionURL() up with its requests; logging it
    # next to the decision is what lets you jump from a log line to the replay.
    session_url = current_request.get('logrocket_session_url', 'unknown')

    logger.info(
        'decision user=%s flag=%s variation=%s rule=%s dispatched=%s replay=%s',
        user_id,
        flag_key,
        value,
        rule_key,
        dispatched,
        session_url,
    )

    current_request.setdefault('experiments', {})[flag_key] = value


optimizely_client = optimizely.Optimizely(sdk_key=os.environ['OPTIMIZELY_SDK_KEY'])
optimizely_client.notification_center.add_notification_listener(
    enums.NotificationTypes.DECISION, on_decision
)

current_request['logrocket_session_url'] = 'https://app.logrocket.com/org/app/s/abc123/'
user = optimizely_client.create_user_context('user_123', {'plan': 'pro'})
decision = user.decide('checkout_redesign')
print('Variation:', decision.variation_key)

The pay-off is the reverse lookup. When an exception fires in a backend request, the log line already carries both the variation and the replay URL, so an on-call engineer can watch what the user was doing when the server failed, without first working out who the user was.

Privacy and Redaction

A session recorder captures the screen, so the privacy question is sharper here than for an analytics integration. Two rules keep this bridge from widening what LogRocket holds. First, forward the flag key and the variation key and nothing else: those are internal identifiers you chose, and neither describes the person. Second, do not use targeting attributes as trait values — plan, country and signup_cohort may be legitimate for Optimizely to bucket on and still be more than you want attached to a recording that support, design and engineering can all open. If a variation genuinely needs a user attribute to be interpretable, add it as a coarse bucket you define rather than the raw value.

Verifying the Integration

  1. Load your application as a test user and trigger a decision.

  2. Open the session in LogRocket — LogRocket.getSessionURL() gives you the direct link.

  3. Confirm the User panel shows flag_checkout_redesign with the expected variation.

  4. Confirm an Experiment Viewed event appears on the session timeline at the moment of the decision, not at the start of the session.

  5. In session search, filter on the trait and confirm your session is returned.

  6. Repeat with the flag turned off and confirm the trait reads off rather than being absent.

If the trait is missing entirely, the usual cause is ordering: LogRocket.init() must run before the first identify(), and the Optimizely listener must be registered before the first decide().

Analyzing Experiments in LogRocket

Save a filter per variation. Build a session filter on flag_checkout_redesign = treatment and save it. Do the same for the control. Two saved filters turn "watch some sessions" into a repeatable comparison you can hand to a designer.

Sort the losing arm by frustration. LogRocket surfaces rage clicks, dead clicks and errors. Applied to the treatment filter, they rank the sessions most likely to show you the problem, which is far more efficient than watching recordings in chronological order.

Read the funnel drop-off, then watch it. Where a conversion metric fell, filter to sessions in the losing variation that reached the step before the drop and did not complete it. Five of those usually settle a debate that a week of dashboard staring will not.

Compare performance between arms. LogRocket captures network timings, so a new variation that added a slow third-party request is visible directly, rather than inferred from a latency regression somewhere in your monitoring.

Troubleshooting

Symptom

Likely cause

Fix

No trait on the session

identify() called before LogRocket.init()

Initialize LogRocket first, then register the Optimizely listener

Trait present, sessions not filterable

Filtering on an event rather than a user trait

Session search matches traits; check which surface you are searching

Second flag appears to overwrite the first

Only the traits passed in the latest call are written

Accumulate traits in one object and pass all of them

Trait missing when the flag is off

variationKey is null for a disabled flag

Send 'off' explicitly

Server-side decisions never appear

LogRocket has no server ingest path

Return the variation to the client and identify there

Sessions attributed to the wrong user

Different identifiers in LogRocket and Optimizely

Use one user ID for both

Personal data visible in the session

The whole decision payload was forwarded

Send only flag key and variation

More sessions than Optimizely reports users

Rollout decisions included

Filter on decisionEventDispatched before forwarding

Related Reading