@nytka/plugin-ga4
v0.2.3
Published
Google Analytics 4 connector for nytka projects. Collects report data into datasets/ and registers it with provenance.
Downloads
25
Readme
@nytka/plugin-ga4
Google Analytics 4 connector for nytka projects. Collects report data into the project's
datasets/ and registers it in datasets/index.json with provenance.
Read-only — it holds analytics.readonly and can write nothing back to Google.
Install
npm install @nytka/plugin-ga4Two dependencies: @google-analytics/data, and @nytka/core, which has no dependencies of its
own and holds the project plumbing this package used to carry a private copy of.
Already using another @nytka Google connector?
Then you need no new key. @nytka/plugin-gsc and this package both read
GOOGLE_SERVICE_ACCOUNT_KEY — one service account, one JSON file, every Google connector.
Skip to step 1 (enable the API for GA4) and step 5 (grant that same account access to the
GA4 property), and ignore steps 2–4 entirely.
Setup
About ten minutes, once per project. Auth is a service account — no consent screen, no refresh tokens, works headless. The cost is step 5: the account must be granted access to each property by hand, because Cloud IAM does not do it for you.
1. Enable the API
https://console.cloud.google.com/apis/library/analyticsdata.googleapis.com → Enable.
Per Cloud project. Enabling the Search Console API does not enable this one — they are separate APIs even on the same Cloud project and the same key.
2. Create the service account
IAM & Admin → Service accounts → Create service account (https://console.cloud.google.com/iam-admin/serviceaccounts)
Name it something like <project>-google. Skip both optional steps — Permissions and
Principals with access. No IAM role is needed.
Do not go via APIs & Services → Credentials. That route pushes you into the OAuth consent screen, asking for Internal/External audience and test users. Service accounts never use it: OAuth is for acting on a human's behalf, and a service account is its own identity. If you are being asked about an audience, you are in the wrong place.
3. Download the key
Open the account → Keys → Add key → Create new key → JSON.
4. Move it into the project and rename it
mv ~/Downloads/<project>-<hash>.json <project>/private/google-service-account.jsonThe rename matters. Google names the download after the Cloud project and a key fingerprint —
acme-503822-bd4313339dc2.json. Rotate the key and the next file has a different hash, so
anything referencing the old name breaks. A fixed name means rotation is dropping a file in
place. Nothing is lost: project_id, private_key_id and client_email are all inside the
JSON.
The name is google-service-account.json, not ga4-…, because the same file serves every
Google connector in the project.
private/ is gitignored by SPEC §3, so credentials sit in the same predictable place in every
nytka project.
5. Grant it access to the property — this is the step that differs
grep client_email <project>/private/google-service-account.jsonhttps://analytics.google.com → Admin (bottom-left gear) → under the Property column,
Property access management → + (top right) → Add users → paste the client_email
→ role Viewer → untick Notify new users by email (a service account has no inbox) →
Add.
Three things people get wrong here, all of which produce a PERMISSION_DENIED that reads like
a broken key:
- Property access management, not Account access management. They sit in different columns of the same Admin screen. Account-level access does cascade down, but property-level is the narrower grant and the one to prefer.
- Viewer is enough. There is no read-only role below it, and nothing this connector does needs more.
- It is per property. Adding the email to one property grants nothing on the next one.
6. Configure
One .env at the project root. Every key for the project lives there and nowhere else.
The names below ship with the package too, so recovering them later never means re-reading this guide:
cat node_modules/@nytka/plugin-ga4/.env.example >> .envGOOGLE_SERVICE_ACCOUNT_KEY=private/google-service-account.json
GA4_PROPERTY=123456789GOOGLE_SERVICE_ACCOUNT_KEY is a path, not a key. Relative paths resolve from the project
root, so they work from any subdirectory; absolute paths also work.
GA4_PROPERTY is the numeric property id. Both 123456789 and properties/123456789 are
accepted and normalised. Where to find it:
- GA4 → Admin → Property settings — shown as PROPERTY ID at the top right, or
- the URL while you are looking at the property:
…/p123456789/…— the digits afterp.
It is not the Measurement ID (G-XXXXXXX), not the stream id, and not the account id.
Using the Measurement ID is the single most common misconfiguration; the connector rejects it
with a message saying so rather than failing at the API.
7. Verify
npx nytka-ga4 propertiesReporting the property as readable means every step above worked.
Unlike Search Console, the Data API has no endpoint that lists your properties — property
discovery lives in the separate Admin API, and this package will not pull in a second Google
dependency for one command. So properties validates what you configured instead of
discovering it for you, which is why step 6 asks you to copy the id out of the UI by hand.
Use
npx nytka-ga4 properties # auth smoke test — checks $GA4_PROPERTY is readable
npx nytka-ga4 monthly # monthly sessions/users/views, last 16 months
npx nytka-ga4 query --dimensions pagePath --metrics screenPageViews --limit 1000
npx nytka-ga4 monthly --no-register # write the payload, leave the registry alonemonthly uses the yearMonth dimension with sessions, totalUsers and screenPageViews.
Month is written as the 1st of the month (2026-01-01) rather than the API's 202601, so it
sorts, parses as a date, and joins against the @nytka/plugin-gsc monthly series on the same
column.
Several properties
A project may span properties. Comma-separate them, and each becomes its own dataset:
GA4_PROPERTY=123456789, 987654321One service account can read all of them — add its client_email to each property (step 5).
The property is part of every dataset id, so two properties over the same dates never collide.
Programmatic:
import { run, collect, checkProperties } from '@nytka/plugin-ga4'
const { id, rowCount, rawPath } = await run({ kind: 'monthly' })run() returns counts and paths, never rows.
Troubleshooting
| What you see | What it means |
|---|---|
| service-account key not found at … | The path in .env does not match the file. Usually the download was moved but not renamed — see step 4 |
| GOOGLE_SERVICE_ACCOUNT_KEY is not set | Not in .env, or you used GA4_SERVICE_ACCOUNT_KEY — the var is deliberately shared across Google connectors |
| no project.yaml found walking up from cwd | Not inside a nytka project. cd to the project root |
| … is not a GA4 property id | GA4_PROPERTY holds a Measurement ID (G-…) or a stream id. Use the numeric property id — see step 6 |
| PERMISSION_DENIED / 403 | Step 5 was skipped, or done on a different property, or on Account rather than Property access management |
| NOT_FOUND / Property with id … not found | The id is wrong, or the property was deleted. It is not an access problem |
| Google Analytics Data API has not been used in project … | Step 1 not done, or the key belongs to a different Cloud project. Enabling the Search Console API does not enable this one |
| Runs fine, returns 0 rows | The property has no data in the window, or it is a brand-new property still filling |
| Last day or two missing | Expected. GA4 processing runs up to 48h behind; ranges already end 2 days back |
| note: … "(other)" row | The property hit its cardinality limit for that dimension. Totals hold, the breakdown does not — narrow the dimensions or shorten the range |
What it writes
| Path | Committed? |
|---|---|
| datasets/payloads/<id>.json | no — the directory ships its own .gitignore |
| datasets/index.json | yes — one entry, added or replaced |
The dataset id
monthly ga4-monthly-123456789-2025-04-to-2026-07
└──────┬───────┘ reporting period, month granularity
query ga4-date-country-123456789-2025-04-01-to-2026-07-26
└────┬─────┘ └────────┬─────────┘ the exact window fetched
dimensionsmonthly names the reporting period; dateRange keeps the exact window fetched. Those are
different things on purpose. A collection runs up to 2 days back, so a window-keyed id moved
every day — and the registry matches on id, so running monthly daily for a month left thirty
near-identical entries describing one series. At month granularity the same month collected
twice replaces in place, and a new month is genuinely a new entry.
query keys the exact window instead, and that difference is deliberate. The id follows
the command's lifecycle, not the code path: monthly recurs on a schedule and must be
idempotent, while query is ad-hoc — two sub-periods of one month with the same dimensions are
two different questions, and a shared id would silently replace the first result and its
payload. Extra entries are a mess you can see and delete; a destroyed answer is not.
The monthly form changed in 0.2.0, which is why that release is a minor bump and not a
patch. Entries written by 0.1.0 carry the old ga4-monthly-…-2025-04-01-to-2026-07-26 form and
will not be matched by a 0.2.0 run; it appends a new entry beside them. Delete the old ones
once the new collection looks right. query ids are unchanged from 0.1.0.
@nytka/plugin-gsc uses the identical convention, so the two connectors' entries sort and read
together.
The registry writer preserves the file's existing formatting: adding one dataset produces a
one-entry diff, not a reformat of every entry already there — including entries written by
other @nytka connectors into the same file.
Each payload also carries an apiMetadata block — the property's reporting timeZone, plus
dataLossFromOtherRow and subjectToThresholding — so a number can be audited later against
the conditions it was collected under.
Rules it follows
- Payloads never enter agent context. Query them with a script; write conclusions to
research/. A dataset is evidence, a research item is knowledge. The CLI prints row counts and paths for this reason. - The project is found by walking up for
project.yaml, so it works at any install depth. - No YAML parsing, no config file. Secrets from
.env, everything else from flags. - Dates are written in the local calendar, never UTC-stamped. West of Greenwich, an evening run would otherwise record tomorrow's date.
Limits worth knowing
- GA4 processing runs up to 48 hours behind. Ranges end 2 days back by default; including today gives a partial day that reads as a traffic collapse.
- 250,000 rows per request; paging is automatic.
- Cardinality limits. High-cardinality dimensions (
pagePath,sessionSource) pool rare values into an(other)row once the property's limit is hit. The CLI says so when it happens, and the flag is recorded in the payload. Inmonthly,(other)is dropped rather than counted — it is not a month, and adding its sessions to a real month would be a fabricated number.dataLossFromOtherRowis how you find out it happened. - Any
yearMonthvalue that is neitherYYYYMMnor(other)fails the collection rather than being bucketed under a guess. A wrong number is worse than a failure, because the failure is visible. - Thresholding. With Google Signals on, GA4 withholds rows that could identify individuals in low-volume reports, so a breakdown may not sum to its total. Expected, not a bug.
- The 16-month default window is chosen to line up with
@nytka/plugin-gsc, not an API limit — GA4's standard aggregated reports go back further than Search Console's hard 16 months. Pass--fromto reach further back. (The GA4 data-retention setting governs event-level data in Explorations, not these reports.) - GA4 sessions are not Search Console clicks. Comparing the two series is useful; treating them as the same measurement is not.
Tests
npm testnode --test against recorded API responses. No credential, no network, no client data.
