---
title: "Mixpanel"
description: "Report experiment exposures to Mixpanel from your application, so you can compare the variations next to the events you already track there."
canonical_url: "https://docs.configdirector.com/integrations/analytics/mixpanel"
---
# Mixpanel

> Report experiment exposures to Mixpanel from your application, so you can compare the variations next to the events you already track there.

## Introduction

To compare an [experiment](https://docs.configdirector.com/getting-started/experiments)'s variations in Mixpanel, your application tells Mixpanel which variation each user saw. You add a handler once, and Mixpanel joins those exposures to the events you already send it.

Nothing passes through ConfigDirector, and ConfigDirector never holds your Mixpanel project token.

## What Mixpanel reads

Mixpanel's Experiment report reads an [`$experiment_started` event](https://docs.mixpanel.com/docs/experiments#implementation-for-experimentation) with two properties:

<table>
<thead>
  <tr>
    <th>
      Property
    </th>
    
    <th>
      What you send
    </th>
    
    <th>
      Example
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        Experiment name
      </code>
    </td>
    
    <td>
      The config key
    </td>
    
    <td>
      <code>
        checkout-cta-copy
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        Variant name
      </code>
    </td>
    
    <td>
      The variation's name
    </td>
    
    <td>
      <code>
        treatment-a
      </code>
    </td>
  </tr>
</tbody>
</table>

## Add the handler

Add a handler for the `configEvaluated` hook where you create the client, in the [browser](https://docs.configdirector.com/sdks/browser/javascript#hooks) or in [Node.js](https://docs.configdirector.com/sdks/server/node-js#hooks). This handler sends each exposure once, because sending more [can raise your Mixpanel bill](#when-the-handler-runs):

<code-group>

```ts [Browser]
import { createClient } from "@configdirector/client-sdk";
import mixpanel from "mixpanel-browser";

mixpanel.init("YOUR-MIXPANEL-PROJECT-TOKEN");

const sentExposureKeys = new Set<string>();

export const client = createClient("YOUR-CLIENT-SDK-KEY", {
  hooks: {
    configEvaluated: ({ evaluation }) => {
      const variationName = evaluation.variation?.name;
      if (!variationName) return;

      const exposureKey = `${evaluation.context?.id}/${evaluation.key}/${variationName}`;
      if (sentExposureKeys.has(exposureKey)) return;
      sentExposureKeys.add(exposureKey);

      mixpanel.track("$experiment_started", {
        "Experiment name": evaluation.key,
        "Variant name": variationName,
      });
    },
  },
});

await client.initialize({ id: "user-42" });
```

```ts [Node.js]
import { createClient } from "@configdirector/server-sdk";
import { LRUCache } from "lru-cache";
import Mixpanel from "mixpanel";

const mixpanel = Mixpanel.init("YOUR-MIXPANEL-PROJECT-TOKEN");

const sentExposureKeys = new LRUCache<string, true>({ max: 100_000 });

export const client = createClient("YOUR-SERVER-SDK-KEY", {
  hooks: {
    configEvaluated: ({ evaluation }) => {
      const variationName = evaluation.variation?.name;
      const userId = evaluation.context?.id;
      if (!variationName || !userId) return;

      const exposureKey = `${userId}/${evaluation.key}/${variationName}`;
      if (sentExposureKeys.has(exposureKey)) return;
      sentExposureKeys.set(exposureKey, true);

      mixpanel.track("$experiment_started", {
        distinct_id: userId,
        "Experiment name": evaluation.key,
        "Variant name": variationName,
      });
    },
  },
});

await client.initialize();
```

</code-group>

Only an experiment's evaluations carry a variation, so the check skips every other config, along with the in-code default value. It also skips a variation without a name, so [name each one](https://docs.configdirector.com/getting-started/experiments#create-the-experiment).

In the browser, Mixpanel attaches the exposure to the user you have identified to it. In Node.js, the handler passes the context's `id` as the `distinct_id`, so it has to be the ID your other Mixpanel events use. A context without an `id` is skipped, since it does not take part in the split on a server.

The handler needs version 1.11.0 or later of the JavaScript SDK, or 1.13.0 or later of the Node.js SDK. React, Vue and React Native take the same handler in their `hooks`. The [Experiments guide](https://docs.configdirector.com/getting-started/experiments#send-exposures-to-your-analytics-tool) lists the hook for every other SDK.

## With OpenFeature

In an OpenFeature application, a hook does the same job, with the `variant` as the variation's name. Every config has a variant, not only experiments: on any other config it is ConfigDirector's ID for the value. So attach the hook to the reads of your experiments instead of registering it for every read:

<code-group>

```ts [Web]
import { OpenFeature, type Hook } from "@openfeature/web-sdk";
import mixpanel from "mixpanel-browser";

mixpanel.init("YOUR-MIXPANEL-PROJECT-TOKEN");

const sentExposureKeys = new Set<string>();

const reportExposure: Hook = {
  after: (hookContext, details) => {
    if (!details.variant) return;

    const exposureKey = `${hookContext.context.targetingKey}/${hookContext.flagKey}/${details.variant}`;
    if (sentExposureKeys.has(exposureKey)) return;
    sentExposureKeys.add(exposureKey);

    mixpanel.track("$experiment_started", {
      "Experiment name": hookContext.flagKey,
      "Variant name": details.variant,
    });
  },
};

const client = OpenFeature.getClient();

const buttonLabel = client.getStringValue("checkout-cta-copy", "Start free trial", {
  hooks: [reportExposure],
});
```

```ts [Node.js]
import { OpenFeature, type Hook } from "@openfeature/server-sdk";
import { LRUCache } from "lru-cache";
import Mixpanel from "mixpanel";

const mixpanel = Mixpanel.init("YOUR-MIXPANEL-PROJECT-TOKEN");

const sentExposureKeys = new LRUCache<string, true>({ max: 100_000 });

const reportExposure: Hook = {
  after: (hookContext, details) => {
    const userId = hookContext.context.targetingKey;
    if (!details.variant || !userId) return;

    const exposureKey = `${userId}/${hookContext.flagKey}/${details.variant}`;
    if (sentExposureKeys.has(exposureKey)) return;
    sentExposureKeys.set(exposureKey, true);

    mixpanel.track("$experiment_started", {
      distinct_id: userId,
      "Experiment name": hookContext.flagKey,
      "Variant name": details.variant,
    });
  },
};

const client = OpenFeature.getClient();

const buttonLabel = await client.getStringValue(
  "checkout-cta-copy",
  "Start free trial",
  { targetingKey: "user-42" },
  { hooks: [reportExposure] },
);
```

</code-group>

A variation without a name also has ConfigDirector's ID for the value as its variant, so name each one. The hook needs version 1.11.0 or later of the [Web provider](https://docs.configdirector.com/sdks/openfeature/web#evaluation-details-and-hooks), or 1.13.0 or later of the [Node.js provider](https://docs.configdirector.com/sdks/openfeature/node-js#evaluation-details-and-hooks).

## When the handler runs

The handler runs once per evaluation, whenever your application reads the config. So read an experiment where the user reaches the change rather than at startup, or users who never see it count as exposed.

Mixpanel [only needs an exposure the first time a user sees a variation](https://docs.mixpanel.com/docs/experiments#when-to-track-an-exposure-event), and sending more can cost you. It [meters experiments](https://docs.mixpanel.com/docs/experiments/exposure-event-metering) by the distinct users exposed each month, but once your users average more than 30 exposure events a month, it [divides your total exposure events by 30 instead](https://docs.mixpanel.com/docs/experiments#does-it-matter-how-many-experiments-a-user-is-exposed-to-within-the-month). A config read on every render would get there quickly, which is why the handler checks what it has already sent:

- In the browser, `sentExposureKeys` holds one entry per variation the page reads for each user, and it empties when the page loads again. So a site that reloads on every navigation sends once per page, and a single-page app sends once until the next reload. To send less, keep the entries in `localStorage` and remove them when the run ends.
- In Node.js, each server process remembers the latest 100,000 it has sent. A user who drops out of that cache, or who evaluates after a restart, is sent once more, which does not change your results.

## Compare the variations

The outcomes you compare are the events your application already sends to Mixpanel, so there is nothing more to add to your code.

The [Experiment report](https://docs.mixpanel.com/docs/experiments) compares them per variation, and it is available on Mixpanel's Free, Growth and Enterprise plans. Click **New Experiment** in the report menu and pick your config key from the dropdown, which lists the experiments that received an exposure in the last 30 days. Mixpanel then counts a user's later events toward the variation they were exposed to.

The handler also runs outside the split: before the run starts, after you ship and when a conditional rule serves a variation. So set the report to the dates of the run, which the [**Experiment** tab](https://docs.configdirector.com/getting-started/experiments#read-the-exposures) lists. A conditional rule above the split can still make Mixpanel's numbers higher than the **Exposures** card, which counts only the contexts the split served.

Keep each variation's name for the length of the run, because Mixpanel treats a new name as a new variant.

To mark each run's start, split change, ship and stop on these charts without code, set up a [Mixpanel integration](https://docs.configdirector.com/integrations/analytics-integrations#mixpanel).


## Sitemap

See the full [sitemap](https://docs.configdirector.com/sitemap.md) for all pages.
