On this page

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 --property as 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 both dimensions and metrics as 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 samplingMetadatas field 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.

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.

// EXERCISE

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.

// CHECKPOINT — GA4 QUERIES
multiple choice · auto-checked

How do you address a GA4 property in a Data API runReport call?

exact answer · auto-checked

Which API version string do you pass to build() for the GA4 Data API?

open · self-checked

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

Lived experience

Sources

Back to guide overview