hello@newnorth.nl+31 (0) 85 401 31 62
/Journal

BigQuery Event Tag: GA4 events from your server container into your own BigQuery

GuideServer-side2026.09.22
Freek Kampen
Freek KampenCo-founder, New North Digital

A server-side GTM template that writes every GA4 event to your own BigQuery table, in almost the same shape as the GA4 export. No sampling, no daily export limit, and queryable within seconds.

What it does

The BigQuery Event Tag writes every GA4 event your server container receives to a BigQuery table in your own project. The columns follow the native GA4 export where they can, so queries you know from GA4 mostly carry over.

What you gain over that native export:

  • No sampling and no daily cap. The free GA4 export stops at a million events a day; this tag streams everything.
  • Queryable within seconds, instead of waiting for tomorrow's export.
  • You decide what goes in. Adding, excluding or overwriting parameters happens in the tag.

The template is in the gallery as BigQuery Event Tag by New North, and the source is on GitHub.

How it works

Your site or app sends GA4 hits to your server container. The GA4 client in that container turns each hit into event data: every parameter becomes a key. This tag reads that event data, builds one BigQuery row from it and streams it into the table you point it at.

  • Source: your website or app, through a GA4 tag with a server container URL or through the Firebase SDK.
  • Server container: the GA4 client parses the hit.
  • This tag: maps the event data to a GA4-style row and inserts it.
  • Destination: your own table, partitioned by day.

The tag picks up new parameters by itself. Add one to the GA4 tag in your web container and it shows up in event_params without touching the server container. The one exception is allowlist mode, further down.

Before you start

  • A running server-side GTM container with a GA4 client that receives traffic from your web tags.

  • A Google Cloud project with BigQuery enabled. It can be the project that hosts the container, or a different one.

  • Rights to create a dataset and to grant IAM roles in that project.

On Cloud Run, turn CPU throttling off. The tag answers the browser first and inserts in the background, so response times stay low. That background work only finishes reliably when CPU is always allocated. Without it, inserts stall or get lost at low traffic.

gcloud run services update <service> --region <region> --no-cpu-throttling

Setting it up in five steps

1. Create the table. Create a dataset in the region where the data should live, then the table from the schema file that ships with the template. Partition on event_date and cluster on event_name: almost every query filters on both, and it keeps cost down.

bq --location=europe-west4 mk --dataset my-project:sgtm

bq mk --table \
  --schema bigquery-event-tag-schema.json \
  --time_partitioning_type DAY \
  --time_partitioning_field event_date \
  --clustering_fields event_name \
  my-project:sgtm.events

You can add your own columns later. The tag only writes the columns it knows, and BigQuery ignores fields the table doesn't have, so an insert never breaks on it. It does mean a field the tag sends is silently dropped until the column exists.

2. Give the container access. The tag writes as the service account your container runs under. Grant that account BigQuery Data Editor on the dataset. Nothing more: no query rights, no project-wide Editor.

bq add-iam-policy-binding \
  --member=serviceAccount:<runtime-sa> \
  --role=roles/bigquery.dataEditor \
  my-project:sgtm

3. Import the template. In the server container: Templates, Tag Templates, Search Gallery, and add BigQuery Event Tag by New North. To run a newer version than the gallery has, import template.tpl from the repository instead.

4. Configure the tag. Only project, dataset and table are required; the rest has sensible defaults. Set Event Name to the built-in {{Event Name}} variable, and User ID to an Event Data variable if you send your own logged-in ID. Fire the tag on a custom trigger where Client Name equals the name of your GA4 client. Events you never want in BigQuery, such as ad impressions at high volume, you exclude in that same trigger.

5. Test and publish. Open Preview in the server container, click a request and check that the tag is under Tags Fired. With Log to console you see the exact row. Streamed rows are queryable within seconds:

SELECT event_timestamp, event_name, user_pseudo_id
FROM `my-project.sgtm.events`
WHERE event_date = CURRENT_DATE()
ORDER BY event_timestamp DESC
LIMIT 20

Publish, then watch your Cloud Run logs for BigQuery insert FAILED. That line means an insert failed twice, and it names the table and the event. Put a log-based alert on it, and you'll know before there's a gap in your data.

What a row contains

The column names follow the GA4 export where they can.

  • event_date, event_timestamp: partition date and time in microseconds. Client time is capped at server time, so a wrong device clock cannot write into the future.
  • event_name, user_id, user_pseudo_id, ga_session_id, ga_session_number, stream_id. Session fields stay empty when the hit has none; the tag never invents one.
  • source, medium, campaign and collected_traffic_source: the attribution of this event, not of the session, from UTMs, click IDs and the referrer.
  • platform, device_category, app_info, device and geo.
  • event_params and user_properties: every parameter as a key with a typed value.
  • privacy_info: the consent state of the hit, with ad_storage, analytics_storage, ad_user_data and ad_personalization.

Each key has a value record with string_value, int_value and float_value. The tag fills the one that matches the incoming value. If a parameter arrives as "5" on one page and 5 on another, it lands in two different fields. Keep the type consistent at the source, or read it with COALESCE.

Controlling parameters

All is the default: everything in the event data is written, except GA4's internal fields. Anything you don't want stored goes in Parameters to Exclude. A common one is ip_override.

Allowlist writes only the keys you list. At high volume that saves on insert and storage cost. The catch: a new parameter from the website is dropped until someone adds it. Only do this when that list has an owner.

Parameters to Add / Edit is always written, whatever the mode. Use it for values the site doesn't send, such as a site or environment label, or to overwrite a parameter with a cleaned-up version.

A new parameter goes on the GA4 tag in your web container. In All mode that's the only step. A dataLayer value that isn't on that tag never leaves the browser, so it cannot reach BigQuery.

Consent and privacy

The tag doesn't block on consent by itself. It records the consent state of every hit in privacy_info, so you can filter at query time. If your legal basis means certain hits must not be stored at all, block them in the trigger.

  • Page URLs are stored in full, query string included. Those can carry email addresses or order IDs. Exclude page_location and page_referrer, or clean them with Parameters to Add / Edit.
  • The user agent sits in device as web_info.user_agent. Exclude it under Device Properties.
  • IP addresses end up in event_params when your web tags send ip_override. Exclude that key unless you need it.

Behind Cloudflare or another proxy, Google's geo lookup sees the data centre instead of the visitor. The tag then takes the country from the proxy's country header. When that disagrees with Google's, it writes only the country, because region and city would describe the data centre. Exclude region, city and metro under Geo Properties to keep the table honest.

Querying and troubleshooting

Always filter on event_date. It is the partition column, and it decides how much data BigQuery scans and bills.

SELECT
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page,
  COUNT(*) AS page_views
FROM `my-project.sgtm.events`
WHERE event_date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY) AND DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
  AND event_name = 'page_view'
GROUP BY page
ORDER BY page_views DESC

Only hits with analytics consent:

WHERE event_date = DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
  AND (SELECT value.string_value FROM UNNEST(privacy_info)
       WHERE key = 'analytics_storage') = 'Yes'

When something is off, it is usually one of these five:

  • No rows at all. The service account lacks BigQuery Data Editor on the dataset, or there's a typo in the project, dataset or table. Search your Cloud Run logs for BigQuery insert FAILED.
  • Rows in preview, gaps in production. CPU throttling is still on for the Cloud Run service.
  • A new parameter is missing. It isn't on the GA4 tag in the web container, or allowlist mode is on and the key isn't in the list.
  • A field stays empty. The table has no column for it. BigQuery drops unknown fields without an error.
  • A parameter is half empty. It arrives as text on some pages and as a number on others. Read it with COALESCE and fix the type at the source.

Questions or a bug? Open an issue on GitHub or mail hello@newnorth.nl.

Want to talk about this?

Let's talk data.

Tell us about your stack, your goals, the data you wish you had.

Takes 1 minute