Integrate Firebase with Optimizely Feature Experimentation

Loading...·9 min read

Firebase gives an app team analytics, crash reporting, and messaging behind one SDK, and Google Analytics for Firebase is where the event data lands. Optimizely Feature Experimentation decides which variation of a flag each user receives, on web and in native apps alike. Forwarding those decisions into Firebase means every funnel, retention chart, and audience you already maintain can be broken down by the variation a user was actually served.

Firebase has its own experimentation product built on Remote Config, and this article is not about that. It is about using Firebase as the analytics destination for experiments you run in Optimizely — which is the situation you are in when the flag has to work identically on web and mobile, when the same flag is read by a backend service, or when Optimizely already owns targeting and results and Firebase is simply where your app's behavioural data lives.

Feature Experimentation exposes no Custom Analytics Integration UI, so the bridge is a decision notification listener: a callback that fires on every decide() and writes the variation into Firebase as a user property and an event.

How the Integration Works

Firebase models experiment membership best as a user property, because user properties are what audiences and comparison breakdowns read. The event is the complement: it is timestamped, so it tells you when the user entered the experiment, which the property alone cannot.

flowchart LR
    A["user.decide('checkout_redesign')"] --> B[DECISION notification fires]
    B --> C{Where does the listener run?}
    C -->|Web or native app| D["setUserProperties + logEvent"]
    C -->|Backend service| E[Measurement Protocol POST]
    D --> F[Firebase user property and event]
    E --> F
    F --> G[Audiences, funnels, retention by variation]
    F --> H[BigQuery export for raw analysis]

Decision Notification Data

The listener's decision info object carries these fields for a flag-type decision. The JavaScript 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

Naming Inside Firebase's Limits

Firebase enforces stricter naming than most analytics tools, and it enforces it silently — a rejected user property does not raise an error, it simply never appears. Three limits govern this integration:

Constraint

Limit

What it means here

User property name

Letters, digits and underscores; must start with a letter; short

Sanitize the flag key; do not send checkout-redesign

Custom user properties per project

Capped, and a registered property cannot be reused

Do not create one property per flag if you run many flags

Reserved prefixes

firebase_, google_, ga_

Prefix your own with something else, such as exp_

Because the per-project property budget is finite and permanent, decide early between two conventions. One property per flag (exp_checkout_redesign = treatment) gives the cleanest audience builder and burns a property slot per flag. One shared property (exp_active = checkout_redesign:treatment) never runs out but cannot express two concurrent experiments at once. Teams running a handful of long-lived flags should take the first; teams running dozens of short experiments should take the second and do the real analysis in the BigQuery export.

Prerequisites

  • Optimizely Feature Experimentation SDK for your platform — JavaScript SDK v6 or later for web, the mobile SDKs for native apps, or the Node.js or Python SDK on the server.

  • A Firebase project with Google Analytics enabled. Analytics is opt-in per project; if it was not enabled at creation, turn it on in the Firebase console before anything below will record.

  • The Firebase SDK initialized on the client, and getAnalytics() called once.

  • For server-side decisions: a Measurement Protocol API secret created in the Firebase or GA4 admin settings, your Firebase App ID, and a way to obtain the app instance ID of the client the decision belongs to.

  • BigQuery linking, if you intend to analyze results rather than only build audiences. The Firebase console's own reports are aggregated and sampled in ways that make small experiments hard to read; the raw export is not.

Web Implementation

On the web, the Optimizely client and Firebase share a page and a user. Register the listener before the first decision.

import { initializeApp } from 'firebase/app';
import { getAnalytics, logEvent, setUserProperties } from 'firebase/analytics';
import { createInstance, enums } from '@optimizely/optimizely-sdk';

const app = initializeApp({
  apiKey: '<YOUR_API_KEY>',
  projectId: '<YOUR_PROJECT_ID>',
  appId: '<YOUR_APP_ID>',
  measurementId: '<YOUR_MEASUREMENT_ID>',
});
const analytics = getAnalytics(app);

// Firebase rejects anything that is not letters, digits and underscores
function sanitize(key) {
  return key.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[^a-zA-Z]+/, '');
}

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, ruleKey } = decisionInfo;
      const value = enabled ? variationKey : 'off';

      // Sticky: every event from now on is attributable to this variation
      setUserProperties(analytics, { [`exp_${sanitize(flagKey)}`]: value });

      // Timestamped: this is when the user entered the experiment
      logEvent(analytics, 'experiment_viewed', {
        flag_key: flagKey,
        variation_key: value,
        rule_key: ruleKey,
      });
    },
  );

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

enabled ? variationKey : 'off' matters here more than elsewhere: variationKey is null for a disabled flag, and Firebase drops a null user property silently, leaving "not in the experiment" and "integration broken" looking identical in your reports.

Server-Side Decisions

Google Analytics for Firebase has no server SDK — the Firebase Admin SDK does not log analytics events. When the decision happens on your backend, send it with the Measurement Protocol, which accepts events for a Firebase app addressed by firebase_app_id and app_instance_id.

The app_instance_id is the client's own analytics instance identifier, read on the client and passed to your backend. Without it the event has no user to attach to.

const APP_ID = process.env.FIREBASE_APP_ID;
const API_SECRET = process.env.FIREBASE_MP_API_SECRET;
const ENDPOINT = `https://www.google-analytics.com/mp/collect?firebase_app_id=${APP_ID}&api_secret=${API_SECRET}`;

async function sendDecision(appInstanceId, decisionInfo) {
  const { flagKey, enabled, variationKey, ruleKey } = decisionInfo;
  const value = enabled ? variationKey : 'off';

  const body = {
    app_instance_id: appInstanceId,
    user_properties: {
      [`exp_${flagKey.replace(/[^a-zA-Z0-9_]/g, '_')}`]: { value },
    },
    events: [
      {
        name: 'experiment_viewed',
        params: { flag_key: flagKey, variation_key: value, rule_key: ruleKey },
      },
    ],
  };

  const response = await fetch(ENDPOINT, { method: 'POST', body: JSON.stringify(body) });

  // The collection endpoint returns 2xx for a malformed payload as readily as a
  // valid one. Validation happens at the /debug/mp/collect endpoint, not here.
  if (!response.ok) {
    console.error('measurement protocol transport error', response.status);
  }
}

That comment is the single most important line in this article. The production Measurement Protocol endpoint accepts almost anything and reports success; a misspelled reserved parameter or an over-long property name is dropped without a word. Point the same payload at the debug endpoint — the same URL with /debug/mp/collect in place of /mp/collect — while you are developing, and read the validationMessages array it returns.

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 rather than raising, so the events keep flowing with empty values.

import os

import requests
from optimizely import optimizely
from optimizely.helpers import enums

APP_ID = os.environ['FIREBASE_APP_ID']
API_SECRET = os.environ['FIREBASE_MP_API_SECRET']
ENDPOINT = f'https://www.google-analytics.com/mp/collect?firebase_app_id={APP_ID}&api_secret={API_SECRET}'


def sanitize(key):
    cleaned = ''.join(char if char.isalnum() or char == '_' else '_' for char in key)
    return cleaned.lstrip('_0123456789')


def send_decision(app_instance_id, flag_key, value, rule_key):
    payload = {
        'app_instance_id': app_instance_id,
        'user_properties': {f'exp_{sanitize(flag_key)}': {'value': value}},
        'events': [
            {
                'name': 'experiment_viewed',
                'params': {'flag_key': flag_key, 'variation_key': value, 'rule_key': rule_key},
            }
        ],
    }
    try:
        response = requests.post(ENDPOINT, json=payload, timeout=2)
        if response.status_code >= 400:
            print(f'measurement protocol transport error: {response.status_code}')
    except requests.RequestException as error:
        print(f'measurement protocol request 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')

    # The app instance id belongs to the client and must be carried through
    # your own request context; it is not something the server can invent.
    app_instance_id = (attributes or {}).get('app_instance_id')
    if not app_instance_id:
        return

    send_decision(app_instance_id, flag_key, variation_key if enabled else 'off', 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', 'app_instance_id': 'abc123'}
)
decision = user.decide('checkout_redesign')
print('Variation:', decision.variation_key)

Passing the app instance ID through the Optimizely user attributes, as above, is a convenient way to get it into the listener without threading a second argument through your call sites. It is not required — any request-scoped context works.

Verifying the Integration

Firebase reporting is delayed by hours, so verify against the realtime surfaces instead.

  1. Enable DebugView for your test client: on the web this means setting the debug flag Firebase documents for your SDK version; in native apps it is a launch argument.

  2. Trigger a decision.

  3. In the Firebase console, open Analytics > DebugView and confirm the experiment_viewed event appears with flag_key and variation_key parameters.

  4. Select the user in DebugView and confirm the exp_* user property is set on the right-hand panel.

  5. For server-side events, post the same payload to the debug Measurement Protocol endpoint and confirm validationMessages comes back empty.

Then wait. User properties apply to events recorded after they are set, never retroactively, and a newly registered custom property does not appear in the reporting UI until Firebase has processed it. A property that is present in DebugView but absent from a report the same afternoon is almost always this, not a bug.

Analyzing Experiments in Firebase

Build an audience per variation. In Analytics > Audiences, create an audience with the condition exp_checkout_redesign exactly matches treatment. Audiences are the unit Firebase reuses everywhere — funnel comparison, retention, and messaging targeting all accept them.

Compare retention. Retention is where a flag integration earns its keep, because it is the metric an experimentation tool measures worst and an app analytics tool measures best. Apply the two variation audiences to the retention report and read the curves side by side.

Do the real analysis in BigQuery. Link the project and query the raw export, where each event row carries its user_properties array. That is the only place you can compute a per-user first-exposure cohort and join it to whatever else you hold, without the aggregation the console applies.

One caution about reading experiment results in the Firebase console at all: the reports are built for describing an app's behaviour over time, not for comparing two arms of a controlled test. They aggregate, they threshold small numbers, and they offer no interval around any figure they display. Use them to see that the integration works and to explore where a variation changed behaviour; take the counts themselves to a significance test before you call an experiment. Optimizely's own results page is doing that work for the metrics you configured there, and the BigQuery export is where you do it for the ones you did not.

Identity Across Web and App

If the same person uses your web app and your native app, Firebase treats them as two analytics instances unless you tell it otherwise. Call the Firebase user-ID setter with the same identifier you pass to createUserContext() on both surfaces, and Optimizely and Firebase will agree on who received which variation. Skipping that step does not break anything visibly — it inflates your user counts and quietly splits one person's journey into two, which is the kind of error that survives a review because every individual number looks plausible.

Troubleshooting

Symptom

Likely cause

Fix

User property never appears

Name contains a hyphen or starts with a digit

Sanitize to letters, digits and underscores, starting with a letter

Property appears in DebugView but not in reports

Firebase applies properties to subsequent events only, and registers new ones with a delay

Wait; then check the property is registered in the console

Server events silently missing

Malformed Measurement Protocol payload accepted with a 2xx

Re-send to /debug/mp/collect and read validationMessages

Server events land on the wrong user

app_instance_id missing or reused

Read it on the client and pass it through per request

Property shows null for some users

Flag disabled, so variationKey is null

Send 'off' explicitly when enabled is false

Cannot create another custom property

The per-project budget is spent

Switch to one shared property, or free unused ones

Numbers disagree with Optimizely

Rollout decisions counted alongside experiment decisions

Filter on decisionEventDispatched before forwarding

Related Reading