Chapter 7 of 8. Prerequisite: Chapter 5 — the CLI scaffold. Chapter 6 covers GSC; this chapter covers GA4. They are independent — you can do either order.
What changes from the GSC shape
The GA4 Data API uses a different request shape from the GSC API. Three differences matter:
- Property identifier. GSC properties are addressed by their URL (
https://example.com/). GA4 properties are addressed by a numeric property ID — the nine-digit number in the GA4 admin panel. The CLI takes--propertyas the numeric ID for GA4 subcommands, not the URL. - Dimensions and metrics are first class. Where GSC's Search Analytics endpoint takes
dimensions: ["query"], the GA4 Data API takes bothdimensionsandmetricsas separate arrays, and you can request multiple of each per call. The response is rectangular: dimension columns then metric columns. - Sampling is real. GA4 returns sampled results when the row count exceeds a threshold (the threshold varies by property type per the Data API docs). The response includes a
samplingMetadatasfield that tells you whether the result was sampled and what the sampling rate was. Ignoring this is the most common cause of "the numbers do not match the UI" complaints.
The credential setup carries over unchanged from chapter 5. The scopes already included analytics.readonly; the Credentials object built by auth.build_credentials() works for both APIs without modification.
Building the GA4 service
from googleapiclient.discovery import build
def _ga4(creds):
return build("analyticsdata", "v1beta", credentials=creds)
Same library, different service name. Per the google-api-python-client docs, the version is v1beta and stays that way for the foreseeable future — Google has been operating GA4 Data API at v1beta for years and shows no signs of cutting a v1.
Daily users + sessions
The simplest GA4 query — sessions and active users over the last seven days, broken down by day:
def cmd_traffic(args: argparse.Namespace, creds: Credentials) -> int:
"""Pull daily sessions + active users for the property."""
service = _ga4(creds)
end = date.today()
start = end - timedelta(days=args.days)
body = {
"dateRanges": [{
"startDate": start.isoformat(),
"endDate": end.isoformat(),
}],
"dimensions": [{"name": "date"}],
"metrics": [
{"name": "sessions"},
{"name": "activeUsers"},
{"name": "engagementRate"},
],
"orderBys": [{"dimension": {"dimensionName": "date"}}],
}
response = service.properties().runReport(
property=f"properties/{args.property}",
body=body,
).execute()
print(f"{'date':>10} {'sessions':>9} {'users':>6} {'engaged':>8}")
for row in response.get("rows", []):
d = row["dimensionValues"][0]["value"]
sessions = row["metricValues"][0]["value"]
users = row["metricValues"][1]["value"]
engagement = float(row["metricValues"][2]["value"])
print(f"{d:>10} {sessions:>9} {users:>6} {engagement:>8.2%}")
_warn_if_sampled(response)
return 0
The property=f"properties/{args.property}" is the only odd bit. The Data API addresses properties as properties/123456789 — the literal properties/ prefix is part of the path, not a Python f-string mistake.
Top pages by sessions
For the daily digest, the question that matters most is: which pages picked up traffic yesterday? The dimensions you want are pagePath (or pagePathPlusQueryString if the query string matters for you) and the metric is sessions.
def cmd_top_pages(args: argparse.Namespace, creds: Credentials) -> int:
"""Pull top pages by sessions over a date window."""
service = _ga4(creds)
end = date.today()
start = end - timedelta(days=args.days)
body = {
"dateRanges": [{
"startDate": start.isoformat(),
"endDate": end.isoformat(),
}],
"dimensions": [{"name": "pagePath"}],
"metrics": [
{"name": "sessions"},
{"name": "screenPageViews"},
{"name": "averageSessionDuration"},
],
"orderBys": [{"metric": {"metricName": "sessions"}, "desc": True}],
"limit": 25,
}
response = service.properties().runReport(
property=f"properties/{args.property}",
body=body,
).execute()
print(f"{'sessions':>9} {'views':>6} {'avg dur':>8} path")
for row in response.get("rows", []):
path = row["dimensionValues"][0]["value"]
sessions = row["metricValues"][0]["value"]
views = row["metricValues"][1]["value"]
duration = float(row["metricValues"][2]["value"])
print(f"{sessions:>9} {views:>6} {duration:>8.0f} {path}")
_warn_if_sampled(response)
return 0
A page that appears in the top 25 today but did not yesterday is a candidate for follow-up — either Google has started ranking it for something new, or you shipped a change that increased its visibility. Chapter 8's digest surfaces exactly that diff.
Sampling — the warning the UI does not show you
def _warn_if_sampled(response: dict) -> None:
"""Surface sampling metadata when present.
The GA4 UI silently aggregates sampled results; the API gives you
the sample rate explicitly via samplingMetadatas. Ignoring it
leads to numbers that do not match the UI — and to confusion."""
metadata = response.get("samplingMetadatas") or response.get("metadata", {}).get("samplingMetadatas")
if not metadata:
return
sample_rate = metadata[0].get("samplingPercentage")
if sample_rate and float(sample_rate) < 100:
print(
f"⚠ sampled at {sample_rate}% — the numbers above are an estimate",
file=sys.stderr,
)
Always print sampling warnings to stderr so they survive in cron logs but do not contaminate the data piped into other tools. For low-volume sites you will rarely see sampling — it kicks in around tens of thousands of sessions per query. For high-volume sites it is the default state and you need to learn to read it.
Event-based queries — for the consent + key-event pattern
GA4's main shift from Universal Analytics is that everything is an event. Sessions and active users are derived from events; engagement is a quality measure on events. To pull a specific event — for example, the newsletter_subscribe event you might fire on a confirmation page — the dimension is eventName and the metric is eventCount:
body = {
"dateRanges": [{"startDate": start.isoformat(), "endDate": end.isoformat()}],
"dimensions": [{"name": "eventName"}],
"metrics": [{"name": "eventCount"}],
"dimensionFilter": {
"filter": {
"fieldName": "eventName",
"stringFilter": {"value": "newsletter_subscribe"},
}
},
}
This is the shape that turns custom events into countable signals. The same pattern works for outbound_click, scroll, or any key event you have marked in the GA4 admin panel.
What's next
Chapter 8 puts the three GSC subcommands from chapter 6 and the three GA4 subcommands from this chapter together into a single daily-digest pipeline, schedules it with launchd, and pipes the formatted output through a Telegram bot for triage. That is the chapter that turns this from a CLI you run when you remember to into a system that runs whether you remember or not.
Build your own GA4 digest queries
Implement the GA4 side of your CLI against your own property: a daily traffic pull, a top-pages pull, and one event query filtered to a key event your site actually fires, all reusing the credentials wrapper unchanged and all routed through a sampling warning helper.
Expected behaviour
- GA4 subcommands that take the numeric property ID and address it as properties/ID in the runReport call
- A traffic pull returning date-ordered rows of sessions, active users and engagement rate
- A top-pages pull ordered descending by sessions with a row limit applied
- An event query filtered to one named event using the eventName dimension and eventCount metric
- Sampling warnings printed to stderr so stdout stays clean for piping
PROVE IT Pipe the traffic subcommand's stdout to a file and show only data rows in the file, then feed the warning helper a fabricated samplingMetadatas payload to demonstrate the warning lands on stderr.
How do you address a GA4 property in a Data API runReport call?
Which API version string do you pass to build() for the GA4 Data API?
What are the three ways the GA4 Data API request shape differs from the GSC API, according to this chapter?
Show answer
Properties are addressed by numeric ID rather than URL. Dimensions and metrics are separate first-class arrays and the response is rectangular, dimension columns then metric columns. Sampling is real: past a row threshold the results are sampled and the samplingMetadatas field reports the rate, which is the usual cause of numbers that do not match the UI.
↺ re-read: “What changes from the GSC shape”