Google Analytics 4
Datafly Signal delivers events to Google Analytics 4 server-to-server using the GA4 Measurement Protocol. This bypasses the need for the gtag.js client-side library entirely.
Prerequisites
Before configuring GA4 in Datafly Signal, you need a GA4 property, a web data stream, and a Measurement Protocol API secret. Follow the steps below to set these up in Google Analytics.
Step 1: Create a Google Analytics 4 Property
- Go to analytics.google.com.
- Click Admin (gear icon) in the bottom-left.
- In the Account column, click Create Account (or use an existing account).
- Enter an account name and configure your data sharing preferences.
- Click Next, then enter a property name (e.g. “My Website”).
- Set your reporting timezone and currency.
- Click Create.
Step 2: Create a Web Data Stream
- In Admin > Property > Data Streams, click Add Stream > Web.
- Enter your website URL and a stream name.
- Note the Measurement ID (format:
G-XXXXXXXXXX) — you will need this when configuring the integration in Signal.
Step 3: Create a Measurement Protocol API Secret
- In the Data Stream details page, scroll down to Measurement Protocol API secrets.
- Click Create.
- Give it a descriptive name (e.g. “Datafly Signal”).
- Copy the generated secret value — you will need this when configuring the integration in Signal.
The API secret is only shown once at creation time. Store it securely. If you lose it, you will need to create a new one.
Step 4: Disable Enhanced Measurement (Optional)
Since Signal handles all event tracking server-side, you may want to disable GA4’s Enhanced Measurement to avoid duplicate events — particularly if you previously had gtag.js installed on your site.
- In the Data Stream details page, click the Enhanced Measurement toggle.
- Disable individual events you no longer need (e.g. scrolls, outbound clicks, file downloads) or turn off Enhanced Measurement entirely.
If you are migrating from a client-side GA4 setup, disabling Enhanced Measurement prevents GA4 from collecting events that Signal is now handling server-side. You can always re-enable individual events later.
API Endpoint
POST https://www.google-analytics.com/mp/collect?measurement_id={measurement_id}&api_secret={api_secret}All events are sent as JSON payloads to the Measurement Protocol endpoint. Authentication is handled via the api_secret query parameter.
Configuration
| Field | Required | Description |
|---|---|---|
measurement_id | Yes | Your GA4 Measurement ID (e.g. G-XXXXXXXXXX). Found in GA4 Admin > Data Streams. |
api_secret | Yes | Measurement Protocol API secret. Create one in GA4 Admin > Data Streams > Measurement Protocol API secrets. |
Management UI Setup
- Go to Integrations in the sidebar.
- Open the Integration Library tab.
- Find Google GA4 or filter by Analytics.
- Click Install, choose a variant, and enter:
- Your Measurement ID from Step 2.
- Your API Secret from Step 3.
- Click Install Integration to create the integration with a ready-to-use default blueprint.
Variants
The GA4 blueprint ships with four pre-tuned variants — choose the one closest to your business:
| Variant | Description |
|---|---|
| Retail | Full e-commerce funnel with all 16 GA4 recommended events: page views, product discovery, cart, checkout, purchase, refunds, and promotions. |
| Travel | Travel and hospitality funnel: search, listing views, booking flow, cancellations, and promotions mapped to GA4 e-commerce events. |
| B2B / SaaS | SaaS lifecycle events: sign-up, login, trials, subscriptions, leads, and content engagement mapped to GA4. |
| Automotive | Vehicle marketing funnel: vehicle search/view/compare/save, brochure downloads, finance quotes, trade-in valuations, test drive bookings, dealer enquiries, and service appointments. |
Every variant includes session_id, engagement_time_msec, and SHA-256 hashed email/phone for Enhanced Conversions.
Management API Setup
curl -X POST http://localhost:8084/v1/admin/integration-catalog/ga4/install \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Google GA4",
"variant": "retail",
"config": {
"measurement_id": "G-XXXXXXXXXX",
"api_secret": "your_api_secret"
},
"delivery_mode": "server_side"
}'Identity
client_id
The Measurement Protocol requires a client_id to identify the user/device. Datafly Signal self-generates this value in the standard GA4 format:
{random_number}.{timestamp_seconds}For example: 1234567890.1706540000
The client_id is generated on the first event for a visitor and persisted server-side for the lifetime of the visitor. It is also stored as a first-party cookie by Datafly.js and included in all subsequent events.
user_id
If your application calls datafly.identify(userId), the user_id is forwarded to GA4 for cross-device reporting.
Enhanced Conversions (user_data)
When a user identifies via datafly.identify() with email or phone traits, Signal hashes those values (SHA-256, lowercased, trimmed) and forwards them in the GA4 user_data block:
| Trait | GA4 field | Hashing |
|---|---|---|
email | user_data.sha256_email_address | lowercase, trim, SHA-256 |
phone | user_data.sha256_phone_number | trim, SHA-256 |
Raw PII never leaves your infrastructure — hashing happens server-side before delivery.
Campaign Attribution
Signal forwards UTM and click-ID parameters automatically so GA4 attributes the event correctly:
| Source | GA4 field |
|---|---|
utm_source | source |
utm_medium | medium |
utm_campaign | campaign |
utm_term | term |
utm_content | content |
gclid | gclid |
wbraid | wbraid |
gbraid | gbraid |
Consent
GA4’s Measurement Protocol supports Google Consent Mode v2. The default blueprint maps Signal’s canonical marketing consent signal to two fields:
| Consent signal | GA4 field | Granted | Denied |
|---|---|---|---|
marketing | consent.ad_user_data | GRANTED | DENIED |
marketing | consent.ad_personalization | GRANTED | DENIED |
The ad_storage and analytics_storage fields are gtag-only and are deliberately not sent — GA4’s Measurement Protocol only accepts ad_user_data and ad_personalization.
Default consent category: analytics. Add advertising if you want events suppressed when the visitor has not consented to advertising.
Session Handling
GA4’s Measurement Protocol does not automatically create sessions. Datafly Signal manages sessions server-side — and because it runs on your own first-party domain, it does so more durably than client-side gtag.js can.
session_id: A numeric identifier set at the start of each session and stable for its entire duration. It rotates only after the inactivity timeout, so a continuously active visit keeps onesession_idno matter how long it runs.session_number: An incrementing counter tracking how many sessions the visitor has had. It persists for the life of the visitor’s first-party cookie (a rolling 400 days), so returning visitors keep counting up correctly.session_start/first_visit: Signal emits GA4’s_ssand_fvsignals exactly once — on the first event of a new session, and the first session ever, respectively — so landing pages attribute correctly and new-vs-returning stays accurate.- Session timeout: 30 minutes of inactivity — a sliding window measured from the last event, not a cap on session length.
{
"client_id": "1234567890.1706540000",
"events": [
{
"name": "page_view",
"params": {
"session_id": "1706540000",
"session_number": 3,
"engagement_time_msec": 100
}
}
]
}The engagement_time_msec parameter is required for events to appear in standard GA4 reports. Datafly Signal sets this to a minimum of 100 milliseconds to ensure events are counted as engaged.
More resilient than client-side gtag
Client-side analytics — including gtag.js — stores session state in browser storage that Safari’s Intelligent Tracking Prevention (ITP) caps at 7 days and clears entirely in private mode. Once that window lapses, a returning visitor is mis-counted as new: their session_number resets to 1 and first_visit re-fires, even though it’s the same person. On Safari and iOS, where a large share of traffic now sits, this quietly inflates new users and fragments sessions.
Signal computes sessions on the server and persists them in a server-set, first-party cookie on your own domain — which ITP does not cap the way it caps JavaScript-written storage. In practice:
- returning visitors stay returning — no spurious
first_visit, session_numberkeeps counting correctly across weeks and months,- session attribution holds, so landing pages stop slipping into
(not set)from fragmented sessions.
This durability relies on your collection endpoint being a first-party subdomain (for example collect.yourdomain.com) that points directly at your Signal deployment — the default for Signal installs. That genuinely first-party, server-set position is exactly what a client-side tag cannot achieve.
Migrating from gtag.js
Moving GA4 from gtag.js to Signal is a real piece of work — every tracked event has to be sent to Signal and then switched off in gtag. It is not a single config flip. This section covers how to do it safely, and the one architectural rule that determines whether you actually escape ad blocking.
Load the collector first-party, not inside GTM
This is the rule that everything else depends on. There are two separate things people call “GTM”:
- The dataLayer — a plain JavaScript array (
window.dataLayer) that your own site code pushes events into (dataLayer.push({ event: 'purchase', ... })). It is part of your site. Ad blockers do not touch it. - Google’s script — the GTM container /
gtag.js, downloaded fromgoogletagmanager.com. Its job is to read that array and send data to Google. This is what ad blockers block.
So when a visitor runs an ad blocker, the dataLayer still fills up, but Google’s script that should read it and send to GA4 is blocked, and the data is lost.
Do not install the Datafly.js collector as a tag inside GTM. If you do, the collector only loads when GTM loads — so when an ad blocker blocks GTM, your Signal tag never fires, and you have inherited exactly the blocking you were trying to escape.
Instead, add the Datafly.js script directly to your page, served from your first-party collection subdomain (e.g. collect.yourdomain.com). Because it is genuinely first-party and not a recognised tracker domain, the collector keeps running even when gtag.js is blocked. This first-party position is what makes Signal resilient to the ad blocking that costs gtag-based GA4 a meaningful share of its events.
Validate in parallel, without double-counting
GA4 does not deduplicate general events between gtag.js and the Measurement Protocol. If both send the same page_view to the same property, GA4 counts both. The only native exception is purchase events that share a transaction_id, and even that is best reconciled in BigQuery.
So during validation, do not send the same event to the same property from both sources. Point Signal at a separate GA4 property (or data stream) and compare its numbers against your existing gtag-fed production property until you trust parity. This lets you verify Signal end-to-end with zero risk to production reporting.
Cut over event by event
Once you trust the numbers, migrate one event at a time. For each event, add the Signal equivalent and switch off the gtag / GTM tag for that same event together, so it is never sent twice to production:
- Standard GA4 event names carry over unchanged — Signal uses a GA4-compatible schema, so
gtag('event', 'purchase', { value: 99.99 })becomesdatafly.track('purchase', { value: 99.99 }). - Datafly.js enhanced measurement covers the automatic interactions GTM used to handle — scroll depth, outbound clicks, file downloads, form start/submit, and site search — first-party, with no GTM triggers.
The destination: no third-party tag in the chain
When every event is on Signal, remove gtag.js and the GTM container entirely. Your events are now collected first-party and delivered to GA4 server-side, with nothing in the request path an ad blocker recognises. That is the end state Signal is built for: one first-party collector, no client-side vendor tags.
Event Mapping
Datafly Signal maps standard event names to their GA4 equivalents:
| Datafly Event | GA4 Event | Notes |
|---|---|---|
page | page_view | Includes page_location, page_title, page_referrer |
Products Searched | search | Requires search_term |
Product List Viewed | view_item_list | Requires items array, item_list_name |
Product Clicked | select_item | Requires items array |
Product Viewed | view_item | Requires items array |
Product Added to Wishlist | add_to_wishlist | Requires items array |
Product Added | add_to_cart | Requires items array |
Product Removed | remove_from_cart | Requires items array |
Cart Viewed | view_cart | Includes value, currency, items |
Checkout Started | begin_checkout | Requires items array, value, currency |
Shipping Info Entered | add_shipping_info | Includes shipping_tier |
Payment Info Entered | add_payment_info | Includes payment_type |
Order Completed | purchase | Requires transaction_id, value, currency |
Order Refunded | refund | Requires transaction_id |
Promotion Viewed | view_promotion | Includes promotion_id, promotion_name, creative_name, creative_slot |
Promotion Clicked | select_promotion | Includes promotion_id, promotion_name, creative_name, creative_slot |
| Custom events | Passed through | Any datafly.track("event_name") is sent as-is |
Example: Purchase Event
Datafly.js call:
datafly.track("Order Completed", {
order_id: "ORD-001",
total: 129.99,
currency: "USD",
products: [
{ product_id: "SKU-A", name: "Widget", price: 49.99, quantity: 2 },
{ product_id: "SKU-B", name: "Gadget", price: 30.01, quantity: 1 }
]
});GA4 Measurement Protocol payload sent by Datafly:
{
"client_id": "1234567890.1706540000",
"events": [
{
"name": "purchase",
"params": {
"transaction_id": "ORD-001",
"value": 129.99,
"currency": "USD",
"session_id": "1706540000",
"session_number": 3,
"engagement_time_msec": 100,
"items": [
{
"item_id": "SKU-A",
"item_name": "Widget",
"price": 49.99,
"quantity": 2
},
{
"item_id": "SKU-B",
"item_name": "Gadget",
"price": 30.01,
"quantity": 1
}
]
}
}
]
}Example: Page View
GA4 Measurement Protocol payload:
{
"client_id": "1234567890.1706540000",
"events": [
{
"name": "page_view",
"params": {
"page_location": "https://example.com/products/widgets",
"page_title": "Widgets | Example Store",
"page_referrer": "https://www.google.com/",
"session_id": "1706540000",
"session_number": 3,
"engagement_time_msec": 100
}
}
]
}Custom Events
Any event not in the mapping table is sent to GA4 with its original name. All event properties are forwarded as GA4 event parameters.
datafly.track("newsletter_signup", { method: "footer_form" });{
"client_id": "1234567890.1706540000",
"events": [
{
"name": "newsletter_signup",
"params": {
"method": "footer_form",
"session_id": "1706540000",
"session_number": 1,
"engagement_time_msec": 100
}
}
]
}GA4 event names must be 40 characters or fewer, use only letters, numbers, and underscores, and must start with a letter. Datafly Signal automatically sanitises event names to comply with these requirements.
Parameters
Event-Level Parameters
| Parameter | Type | Description |
|---|---|---|
session_id | string | Server-generated session identifier |
session_number | integer | Incrementing session count per visitor |
engagement_time_msec | integer | Time in milliseconds the user was engaged (minimum 100) |
page_location | string | Full URL of the page |
page_title | string | Document title |
page_referrer | string | Referrer URL |
E-commerce Parameters
| Parameter | Type | Description |
|---|---|---|
transaction_id | string | Unique order/transaction identifier |
value | number | Total monetary value |
currency | string | ISO 4217 currency code (e.g. USD, EUR) |
items | array | Array of item objects |
Item Parameters
| Parameter | Type | Description |
|---|---|---|
item_id | string | Product SKU or identifier |
item_name | string | Product name |
price | number | Unit price |
quantity | integer | Number of units |
item_category | string | Product category |
item_brand | string | Product brand |
Limitations
- Google Signals: Demographic and interest data from Google Signals is not available with server-side Measurement Protocol. These features require the client-side
gtag.jslibrary. - Automatic events: GA4 automatic events (e.g.
scroll,outbound_click,file_download) are not collected unless you explicitly track them with Datafly.js. - Real-time reports: Events sent via the Measurement Protocol may take a few seconds longer to appear in GA4 real-time reports compared to client-side collection.
- BigQuery export: Events sent via the Measurement Protocol are included in BigQuery exports but may have limited attribution data.
- Parameter limits: GA4 allows a maximum of 25 event parameters per event and 25 user properties per project.
Verify it’s working
- In the Signal Management UI, open your integration and watch the Live Events stream as you trigger events on your site.
- In GA4, open Reports > Realtime. Server-side events appear in Realtime within ~30 seconds.
- Open Admin > DebugView (only works while
debug_modeis on for a session, or while the integration hasdebug_mode: trueconfigured) to inspect individual event payloads. - After 24-48 hours, check Reports > Engagement > Events to confirm event volumes are normal.
Troubleshooting
| Problem | Solution |
|---|---|
| Events not appearing | Confirm measurement_id and api_secret are for the same Data Stream. The Measurement Protocol silently accepts mismatched pairs. |
| Events accepted but missing from reports | Set engagement_time_msec to at least 100 — without it, GA4 treats events as non-engaged and may hide them. Signal sets this automatically. |
| Realtime works but standard reports show nothing | Check the Data Stream’s filters and internal_traffic settings — these silently exclude events. |
| User Acquisition shows no data | Ensure gclid, wbraid, gbraid, or UTM parameters are reaching Datafly.js. Signal forwards them automatically when present on the landing URL. |
| Enhanced Conversions match rate is low | Call datafly.identify(userId, { email, phone }) whenever you have the data — at login, signup, checkout, or form submit. |
Debugging
Use the GA4 debug endpoint to validate payloads without sending data to production:
POST https://www.google-analytics.com/debug/mp/collect?measurement_id={measurement_id}&api_secret={api_secret}The debug endpoint returns validation messages for each event:
{
"validationMessages": [
{
"fieldPath": "events[0].params.value",
"description": "Expected type NUMBER, got STRING.",
"validationCode": "VALUE_INVALID"
}
]
}To enable debug mode for your integration, set debug_mode: true in the integration configuration. This routes events through the debug endpoint and logs the validation responses.
{
"config": {
"measurement_id": "G-XXXXXXXXXX",
"api_secret": "your_api_secret",
"debug_mode": true
}
}Events sent to the debug endpoint are not recorded in your GA4 property. Disable debug_mode before going to production.
Rate Limits
Google’s Measurement Protocol does not publish strict rate limits, but the following defaults are used:
| Setting | Default |
|---|---|
rate_limit_rps | 100 |
rate_limit_burst | 200 |
If you experience 429 Too Many Requests responses, reduce rate_limit_rps or contact Google to discuss higher quotas.
Blueprint
The GA4 blueprint version is 2.2.0 (ga). It ships with four variants (Retail, Travel, B2B / SaaS, Automotive), Consent Mode v2, Enhanced Conversions, and the 16 GA4 recommended e-commerce events.
See also
- Meta Conversions API — pair with GA4 for paid-media measurement.
- Google Ads Offline Conversions — closes the loop on Google paid-search attribution.
- Cross-Platform Deduplication — run GA4 alongside gtag.js during migration.