ga4-cli
v0.1.0
Published
GA4 Measurement Protocol simulator for synthetic user data
Maintainers
Readme
ga4-cli
TypeScript toolkit for generating realistic GA4 Measurement Protocol traffic and managing GA4 properties via the Admin API — from your terminal or as a library.
Feature Overview
| Feature | What it gives you | Main API |
| --- | --- | --- |
| Real-time simulation | Run concurrent synthetic users through journeys with optional jitter and callbacks | ScenarioRunner |
| Historical backfill | Replay sessions across past windows (up to GA4's 72h MP limit) with virtual time | BackfillRunner, hoursAgo, minutesAgo |
| Persona modeling | Mix behavior patterns using weighted journey selection (e.g. 70/30) | Persona, pickJourney |
| Typed event builders | Compose GA4 event payloads safely with TypeScript types | pageView, viewItem, addToCart, purchase, ... |
| Session/user helpers | Automatically attach client_id, session_id, engagement time, campaign context | User, Session |
| Reliable transport | Timeout + retry handling (429/5xx retries, network-safe failure shape) | MeasurementProtocolClient |
| Debug-first workflow | Validate payloads before production sends to avoid polluting analytics | endpoint: "debug" |
| Admin API | List accounts, properties, data streams, MP secrets, custom dimensions, key events | GA4AdminClient |
| Data API | Run reports, realtime queries, pivot reports, metadata lookups, audience exports | GA4DataClient |
How simulation works
Journeys and steps
A Journey is a sequence of steps that represents one user's visit. Each step sends a single GA4 event and optionally waits before the next one:
const journey: Journey = {
name: "checkout-flow",
steps: [
step(pageView("/"), 1000), // send page_view, wait 1 s
step(addToCart(...), 500), // send add_to_cart, wait 500 ms
step(beginCheckout(...), 1500), // send begin_checkout, wait 1.5 s
step(purchase(...)), // send purchase, no wait
],
};Real-time simulation (ScenarioRunner)
For each simulated user ScenarioRunner:
- Generates a synthetic identity — random
client_id(UUID) andsession_id(epoch seconds) - Walks through the journey steps in order, honouring the
delayMsAfterbetween steps as real async sleeps - Sends each step as an
MPPayloadto GA4's Measurement Protocol endpoint - Runs up to
--concurrencyusers in parallel (default: 5)
Historical backfill (BackfillRunner)
BackfillRunner runs the same journeys but with virtual time — instead of sleeping between steps, it calculates timestamps that spread users evenly across the requested time window and stamps each event with timestamp_micros. This means a 24-hour backfill of 200 users finishes in seconds rather than hours.
GA4 accepts events up to 72 hours in the past via timestamp_micros. The runner validates this window before starting.
Personas (weighted randomness)
A Persona wraps multiple journeys with probability weights so different users follow different paths:
const persona: Persona = {
name: "shopper",
journeys: [
{ weight: 0.7, journey: browseOnly }, // 70% of users just browse
{ weight: 0.3, journey: browseAndBuy }, // 30% complete a purchase
],
};Each user randomly picks a journey according to the weights before their session starts.
The Measurement Protocol payload
Every event is an HTTP POST to Google's endpoint:
{
"client_id": "a1b2c3d4-...",
"timestamp_micros": 1711234567000000,
"events": [{
"name": "purchase",
"params": { "transaction_id": "txn-1", "currency": "USD", "value": 49.99 }
}]
}With --endpoint debug (the default) requests go to the validation endpoint — GA4 returns any payload errors but writes no data. Switch to --endpoint production only when you're ready to record real traffic.
Session bootstrap and attribution (--bootstrap)
By default, client_id and session_id are generated randomly in code. GA4 has no record of a real session starting, so traffic-source attribution (source / medium / campaign) will not appear correctly in reports.
Passing --bootstrap fixes this by spinning up a real headless browser for each user:
- A local HTTP server serves a minimal HTML page with the
gtag.jssnippet embedded - Puppeteer launches headless Chromium and navigates to that page (with UTM params appended if a campaign is configured)
- The real
gtag.jsfires, GA4 assigns aclient_idandsession_id, and sets the_ga/_ga_XXXXXXcookies - The cookies are read to extract those real IDs
- The browser context is closed, and the IDs are used for all subsequent synthetic events in that user's journey
This anchors each synthetic session to a legitimate GA4 session origin, so attribution works correctly. For batch runs a single shared browser is reused — each user gets an isolated incognito context to keep cookies separate, avoiding the cost of launching a new browser per user.
Note: GA4 may silently discard hits sent from headless browsers, even when the HTTP response is 2xx. There is no error from GA4 — events simply never appear in reports. This is likely caused by bot-detection heuristics on Google's side and is the most common reason for missing sessions when using --bootstrap.
--bootstrap requires Puppeteer: npm install puppeteer.
Authentication
Measurement Protocol (sim commands) authenticates with a plain API secret — no OAuth needed. Set GA4_API_SECRET in your .env.
Admin API and Data API (admin / data commands) use Google Application Default Credentials (ADC). Two auth methods are supported:
- User account — interactive OAuth via
gcloud. Best for local development. - Service account — JSON key file. Best for CI, automation, and production environments.
One login covers both the Admin API and the Data API — no separate setup is needed for each.
Getting Started
1. Install
Option A (recommended): install as a CLI package
npm install -g ga4-cli
ga4 --helpOr run without global install:
npx ga4-cli --helpOption B: install from source
git clone https://github.com/Liscor/ga4-cli.git
cd ga4-cli
npm install
npm run build
npm link # makes `ga4` available globally2. Configure Measurement Protocol credentials
The sim commands need a GA4 Measurement ID and API secret. The interactive init wizard sets these up:
ga4 sim initThis creates a .env file with GA4_MEASUREMENT_ID, GA4_API_SECRET, and GA4_ENDPOINT.
You can also pass them as flags (--measurement-id, --api-secret) or set the environment variables directly.
3. Set up credentials (for Admin API and Data API)
Choose the method that fits your environment.
Option A: User account (local development)
Requires the Google Cloud CLI.
- Go to Google Cloud Console > APIs & Services > Credentials
- If prompted, click "Configure consent screen":
- Choose External user type
- Fill in the app name (e.g.
ga4-cli) and your email - Skip scopes and test users, save
- Back on the Credentials page, click "Create Credentials" > "OAuth client ID"
- Application type: Desktop app
- Name: anything (e.g.
ga4-cli)
- Click Download JSON to save the client secret file
- Enable both APIs in your project:
- Google Analytics Admin API → Enable
- Google Analytics Data API → Enable
Option B: Service account (CI / automation)
No browser or gcloud required.
- Go to Google Cloud Console > IAM & Admin > Service Accounts
- Click "Create Service Account", give it a name and click Done
- Open the service account, go to Keys > Add Key > Create new key > JSON — download the file
- In Google Analytics, go to Admin > Account/Property Access Management and add the service account email with at least Viewer role
- Enable both APIs in your GCP project:
- Google Analytics Admin API → Enable
- Google Analytics Data API → Enable
4. Authenticate
User account:
ga4 auth login --client-id-file /path/to/client_secret.jsonThis opens a browser for Google OAuth with the correct Analytics API scopes. Credentials are stored locally by gcloud — you only need to do this once.
Service account:
ga4 auth login --service-account /path/to/sa.jsonThis validates the key file and confirms Analytics API access. Then export the path so subsequent commands pick it up:
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/sa.json"Add the export to your shell profile (~/.zshrc, ~/.bashrc) to persist it, or set it in your CI environment variables.
5. Verify everything works
ga4 sim doctor # check Measurement Protocol setup
ga4 auth status # check Admin API credentials
ga4 admin accounts # list your GA4 accountsConfig file
Instead of passing every flag on the command line, you can put your simulation settings in a JSON file. By default ga4 sim run and ga4 sim send look for ga4.config.json in the current directory. Pass a different path with --config <path>.
CLI flags always win — the config file only fills in values you didn't explicitly pass.
Example: ga4.config.json
{
"measurementId": "G-XXXXXXXXXX",
"apiSecret": "your-api-secret-here",
"endpoint": "debug",
"users": 50,
"concurrency": 5,
"journeys": [
{
"template": "ecommerce-funnel",
"weight": 40,
"campaign": { "source": "google", "medium": "cpc", "campaign": "spring-sale" }
},
{
"template": "content-browse",
"weight": 35,
"campaign": { "source": "newsletter", "medium": "email", "campaign": "weekly-digest" }
},
{
"template": "search-and-discover",
"weight": 25,
"campaign": { "source": "organic", "medium": "search" }
}
],
"backfill": { "hours": 24 },
"userProperties": { "plan": "free", "region": "us-east" }
}A starter file with all available options is included in the repo as ga4.config.example.json.
Config reference
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| measurementId | string | env GA4_MEASUREMENT_ID | GA4 Measurement ID (G-XXXXXX) |
| apiSecret | string | env GA4_API_SECRET | Measurement Protocol API secret |
| endpoint | "debug" | "production" | "debug" | Validation only vs real writes |
| users | integer | 10 | Number of simulated users |
| concurrency | integer | 5 | Max users running in parallel |
| dryRun | boolean | false | Skip all HTTP requests |
| bootstrap | boolean | false | Use Puppeteer for real attribution |
| minIntervalMs | number | — | Minimum ms between requests |
| template | string | — | Shorthand for a single journey (ignored when journeys is set) |
| journeys | array | — | Weighted journey mix (see below) |
| backfill | object | — | Run as a historical backfill |
| userProperties | object | — | User-scoped string properties on every event |
journeys[]
| Key | Type | Description |
|-----|------|-------------|
| template | string | Built-in template name (e.g. "ecommerce-funnel") |
| weight | number | Relative traffic share (weights are normalised automatically) |
| campaign | object | UTM params for users on this journey (source, medium, campaign, term, content) |
When journeys contains more than one entry, or any entry has a weight, a Persona is created automatically and users are randomly assigned based on the weights. A single entry with no weight runs that journey for all users.
backfill
| Key | Type | Description |
|-----|------|-------------|
| hours | number | Shorthand: backfill the last N hours |
| start | string | ISO 8601 start of window (mutually exclusive with hours) |
| end | string | ISO 8601 end of window (defaults to now) |
Using the config programmatically
import { loadSimConfig, buildTargetFromConfig } from "ga4-cli";
const config = loadSimConfig("ga4.config.json");
const target = buildTargetFromConfig(config, (name) => {
// map template names to your own Journey objects
if (name === "my-journey") return myJourney;
throw new Error(`Unknown template: ${name}`);
});
await new ScenarioRunner(client, target).run({ users: config.users ?? 10 });CLI Reference
Full CLI command reference lives here: docs/cli-reference.md.
ga4 <command> [subcommand] [options]
Commands:
auth Authenticate with Google for Admin API access
sim Simulate GA4 traffic via the Measurement Protocol
admin Manage GA4 properties via the Admin API
data Query GA4 reports via the Data APISee docs/cli-reference.md for all subcommands, options, and examples.
Library Usage
Minimal example
import {
MeasurementProtocolClient,
ScenarioRunner,
step,
pause,
pageView,
type Journey,
} from "ga4-cli";
const client = new MeasurementProtocolClient({
measurementId: process.env.GA4_MEASUREMENT_ID!,
apiSecret: process.env.GA4_API_SECRET!,
endpoint: "debug", // use production only when ready
});
const journey: Journey = {
name: "landing-flow",
steps: [
step(pageView("https://example.com/", { title: "Home" })),
pause(1000),
step(pageView("https://example.com/pricing", { title: "Pricing" })),
],
};
const runner = new ScenarioRunner(client, journey);
await runner.run({ users: 25, concurrency: 5 });Personas (weighted behavior)
import {
ScenarioRunner,
pageView,
viewItem,
purchase,
step,
pause,
type Journey,
type Persona,
} from "ga4-cli";
const browseOnly: Journey = {
name: "browse-only",
steps: [
step(pageView("https://example.com/")),
pause(800),
step(viewItem("USD", 59.99, [{ item_id: "sku-1", item_name: "Hoodie" }])),
],
};
const browseAndBuy: Journey = {
name: "browse-and-buy",
steps: [
...browseOnly.steps,
pause(1200),
step(
purchase("txn-1", "USD", 59.99, [{ item_id: "sku-1", item_name: "Hoodie" }])
),
],
};
const persona: Persona = {
name: "organic-shopper",
journeys: [
{ journey: browseOnly, weight: 7 },
{ journey: browseAndBuy, weight: 3 },
],
};
await new ScenarioRunner(client, persona).run({ users: 100, concurrency: 10 });Backfill
Backfill runs with virtual time — pauses advance timestamps without slowing execution.
import {
BackfillRunner,
hoursAgo,
step,
pause,
pageView,
type Journey,
} from "ga4-cli";
const journey: Journey = {
name: "recent-journey",
steps: [
step(pageView("https://example.com/")),
pause(3000),
step(pageView("https://example.com/product")),
],
};
const runner = new BackfillRunner(client, journey);
await runner.run({
start: hoursAgo(24),
users: 200,
concurrency: 20,
});Backfill constraints:
startmust be beforeendendmust be in the paststartcannot be older than 72 hours (GA4 MP drop window)- Journey duration must fit into the selected time window
Admin API (library)
import { GA4AdminClient } from "ga4-cli";
const admin = new GA4AdminClient();
const accounts = await admin.listAccountSummaries();
const streams = await admin.listDataStreams("123456789");
const secrets = await admin.listMeasurementProtocolSecrets("123456789", "1234567");
const dims = await admin.listCustomDimensions("123456789");
const keyEvents = await admin.listKeyEvents("123456789");Data API (library)
import { GA4DataClient } from "ga4-cli";
const data = new GA4DataClient();
const report = await data.runReport({
property: "123456789",
metrics: [{ name: "activeUsers" }, { name: "sessions" }],
dimensions: [{ name: "city" }],
dateRanges: [{ startDate: "28daysAgo", endDate: "today" }],
});
const realtime = await data.runRealtimeReport({
property: "123456789",
metrics: [{ name: "activeUsers" }],
});Client reliability settings
const client = new MeasurementProtocolClient({
measurementId: "G-XXXX",
apiSecret: "secret",
endpoint: "debug",
requestTimeoutMs: 10_000, // default
maxRetries: 2, // default
retryBaseDelayMs: 250, // default, exponential backoff
});Retry behavior:
- Retries on
429and5xx - Does not retry most
4xx - Network-level failures return
status: 0
Requirements
- Node.js 18+ (uses global
fetchandAbortController) - GA4 Measurement ID + API secret (for
simcommands) - User account: Google Cloud CLI + OAuth client (for
admin/datacommands) - Service account: JSON key file +
GOOGLE_APPLICATION_CREDENTIALSenv var (foradmin/datacommands)
Scripts
npm run build— Compile todist/npm run typecheck— TypeScript checks (no emit)npm run lint— ESLintnpm run lint:fix— ESLint with auto-fixnpm test— Jest test suitenpm run test:coverage— Jest with coverage reportnpm run test:watch— Jest in watch mode
Project structure
src/
core/
admin.ts # GA4 Admin API client
data.ts # GA4 Data API client (reports, realtime, audiences)
client.ts # GA4 Measurement Protocol transport
scenario.ts # real-time runner + journey steps
backfill.ts # historical runner + window validation
session-bootstrap.ts # Puppeteer-based session bootstrapping
user.ts # user/session payload construction
events.ts # typed GA4 event builders
persona.ts # weighted journey selection
types.ts # shared types
templates/ # built-in journey templates + personas
cli.ts # CLI entry point
config.ts # env var loading
index.ts # package exports
__tests__/ # Jest tests
bin/
ga4.js # CLI binary
deploy/ # Terraform + Docker for GCP deployment
functions/ # Google Cloud Functions entry pointSafety tips
- Start in
debugmode and inspect validation responses first. - Move to
productiononly when payloads are valid. - Use lower
users+concurrencyfor first production dry runs.
Read-only mode (GA4_READ_ONLY)
Set the GA4_READ_ONLY environment variable to prevent all write, delete, and mutating operations. Read-only commands (admin accounts, admin properties, data report, sim doctor, sim list-templates, etc.) continue to work normally.
# any truthy value enables read-only mode
export GA4_READ_ONLY=true
ga4 admin accounts # ✓ works
ga4 data report --property 123 # ✓ works
ga4 admin delete-property ... # ✗ blocked
ga4 sim run ... # ✗ blockedBlocked commands exit with:
Error: write operations are disabled (GA4_READ_ONLY is set).Use with AI agents
When an AI agent (e.g. Claude Code) has access to the CLI, set this variable in the agent's environment so it can query analytics data but cannot modify or delete anything.
Claude Code — add to .claude/settings.json:
{
"env": {
"GA4_READ_ONLY": "true"
}
}Docker — set in the container environment:
ENV GA4_READ_ONLY=trueTroubleshooting
Missing required env var: ensure.envhasGA4_MEASUREMENT_IDandGA4_API_SECRET, or runga4 sim init.- No data in GA4: confirm
--endpoint productionand check property config. - Validation errors in debug mode: inspect returned validation messages and adjust event params.
PERMISSION_DENIEDon admin commands: for user accounts, runga4 auth login --client-id-file <path>to re-authenticate; for service accounts, ensure the service account email has been added to your GA4 property with at least Viewer access.This app is blocked: you need to create your own OAuth Desktop client — see Set up credentials.- Service account not working: verify
GOOGLE_APPLICATION_CREDENTIALSis set and points to a valid file, then runga4 auth status.
Contributing
Contributions are welcome.
- Start with CONTRIBUTING.md
- Follow CODE_OF_CONDUCT.md
- Use issue and PR templates in
.github/
Security
Please report vulnerabilities privately according to SECURITY.md.
