---
title: "Google Analytics 4"
description: "Report experiment exposures to Google Analytics 4 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/ga4"
---
# Google Analytics 4

> Report experiment exposures to Google Analytics 4 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 Google Analytics, your application tells Google Analytics which variation each user saw. You add a handler once, and you can then compare those users across the events you already send it.

Your application sends these events straight to Google Analytics, and ConfigDirector never holds your API secret.

## What Google Analytics reads

Google Analytics identifies a variation through an [`experience_impression` event](https://developers.google.com/analytics/devguides/collection/ga4/integration#events) with one parameter:

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

<tbody>
  <tr>
    <td>
      <code>
        exp_variant_string
      </code>
    </td>
    
    <td>
      <code>
        CD-
      </code>
      
      , the config key, a hyphen and the variation's name
    </td>
    
    <td>
      <code>
        CD-checkout-cta-copy-treatment-a
      </code>
    </td>
  </tr>
</tbody>
</table>

Google recommends that format, a tool ID, an experience ID and a variant ID joined by hyphens, so the value stays apart from other experiments and other tools. A value must be [100 characters or fewer](https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events#limitations) on a standard property and 500 or fewer on a Google Analytics 360 property.

## 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). The browser is the simpler of the two, because the Google tag on your site already knows the visitor:

<code-group>

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

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

      gtag("event", "experience_impression", {
        exp_variant_string: `CD-${evaluation.key}-${variationName}`,
      });
    },
  },
});

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

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

const collectUrl =
  "https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=YOUR-API-SECRET";

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

      fetch(collectUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          client_id: clientId,
          events: [
            {
              name: "experience_impression",
              params: { exp_variant_string: `CD-${evaluation.key}-${variationName}` },
            },
          ],
        }),
      }).catch((error) => console.error("Could not send the exposure to Google Analytics", error));
    },
  },
});

await client.initialize();

const buttonLabel = client.getValue("checkout-cta-copy", "Start free trial", {
  id: "user-42",
  traits: { gaClientId: "CLIENT-ID-FROM-THE-GOOGLE-TAG" },
});
```

</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 TypeScript, the `gtag` global needs the `@types/gtag.js` package.

From Node.js, the handler sends the event through the [Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events), which needs an API secret from your data stream and a `client_id` that matches the one the Google tag generated in the visitor's browser. A server cannot work that out from the context's `id`, so your application has to pass it along. The example passes it as a trait, and [traits show in the ConfigDirector dashboard](https://docs.configdirector.com/getting-started/core-concepts#contexts). A context without that trait is skipped.

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";

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

    gtag("event", "experience_impression", {
      exp_variant_string: `CD-${hookContext.flagKey}-${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";

const collectUrl =
  "https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=YOUR-API-SECRET";

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

    fetch(collectUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        client_id: clientId,
        events: [
          {
            name: "experience_impression",
            params: { exp_variant_string: `CD-${hookContext.flagKey}-${details.variant}` },
          },
        ],
      }),
    }).catch((error) => console.error("Could not send the exposure to Google Analytics", error));
  },
};

const client = OpenFeature.getClient();

const buttonLabel = await client.getStringValue(
  "checkout-cta-copy",
  "Start free trial",
  { targetingKey: "user-42", gaClientId: "CLIENT-ID-FROM-THE-GOOGLE-TAG" },
  { 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.

A config read on every render sends an event each time, and Google Analytics keeps each one as its own event. So compare variations by users rather than by event count: a user counts once in an audience, however many of these events they sent.

## Compare the variations

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

1. Right after you start the run, [create an audience](https://developers.google.com/analytics/devguides/collection/ga4/integration#audiences) for each variation: include users when an `experience_impression` event has an `exp_variant_string` that exactly matches, such as `CD-checkout-cta-copy-treatment-a`. Set its membership duration to cover the run.
2. Compare those audiences in your reports, over the dates of the run, which the [**Experiment** tab](https://docs.configdirector.com/getting-started/experiments#read-the-exposures) lists. A report shows up to four comparisons at once.
3. When the run ends, [archive the audiences](https://developers.google.com/analytics/devguides/collection/ga4/integration#archive-audiences) to free up space for your next ones.

The handler also runs outside the split: before the run starts, after you ship and when a conditional rule serves a variation. An audience [collects users from the moment you create it](https://support.google.com/analytics/answer/9267572), and that keeps out the users served the default targeting rule value before the run. It can take 24 to 48 hours to fill. A conditional rule above the split can still make the numbers higher than the **Exposures** card, which counts only the contexts the split served.

To explore further, [register `exp_variant_string` as an event-scoped custom dimension](https://support.google.com/analytics/answer/14239696) and use it in an [Exploration](https://developers.google.com/analytics/devguides/collection/ga4/integration#in-house-experiments). Google [notes this is less accurate than audiences](https://developers.google.com/analytics/devguides/collection/ga4/integration#reprocessing-experience-data), because it also shows a user's events from before and after the experience.

Keep each variation's name for the length of the run, because a new name makes a new `exp_variant_string` value, which your audiences would not match.

Chart markers from an [analytics integration](https://docs.configdirector.com/integrations/analytics-integrations) are not offered for this tool; they are for Amplitude, Mixpanel, Datadog, Grafana and PostHog.


## Sitemap

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