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 call | GA4 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 |
dedupId | nothing |
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:
_gamust 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 persistsundefined, 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.