GA4
Decoder transformer that turns Google Analytics 4 Measurement Protocol v2 hits (/g/collect, /mp/collect) into walkerOS events. Drop it in a server source's before chain to ingest traffic from an existing gtag/Google Tag setup: you point the tag at your own collector, and your gtag('event', ...) calls stay as they are. One HTTP request can carry many GA4 events; the transformer returns one walkerOS event per GA4 event in the hit.
The scope: server-side decoding in front of a server source, GA4 v2 only. See Caveats and Roadmap for the boundaries.
Installation
npm install @walkeros/transformer-ga4Send gtag hits to your collector
gtag.js sends GA4 hits to /g/collect on the host it is configured with. To send them to your walkerOS collector instead of Google, set server_container_url on the GA4 config to your collector's origin:
gtag('config', 'G-XXXXXXXXXX', {
server_container_url: 'https://collect.example.com',
});
Hits then arrive at https://collect.example.com/g/collect. If the URL carries a path (for example https://example.com/metrics), gtag.js appends /g/collect to it, and that full path is what the server source must listen on. Hits go to your collector instead of Google, so GA4 only receives what your flow forwards.
If gtag runs through the walkerOS gtag destination, set the same value in its settings: settings.ga4.server_container_url (or settings.ga4.transport_url), which it passes to the config command.
The request URL, method, and body format described on this page are gtag.js behaviour, not a walkerOS contract, and can change with gtag.js releases. Confirm them in your browser's Network tab (filter for collect) before you rely on them.
Wire it up
The transformer reads ctx.ingest.url (required) and ctx.ingest.body (optional) from the source it sits in front of. gtag.js can send several events in one POST body, so the source must keep a body that is not JSON as ingest.body. These server sources do, so pick by runtime:
@walkeros/server-source-express, the sourcewalkeros runand thewalkeros/flowDocker image serve. Atext/plainbody that is valid JSON is parsed, and any othertext/plainbody reaches the transformer as the raw string.@walkeros/server-source-fetch, for a runtime that serves a(Request) => Responsehandler (Cloudflare Workers, Deno, Bun, or Node.js 18+ with a fetch adapter). See Batched POST hits for the wiring.@walkeros/server-source-gcp(sourceCloudFunction) on Google Cloud Functions.@walkeros/server-source-aws(sourceLambda) on AWS Lambda. It decodes batched POSTs and GET hits, but answers a POST without a body with 400, so single-event hits sent that way are lost.
The example below uses the express source. It listens on /collect by default, so set paths to the path gtag.js sends to. Without it, GA4 hits get a 404:
- Bundled
- Integrated
{
"version": 4,
"flows": {
"default": {
"config": { "platform": "server" },
"sources": {
"http": {
"package": "@walkeros/server-source-express",
"config": {
"settings": {
"port": 8080,
"paths": ["/g/collect"]
},
"ingest": {
"map": {
"url": { "key": "url" },
"path": { "key": "path" },
"method": { "key": "method" },
"body": { "key": "body" }
}
}
},
"before": "ga4"
}
},
"transformers": {
"ga4": { "package": "@walkeros/transformer-ga4" }
},
"destinations": {
"log": { "package": "@walkeros/destination-demo" }
}
}
}
}
import { startFlow } from '@walkeros/collector';
import { sourceExpress } from '@walkeros/server-source-express';
import { transformerGa4 } from '@walkeros/transformer-ga4';
import { destinationDemo } from '@walkeros/destination-demo';
await startFlow({
sources: {
http: {
code: sourceExpress,
config: {
settings: {
port: 8080,
paths: ['/g/collect'],
},
ingest: {
map: {
url: { key: 'url' },
path: { key: 'path' },
method: { key: 'method' },
body: { key: 'body' },
},
},
},
before: 'ga4',
},
},
transformers: {
ga4: { code: transformerGa4 },
},
destinations: {
log: { code: destinationDemo },
},
});
Ingest contract
The transformer expects the source to populate ctx.ingest with these keys:
| Key | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Full request URL including the query string. |
path | string | no | Request path. Use it to gate the before chain when the source also receives other traffic. |
body | string | no | Raw POST body. Multi-event batches are \n-separated lines. |
config.ingest must use the map operator with direct field paths on the request scope (no req. prefix), as shown above. A bare object like { "url": "req.url" } is silently inert: without an operator no field is extracted, ctx.ingest stays empty, and the transformer drops the hit.
If url is missing or not a string the transformer drops the event silently.
GA4 hits and walkerOS events on one source
With before: "ga4" every request on the source runs through the decoder, and a request without GA4 parameters is dropped. To receive walkerOS events on the same source, listen on both paths and gate the chain on ingest.path:
"http": {
"package": "@walkeros/server-source-express",
"config": {
"settings": { "port": 8080, "paths": ["/collect", "/g/collect"] },
"ingest": {
"map": {
"url": { "key": "url" },
"path": { "key": "path" },
"method": { "key": "method" },
"body": { "key": "body" }
}
}
},
"before": {
"match": { "key": "ingest.path", "operator": "eq", "value": "/g/collect" },
"next": "ga4"
}
}
Batched POST hits
gtag.js sends a single event as a POST with the parameters in the query string and no body, and several events as one POST whose body holds one event per line (check this in the Network tab, as above). The body arrives as text/plain, and @walkeros/server-source-express keeps a text/plain body that is not JSON as ingest.body, so the express example above decodes GET hits, body-less POST hits, and batched POST hits alike.
@walkeros/server-source-fetch, sourceCloudFunction (@walkeros/server-source-gcp), and sourceLambda (@walkeros/server-source-aws) also keep a body that is not JSON as ingest.body, so batched hits decode there with the same ingest and before settings (the Lambda source rejects a POST without a body, see above). The fetch source also takes paths, but has no port setting: it is a (Request) => Response handler you hand to your runtime. On Cloudflare Workers:
import { startFlow } from '@walkeros/collector';
import { sourceFetch } from '@walkeros/server-source-fetch';
import { transformerGa4 } from '@walkeros/transformer-ga4';
import { destinationDemo } from '@walkeros/destination-demo';
const { collector } = await startFlow({
sources: {
http: {
code: sourceFetch,
config: {
settings: { paths: ['/g/collect'] },
ingest: {
map: {
url: { key: 'url' },
path: { key: 'path' },
method: { key: 'method' },
body: { key: 'body' },
},
},
},
before: 'ga4',
},
},
transformers: {
ga4: { code: transformerGa4 },
},
destinations: {
log: { code: destinationDemo },
},
});
export default { fetch: collector.sources.http.push };
walkeros run and the walkeros/flow Docker image only serve a source that exposes a Node HTTP handler, which the fetch source does not, so a bundled flow with the fetch source needs a runtime that calls its push with each Request.
Configuration
This transformer uses the standard transformer config wrapper (consent, data, env, id, ...). For the shared fields see transformer configuration. Package-specific fields live under config.settings and are listed below.
Settings
| Property | Type | Description | More |
|---|---|---|---|
mapping | Record<string, object> | Mapping rules keyed by GA4 event name (`en`), `'*'` for unknown events. A rule replaces the matching default unless it sets `extend` or `remove`; `ignore: true` drops the event. | |
tidPattern | string | Regex string the tracking ID (`tid`) must match, compiled once at init. Default `^G-`, which drops Ads (`AW-`) and DC (`DC-`) hits. | |
maxEvents | integer | Maximum number of events (POST body lines) decoded from one request. A request above the cap is dropped whole. Default 100. |
Mapping
This package does not define custom rule-level settings. For the standard rule fields (consent, condition, data, batch, name, policy) see mapping.
Examples
Add to cart
A GA4 add_to_cart hit decoded to a walkerOS product add event with currency and value.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&en=add_to_cart&ep.currency=EUR&epn.value=129.99"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "product add",
"entity": "product",
"action": "add",
"data": {
"currency": "EUR",
"value": 129.99
}
}Batched POST (fan-out)
A single POST request carrying two newline-separated events fans out into two walkerOS events.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000",
"body": "en=add_to_cart&ep.currency=EUR&epn.value=19.99\nen=add_to_cart&ep.currency=EUR&epn.value=29.99"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "product add",
"entity": "product",
"action": "add",
"data": {
"currency": "EUR",
"value": 19.99
}
};
return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "product add",
"entity": "product",
"action": "add",
"data": {
"currency": "EUR",
"value": 29.99
}
}Begin checkout
A GA4 begin_checkout hit decoded to a walkerOS order start event with currency, value, and coupon.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&en=begin_checkout&ep.currency=EUR&epn.value=149.97&ep.coupon=WELCOME10"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "order start",
"entity": "order",
"action": "start",
"data": {
"currency": "EUR",
"value": 149.97,
"coupon": "WELCOME10"
}
}Consent denied (gcs=G100)
A page_view hit with gcs=G100 still maps, with consent.{marketing,analytics} both false on the resulting event.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&gcs=G100&en=page_view&dl=https%3A%2F%2Fx&dt=X"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {
"marketing": false,
"analytics": false
},
"name": "page view",
"entity": "page",
"action": "view",
"data": {
"id": "https://x",
"title": "X"
}
}Custom event (* fallback)
Unknown GA4 event names hit the * fallback rule and surface as a ga4 track event carrying the original name.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&en=newsletter_subscribe&ep.source=footer"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "ga4 track",
"entity": "ga4",
"action": "track",
"data": {
"event_name": "newsletter_subscribe"
}
}Login
A GA4 login hit decoded to a walkerOS session login event with the auth method.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&en=login&ep.method=google"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "session login",
"entity": "session",
"action": "login",
"data": {
"method": "google"
}
}Page view
A standard GA4 page_view hit decoded to a walkerOS page view with id, title, and referrer.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&en=page_view&dl=https%3A%2F%2Fshop.example.com%2Fproducts%2Fsku-123&dt=Trail%20Runner%20Pro&dr=https%3A%2F%2Fshop.example.com%2F"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "page view",
"entity": "page",
"action": "view",
"data": {
"id": "https://shop.example.com/products/sku-123",
"title": "Trail Runner Pro",
"referrer": "https://shop.example.com/"
}
}Purchase (canary)
A GA4 purchase hit decoded to a walkerOS order complete event with id, currency, total, tax, shipping, and coupon.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&en=purchase&ep.transaction_id=T-9001&ep.currency=EUR&epn.value=149.97&epn.tax=23.97&epn.shipping=4.95&ep.coupon=WELCOME10"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "order complete",
"entity": "order",
"action": "complete",
"data": {
"id": "T-9001",
"currency": "EUR",
"total": 149.97,
"tax": 23.97,
"shipping": 4.95,
"coupon": "WELCOME10"
}
}Scroll
A GA4 scroll hit decoded to a walkerOS page scroll event with the percent_scrolled value.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&en=scroll&epn.percent_scrolled=90"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "page scroll",
"entity": "page",
"action": "scroll",
"data": {
"percent": 90
}
}Search
A GA4 search hit decoded to a walkerOS search submit event carrying the search term.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&en=search&ep.search_term=trail%20runner"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "search submit",
"entity": "search",
"action": "submit",
"data": {
"term": "trail runner"
}
}user_engagement (ignored)
Auto-fired GA4 user_engagement events are dropped by default — the transformer returns false.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&en=user_engagement&_et=1500"
}return falseView item
A GA4 view_item hit decoded to a walkerOS product view event with currency and value.
{
"url": "https://www.google-analytics.com/g/collect?v=2&tid=G-EXAMPLE&_p=p1&cid=cid-1&sid=1700000000&en=view_item&ep.currency=EUR&epn.value=129.99"
}return {
"id": "p1",
"timestamp": 1700000000000,
"timing": 0,
"trigger": "ga4",
"user": {
"device": "cid-1",
"session": "1700000000"
},
"globals": {},
"source": {
"type": "ga4"
},
"consent": {},
"name": "product view",
"entity": "product",
"action": "view",
"data": {
"currency": "EUR",
"value": 129.99
}
}Default mappings
transformer-ga4 ships with default mappings for 33 standard GA4 event names. Out of the box, you get pageviews, ecommerce, list/promotion, engagement, and auth events mapped to walkerOS's entity-action naming.
Page / scroll / click
GA4 (en) | walkerOS (name) | Fields |
|---|---|---|
page_view | page view | id, title, referrer |
scroll | page scroll | percent |
click | link click | url, domain, outbound |
file_download | file download | name, extension, url |
Ecommerce
GA4 (en) | walkerOS (name) | Fields |
|---|---|---|
view_item | product view | currency, value |
add_to_cart | product add | currency, value |
remove_from_cart | product remove | currency, value |
view_cart | cart view | currency, value |
begin_checkout | order start | currency, value, coupon |
add_shipping_info | order shipping | currency, value, tier |
add_payment_info | order payment | currency, value, type |
purchase | order complete | id, currency, total, tax, shipping, coupon |
refund | order refund | id, currency, total |
add_to_wishlist | wishlist add | currency, value |
List / promotion
GA4 (en) | walkerOS (name) | Fields |
|---|---|---|
view_item_list | list view | id, name |
select_item | product click | list_id, list_name |
view_promotion | promotion view | reads from items[0] |
select_promotion | promotion click | reads from items[0] |
select_content | content select | type, id |
Video / form / search
GA4 (en) | walkerOS (name) | Fields |
|---|---|---|
video_start | video start | title, duration, current, percent |
video_progress | video progress | same as video_start |
video_complete | video complete | same as video_start |
form_start | form start | id, name, destination |
form_submit | form submit | id, name, destination |
search | search submit | term |
Auth / lead / share
GA4 (en) | walkerOS (name) | Fields |
|---|---|---|
login | session login | method |
sign_up | session signup | method |
generate_lead | lead generate | currency, value |
share | content share | method, type, id |
Auto-fired noise (dropped by default)
GA4 (en) | Behavior |
|---|---|
user_engagement | ignore: true |
session_start | ignore: true |
first_visit | ignore: true |
These events are emitted automatically by gtag and rarely carry analytics intent. Override the rule if you need them.
Fallback
GA4 (en) | walkerOS (name) | Fields |
|---|---|---|
'*' | ga4 track | data.event_name = original en |
Any GA4 event name not listed above falls through to '*' and produces a generic ga4 track walkerOS event. Override '*' to change the fallback rule globally.
Override a default field
User config applies per event name; other events keep their defaults. A rule with extend or remove patches the matching default rule. A rule with neither replaces it.
Patch a default rule
extend is deep-merged onto the default rule, so you add or change individual fields and keep the rest. remove strips fields from the produced data. To add affiliation to purchase and drop currency:
{
"transformers": {
"ga4": {
"package": "@walkeros/transformer-ga4",
"config": {
"settings": {
"mapping": {
"purchase": {
"extend": {
"data": { "map": { "affiliation": "params.ep.affiliation" } }
},
"remove": ["currency"]
}
}
}
}
}
}
}
id, total, tax, shipping, and coupon stay as the default defines them. A null value in extend clears an inherited field, for example "extend": { "name": null }.
Replace a default rule
Without extend or remove, the whole rule is taken from your config:
{
"transformers": {
"ga4": {
"package": "@walkeros/transformer-ga4",
"config": {
"settings": {
"mapping": {
"purchase": {
"name": "order complete",
"data": {
"map": {
"id": "params.ep.transaction_id",
"total": "params.epn.value",
"currency": "params.ep.currency",
"coupon": "params.ep.promo_code"
}
}
}
}
}
}
}
}
}
The default tax and shipping fields are gone and coupon now reads promo_code: a replacing rule keeps only what it lists.
Drop an event
Set ignore: true on any key to prevent it from being emitted:
"settings": {
"mapping": {
"click": { "ignore": true }
}
}
This is how user_engagement, session_start, and first_visit are silenced by default.
Custom events
Two patterns:
1. Override '*' to change the global fallback for unknown GA4 event names:
"settings": {
"mapping": {
"*": {
"name": "custom event",
"data": { "map": { "event_name": "name" } }
}
}
}
2. Add a specific key for an event you fire via gtag('event', '<your_name>', ...):
"settings": {
"mapping": {
"newsletter_subscribe": {
"name": "newsletter signup",
"data": { "map": { "source": "params.ep.source" } }
}
}
}
Tracking ID filtering
By default only Measurement IDs starting with G- are accepted; Ads (AW-) and DC (DC-) hits are dropped. Widen via a string regex in settings.tidPattern:
"settings": {
"tidPattern": "^(G|AW|DC)-"
}
The string is compiled to a RegExp at init time.
Batch size limit
Every decoded event continues through the pipeline as its own push, and a source's batch limit (such as the express maxBatchSize) only covers JSON batches, not a raw text/plain body. The transformer therefore caps the events in one request with settings.maxEvents (default 100). It counts the non-empty lines of the POST body before decoding, and a request with more lines than the cap is dropped whole: no event from it is decoded, and one warn log line records the line count and the cap. The source still answers the client with success, so that log line is the only sign of the drop. Raise the cap only if your own server-to-server calls send larger batches:
"settings": {
"maxEvents": 250
}
Caveats
- Replace unless
extendorremoveis set. A user mapping rule without either keyword fully replaces the matching default rule. - GA4 v2 only. Assumes the v2 Measurement Protocol layout (
ep.,epn.,up.,upn.,prN,gcs). v1 is out of scope. G-tids only by default. OverridetidPatternto capture Ads and DC traffic.- Basic
gcsonly. MapsG1XXtomarketing/analyticsbooleans. Functional/preferences flags and the newergcdparameter are not decoded. - Body must be raw text. The transformer parses POST bodies as URL-encoded form lines. Pre-parsed JSON bodies will not decode.
- Batched POST hits need a raw body. The source must keep a
text/plainbody that is not JSON asingest.body. The express, fetch, Cloud Function, and Lambda sources do; the Lambda source rejects a POST without a body. See Batched POST hits. - At most 100 events per request by default. A POST body with more lines than
settings.maxEventsis dropped whole. See Batch size limit. - Ingest contract is required. Source wiring must populate
ctx.ingest.url(required) andctx.ingest.body(optional) for batched hits.
Roadmap
- Web ingest via interception sources for capturing
gtagtraffic from the browser - More vendor decoders (Segment, Snowplow, Adobe) following the same
before-chain pattern - Richer consent decoding (
gcd, functional/preferences flags)
Next steps
- Create your own - Build custom transformers
- Server source: express - Pair the decoder with the HTTP source