# Reading a GA4 property without changing it

Read once, save to files, run the rules engine on the files. Never call an API once per rule. Checked on 2026-11-01, and on 2026-11-06 for the GA4, Google Ads and BigQuery MCP servers and the gcloud sign-in; re-check the v1alpha methods, which can change without notice.

## Choosing a route

- Route B, the snapshot script: the user can mint their own read-only Google credentials. Reads the most (settings and reports).
- Route A, the official GA4 MCP server: it is already connected to the agent. Reads property details, key events, Google Ads links, annotations, custom definitions and reports, but not most settings.
- Route C, interface exports: no API access at all. The user downloads exploration CSVs and fills the intake; the agent transcribes a few settings from screenshots.
- Decision rule: prefer B, then A, then C. Mixed routes are fine; the script reads whatever the snapshot holds and marks the rest NOT CHECKED.

## Route B: scripts/ga4_snapshot.py

Who runs what: the user signs in, because the sign-in needs browser consent. The agent then runs `--dry-run` from the audit working folder, shows the user every planned call, and runs the snapshot only after the user agrees.

Sign-in, run by the user in their own terminal:

```bash
gcloud auth application-default login \
  --scopes=https://www.googleapis.com/auth/analytics.readonly,https://www.googleapis.com/auth/cloud-platform
```

- This overwrites any credentials an earlier `gcloud auth application-default login` saved and leaves a cloud-platform credential on disk. After the audit, the user runs `gcloud auth application-default revoke` unless other tools rely on it.
- If Google blocks the sign-in or rejects the scope, run the login again with `--client-id-file=CLIENT_JSON` (an OAuth desktop client in your own Cloud project) or with `--impersonate-service-account=SA_EMAIL` for a service account that has Viewer on the property, as the GA4 MCP README shows. The scope gate still applies.

Then, run by the agent from the working folder (the script path is the skill's own):

```bash
python3 <skill folder>/scripts/ga4_snapshot.py --property 123456789 --as-of 2026-11-01 \
  --quota-project YOUR_CLOUD_PROJECT --dry-run
python3 <skill folder>/scripts/ga4_snapshot.py --property 123456789 --as-of 2026-11-01 \
  --quota-project YOUR_CLOUD_PROJECT --out ga4-snapshot.json
```

- The Google Analytics Admin API and Data API must be enabled in the Cloud project that bills quota. The script sends raw REST calls, which carry no quota project unless `--quota-project` sets the `x-goog-user-project` header; without it, user credentials usually get 403 on every call, and the script prints a hint when the property read returns 403.
- Google's own MCP sample adds the cloud-platform scope; the script notes it and continues.
- Token: `gcloud auth application-default print-access-token`. Kept in memory only.
- Scope gate: before any API call, the script posts the token to Google's tokeninfo endpoint in the request body (never in a URL) and stops with exit 2 if the bare analytics scope, analytics.edit, analytics.manage.users or analytics.provision is granted. It prints scope names only. If tokeninfo fails, it records `scope_check: not run` and continues with the allowlist alone (verify at run time that tokeninfo accepts the POST body).
- Allowlist: every request is matched against a fixed list of (method, URL pattern) pairs before it is sent; anything else raises.
- Behaviour: serial requests, one retry after 2 seconds on 429 or 5xx, stop on 401, `pageSize` 200 with `nextPageToken`. A 403 or 404 is recorded under `not_readable` and the run continues. `--dry-run` prints every planned call and makes no request. `--skip-reports` reads settings only.
- Exit codes: 0 complete, 3 partial (file written, some endpoints unreadable), 2 auth or scope stop.
- Cost: 18 Admin calls plus 4 per web stream, 21 reports, a paged transaction report, one metadata call and two reports per custom dimension (first 60). Standard properties allow 200,000 Data API tokens a day, 40,000 an hour and 10 concurrent requests; most requests use 10 tokens or fewer.
- Written to disk: R13A, R13B and R16 keep only rowCount and metadata, because page paths can carry personal data; R17C returns only the (not set) item row.

Calls made (all GET unless noted):

- v1beta: `properties/{id}`, `accounts/{account}/dataSharingSettings` (account from the property; flags only), `dataRetentionSettings`, `dataStreams`, `keyEvents`, `customDimensions`, `customMetrics`, `googleAdsLinks` (creatorEmailAddress is dropped before writing), `firebaseLinks`.
- v1alpha: `attributionSettings`, `googleSignalsSettings`, `reportingIdentitySettings`, `userProvidedDataSettings`, `bigQueryLinks`, `channelGroups`, `reportingDataAnnotations`, `calculatedMetrics`, `audiences` (count and names only).
- v1alpha, per web stream: `enhancedMeasurementSettings`, `dataRedactionSettings`, `eventCreateRules`, `eventEditRules`.
- Data API v1beta: POST `properties/{id}:runReport` for each report in [data-checks.md](data-checks.md); GET `properties/{id}/metadata`.

Never called:

- Any create, update, patch, delete, archive, acknowledge, provision or reorder method.
- `measurementProtocolSecrets` (the response holds secret values).
- `accessBindings` (needs analytics.manage.users and returns emails).
- `accounts.searchChangeHistoryEvents` (needs analytics.edit), `runAccessReport` (returns user records), `audienceExports.create`.

## Route A: the official GA4 MCP server

- Server: `googleanalytics/google-analytics-mcp`, runs locally over stdio by default (PyPI package `analytics-mcp`). Version 1.0.0 was released on 2026-11-03; it dropped the experimental label and added an HTTP transport with OAuth, which this audit does not need.
- Read-only setup: Application Default Credentials with the analytics.readonly scope. Start the server without `--allow-write`. With `--allow-write` and analytics.edit it exposes `create_property_annotation`, `create_custom_dimension` and `create_key_event`, all writes; never call them.
- Check before reading: list the server's tools. If `create_property_annotation`, or any create, update or delete tool, is present, the server is running with `--allow-write`: stop and ask the user to restart it without the flag.
- Tools this audit uses: `get_account_summaries` (find the property), `get_property_details` (snapshot `property`), `list_key_events` (`key_events`), `list_google_ads_links` (`google_ads_links`), `list_property_annotations` (`annotations`), `get_custom_dimensions_and_metrics` (`metadata_api`; scope from the customEvent:, customUser: and customItem: prefixes), `run_report` (the R reports, with the route A limits in [data-checks.md](data-checks.md#count-only-versions-for-route-a)), `run_conversions_report` only if conversion reporting is in scope.
- Key style: the server returns proto field names in snake_case (`time_zone`, `subject_to_thresholding`); REST returns camelCase. The rules engine reads both.
- Cannot read: data retention, data streams and enhanced measurement, attribution settings, Google signals, reporting identity, BigQuery links, channel groups, event create and edit rules, data redaction, user-provided data settings. Transcribe those from the UI into the snapshot (shape below) or use route B for them.
- Saving outputs: write each tool result into the snapshot under its key. Drop creator_email_address from list_google_ads_links results before writing them to the snapshot, and never quote it. `metadata_api` keeps the tool's own `custom_dimensions` and `custom_metrics` keys.
- Never request transactionId, pageLocation, pagePath, pageReferrer or customEvent: and customUser: dimensions through `run_report`: the tool returns every value into the conversation, and writing them to a file would paste them again. Take R08, R14 and R15 from route B, or from a route C export passed to the script by path without opening it. If neither is possible, run the count-only versions in [data-checks.md](data-checks.md#count-only-versions-for-route-a) (a filter on the field, eventCount, and hostName as the only dimension for R15), or mark those sub-checks NOT CHECKED. R16 is the one exception: it runs filtered to the root path with limit 1, and only its metadata flag is read.

## Route C: interface exports

- The user builds the explorations in [data-checks.md](data-checks.md#route-c-export-labels), exports each as CSV, adds the Tag coverage CSV, and fills [../assets/ui-settings-intake.csv](../assets/ui-settings-intake.csv).
- Run the script with `--exports DIR` (or a list of files). The parser skips comment lines starting with # and blank lines, finds each table by its English column labels, skips Totals rows and stops at the next blank or comment line. If nothing matches it prints the first non-comment line (unless it may hold a URL) and the labels it expected. Rename the columns of a non-English export.
- Privacy: the R08 and R15 exports hold transaction IDs and raw URLs. Ask the user to export only the filtered rows. The agent passes those files to the script by path and never opens, prints or quotes them; the script reduces them in memory; the user deletes them after the audit.
- Settings for route C come from screenshots: transcribe the keys below that the screens show and set `route` to `ui-export`.

## Snapshot shape

Top-level keys (snake_case; nested Admin API objects keep the API's own camelCase or snake_case):

```json
{
  "snapshot_version": "1", "read_time_utc": "2026-11-01T09:20:00Z", "route": "collector | mcp | ui-export",
  "scopes_granted": [], "scope_check": "passed",
  "window": {"start": "2026-10-03", "end": "2026-10-30", "previous_start": "2026-09-05", "previous_end": "2026-10-02"},
  "property": {}, "data_retention": {},
  "data_streams": [{"name": "properties/123456789/dataStreams/1234567890", "type": "WEB_DATA_STREAM",
                    "enhanced_measurement": {}, "data_redaction": {}, "event_create_rules": [], "event_edit_rules": []}],
  "key_events": [], "custom_dimensions": [], "custom_metrics": [], "calculated_metrics": [],
  "google_ads_links": [], "firebase_links": [], "bigquery_links": [], "attribution_settings": {},
  "google_signals": {}, "reporting_identity": {}, "user_provided_data": {}, "data_sharing": {}, "channel_groups": [],
  "annotations": [], "audiences_count": 0,
  "reports": {"R01": {"dimensionHeaders": [], "metricHeaders": [], "rows": [], "metadata": {}}},
  "metadata_api": {"dimensions": [], "metrics": []},
  "not_readable": [{"endpoint": "v1alpha/reportingIdentitySettings", "status": 404, "reason": ""}],
  "call_log": []
}
```

- Leave a key out when it was not read; an empty list means "read, none found". The difference decides NOT CHECKED versus PASS.
- Minimal transcription for routes A and C: `property` (timeZone, currencyCode, serviceLevel), `data_retention`, `data_streams` with `enhanced_measurement` and `data_redaction`, `attribution_settings`, `reporting_identity`, `bigquery_links`, `channel_groups` (display names in order and which is primary). On route C also `key_events` (eventName, countingMethod, defaultValue, deletable if shown). [../assets/example-acme-snapshot.json](../assets/example-acme-snapshot.json) shows every field.

## Other systems

- Google Ads: the official Google Ads MCP server (`googleads/google-ads-mcp`) tools `search`, `get_resource_metadata` and `list_accessible_customers`. Its README lists mutate tools (`create_asset`, `upload_video_asset`, `manage_labels`, `manage_label_associations`) that are on by default, while Google's developer page still calls the server read-only, so set `labels: false` and `assets: false` in its `tools_config.yaml` and connect as a user with Read-only access (checked 2026-11-06). Run the two GAQL queries in [data-checks.md](data-checks.md#google-ads-gaql) and save the result in the ads-conversions shape. Without access, the Google Ads side of G16 is NOT CHECKED.
- BigQuery: the BigQuery MCP server's `execute_sql_readonly` (SELECT only, one statement per call), or console queries the user runs. Two tools are not read-only: `execute_sql` and `cancel_job` (the tool pages mark both Read Only Hint false and Destructive Hint true, checked 2026-11-06). Deny both in the client, and ask the admin to block them with an IAM deny policy; a denied tool can still appear in the tool list. Read the argument names from the client's tool schema: Google's pages write them in two styles. Save results in the bq-results shape.

## When reads fail

- 401: the token expired or was revoked. Mint fresh read-only credentials; never ask for a token in chat.
- 403 on an Admin endpoint: the identity lacks access to the property, the API is not enabled in the quota project, or no quota project was passed (`--quota-project`). The rule becomes NOT CHECKED with the status.
- 404 on a v1alpha endpoint: the method moved or is not available to this property. Record it, mark the rule NOT CHECKED, and check the Admin API changelog.
- 429 or quota errors: the script retries once, then records the endpoint. Lower `--max-custom-dims` or run reports later; do not loop.
- Partial snapshot (exit 3): run the audit anyway; the report lists every unreadable endpoint in the baseline.

## Other near misses

- A cross-domain or single-page-app journey needs end-to-end proof: cross-domain-and-spa-qa (planned).
- Choosing which outcomes become key events or Google Ads primary conversions: conversion-goals-and-key-events (planned).
- Building or modelling the BigQuery export: ga4-bigquery-export-and-sessions (planned).
- Google Ads shows conversion warnings: google-ads-conversion-diagnostics (planned); until then the Google Ads diagnostics pages.
- Setting up the GA4 MCP connection itself: marketing-mcp-setup.
- App stream attribution and SKAdNetwork: mobile-attribution-fundamentals.
- Recurring monitoring and alerting: tracking-monitoring-and-incidents (planned); rerun this audit as the baseline.
