tracklane
Providers

Google Analytics 4

What this library does with GA4, and what it deliberately does not

This page covers what is ours: what you configure, what we translate, and the traps that cost us a day to find. For what each GA4 parameter means, go to Google's own reference. We do not restate it here.

Browser

Put Google's tag on the page first, the way Google documents it: a snippet in your HTML or a tag manager. This library talks to the tag that is there; it does not inject scripts into your page and does not configure your property.

Two things about that snippet matter to this library, and only two: it must define gtag and configure your property before createTracking runs, and if you declare initial consent, that declaration belongs in the snippet, since Google requires it before the property is configured, and by the time you hold the object createTracking returns, that moment has passed.

import { createTracking, ga4 } from 'tracklane/browser';

const { track, identify, consent } = createTracking({
  providers: [ga4('G-XXXXXXX')],
});

The measurement id must match the tag on the page. If the tag is missing, calls fail and are reported through onError, the same thing a hand-written gtag(…) would do.

This library handles every consent declaration after the initial one, through consent().

Optional configuration

ga4('G-XXXXXXX', { events: { my_event: 'my_ga4_name' } });

Rarely needed: GA4's names are the canonical vocabulary, so events pass through unchanged.

What maps where

You callGA4 receives
track(name, data)gtag('event', name, data), always addressed to your property
identify({ userId })gtag('set', { user_id })
identify(user, traits)gtag('set', 'user_properties', traits)
consent(command, state)gtag('consent', command, state), verbatim
dedupIdnothing

The consent vocabulary is Google's own spelling, so nothing is translated and no signal you did not declare can be invented on your behalf.

dedupId maps nowhere because GA4 documents no browser-to-server deduplication. Inventing a parameter for it would imply a merge the platform never performs. Use transaction_id (which is business data) to control duplicate purchases.

Server

Create the API secret first: in Google Analytics, Admin → Data Streams → your stream → Measurement Protocol API secrets. It is a credential. Keep it out of your client bundle, which is one of the reasons the server entry point is separate.

import { createTracking, ga4 } from 'tracklane/server';

const { track } = createTracking({
  providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret: process.env.GA4_API_SECRET })],
});

await track('purchase', order, {
  cookies: request.headers.get('cookie'),
  user: { userId: order.userId },
  timestamp: order.paidAt,
});

Pass the cookies. GA4's server API needs both a visitor id and a session id, and both live in cookies its own browser tag has already set. The library reads them for you.

Without the visitor cookie the library refuses to send and tells you why: there is no valid request to make, and the endpoint would have answered success anyway. Without the session cookie it sends and warns, because the event will be accepted and appear in no report.

Wire up onError before you rely on any of this. It is optional in the type and it is not optional in practice: without it the library is silent, exactly as a hand-written fetch would be, and a conversion dropped for a missing cookie tells nobody.

When there is no browser: webhooks, and visitors without cookies

cookies reads like "the cookies on this request", and for a route handler it is. For a payment webhook firing hours later there is no request to read from — and the same gap appears for visitors behind an ad blocker, inside an in-app webview, or under a consent answer that kept the tag from ever running.

The answer is to store the two values while the browser has them and hand them back later:

// The container suffix is your measurement id without the `G-`. It is not the
// numeric "Stream ID" the GA4 interface shows you.
const container = measurementId.replace(/^G-/, '');

// In the browser, at a moment the session exists: checkout start, sign-in, add to cart.
saveWithTheOrder({ ga: cookies._ga, gaSession: cookies[`_ga_${container}`] });

// In the webhook, later, with no browser in sight.
await track('purchase', order, {
  cookies: { _ga: order.ga, [`_ga_${container}`]: order.gaSession },
  user: { userId: order.userId },
  timestamp: order.paidAt,
});

Two details decide whether this works, and both fail silently:

  • _ga must be the whole cookie value, GA1.<n>.<a>.<b>, not the client id on its own. The adapter parses the cookie's shape, and a stripped value fails the same way a missing one does.
  • The session cookie's suffix is the measurement id with G- removed. The "Stream ID" in the GA4 admin is a different, numeric thing. Storing that one persists undefined, and you get an event that is accepted and appears nowhere.

What not to do instead: generate a client_id when the cookie is absent. The Measurement Protocol accepts any string, so it looks like it works, and it does not. A request with no _ga has no session cookie either, and an event with no session is accepted and shows up in no report — so you get a 204, a satisfied warning, and no measurement. Making it visible means generating a session too, and then every send is a brand new unattributed user, permanently inflating the user count of your property. A gap you can see beats a number you cannot trust, which is why this library will not invent either value for you.

The traps

GA4 is unusual among the vendors here: it fails by accepting. A 204 means the request arrived, not that the event exists. Four different mistakes all look identical to success, and we hit three of them:

  • Sending from a headless browser. Google discards known bots by user agent, silently. If you verify this integration in automation, override the user agent or you will be measuring nothing.
  • A missing session cookie on the server path. Accepted, then absent from every report.
  • Person properties in the wrong shape on the server path. The collection endpoint accepts them; the validation endpoint rejects them. The library sends the shape that validates.

The practical rule: to confirm anything about GA4, use the Realtime report. The network tab and the status code tell you almost nothing.

Not included

Consent gating, queues, retries, and any decision about whether an event should be sent. See what this library is not.

On this page