Skip to main content
Ask your AI

GA4

Server Source code Package

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-ga4

Send 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 source walkeros run and the walkeros/flow Docker image serve. A text/plain body that is valid JSON is parsed, and any other text/plain body reaches the transformer as the raw string.
  • @walkeros/server-source-fetch, for a runtime that serves a (Request) => Response handler (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:

{
"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" }
}
}
}
}

Ingest contract

The transformer expects the source to populate ctx.ingest with these keys:

KeyTypeRequiredNotes
urlstringyesFull request URL including the query string.
pathstringnoRequest path. Use it to gate the before chain when the source also receives other traffic.
bodystringnoRaw 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

PropertyTypeDescriptionMore
mappingRecord<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.
tidPatternstringRegex string the tracking ID (`tid`) must match, compiled once at init. Default `^G-`, which drops Ads (`AW-`) and DC (`DC-`) hits.
maxEventsintegerMaximum 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.

Event
{
  "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"
}
Out
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.

Event
{
  "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"
}
Out
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.

Event
{
  "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"
}
Out
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.

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"
}
Out
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.

Event
{
  "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"
}
Out
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.

Event
{
  "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"
}
Out
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.

Event
{
  "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"
}
Out
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.

Event
{
  "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"
}
Out
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.

Event
{
  "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"
}
Out
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.

Event
{
  "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"
}
Out
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.

Event
{
  "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"
}
Out
return false

View item

A GA4 view_item hit decoded to a walkerOS product view event with currency and value.

Event
{
  "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"
}
Out
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_viewpage viewid, title, referrer
scrollpage scrollpercent
clicklink clickurl, domain, outbound
file_downloadfile downloadname, extension, url

Ecommerce

GA4 (en)walkerOS (name)Fields
view_itemproduct viewcurrency, value
add_to_cartproduct addcurrency, value
remove_from_cartproduct removecurrency, value
view_cartcart viewcurrency, value
begin_checkoutorder startcurrency, value, coupon
add_shipping_infoorder shippingcurrency, value, tier
add_payment_infoorder paymentcurrency, value, type
purchaseorder completeid, currency, total, tax, shipping, coupon
refundorder refundid, currency, total
add_to_wishlistwishlist addcurrency, value

List / promotion

GA4 (en)walkerOS (name)Fields
view_item_listlist viewid, name
select_itemproduct clicklist_id, list_name
view_promotionpromotion viewreads from items[0]
select_promotionpromotion clickreads from items[0]
select_contentcontent selecttype, id
GA4 (en)walkerOS (name)Fields
video_startvideo starttitle, duration, current, percent
video_progressvideo progresssame as video_start
video_completevideo completesame as video_start
form_startform startid, name, destination
form_submitform submitid, name, destination
searchsearch submitterm

Auth / lead / share

GA4 (en)walkerOS (name)Fields
loginsession loginmethod
sign_upsession signupmethod
generate_leadlead generatecurrency, value
sharecontent sharemethod, type, id

Auto-fired noise (dropped by default)

GA4 (en)Behavior
user_engagementignore: true
session_startignore: true
first_visitignore: 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 trackdata.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 extend or remove is 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. Override tidPattern to capture Ads and DC traffic.
  • Basic gcs only. Maps G1XX to marketing/analytics booleans. Functional/preferences flags and the newer gcd parameter 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/plain body that is not JSON as ingest.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.maxEvents is dropped whole. See Batch size limit.
  • Ingest contract is required. Source wiring must populate ctx.ingest.url (required) and ctx.ingest.body (optional) for batched hits.

Roadmap

  • Web ingest via interception sources for capturing gtag traffic from the browser
  • More vendor decoders (Segment, Snowplow, Adobe) following the same before-chain pattern
  • Richer consent decoding (gcd, functional/preferences flags)

Next steps

💡 Need implementation support?
elbwalker offers hands-on support: setup review, measurement planning, destination mapping, and live troubleshooting. Book a 2-hour session (€399)