npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-ga4

Two 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.json

The 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.json

https://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 >> .env
GOOGLE_SERVICE_ACCOUNT_KEY=private/google-service-account.json
GA4_PROPERTY=123456789

GOOGLE_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 after p.

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 properties

Reporting 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 alone

monthly 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, 987654321

One 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
              dimensions

monthly 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. In monthly, (other) is dropped rather than counted — it is not a month, and adding its sessions to a real month would be a fabricated number. dataLossFromOtherRow is how you find out it happened.
  • Any yearMonth value that is neither YYYYMM nor (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 --from to 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 test

node --test against recorded API responses. No credential, no network, no client data.