Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ ga4 properties get --property-id <id> [-o json|table|csv]
### Reports (Data API)

```
ga4 reports run --property-id <id> --metrics <m1,m2> --start-date <date> --end-date <date> [--dimensions <d1,d2>] [--limit <n>] [--offset <n>] [--order-by <field>] [-o json|table|csv]
ga4 reports run --property-id <id> --metrics <m1,m2> --start-date <date> --end-date <date> [--dimensions <d1,d2>] [--limit <n>] [--offset <n>] [--order-by <field>] [--dimension-filter <json>] [--metric-filter <json>] [--include-metadata] [-o json|table|csv]
```

### Realtime Reports (Data API)
Expand Down Expand Up @@ -91,6 +91,19 @@ ga4 reports run \
--output table
```

### Run a filtered report (page path + event)

```bash
ga4 reports run \
--property-id 123456 \
--metrics eventCount \
--dimensions date \
--start-date 30daysAgo \
--end-date yesterday \
--dimension-filter '{"andGroup": {"expressions": [{"filter": {"fieldName": "pagePath", "stringFilter": {"matchType": "CONTAINS", "value": "/pricing"}}}, {"filter": {"fieldName": "eventName", "stringFilter": {"matchType": "EXACT", "value": "form_submit"}}}]}}' \
--include-metadata -q
```

### Check realtime active users by country

```bash
Expand Down Expand Up @@ -139,6 +152,28 @@ All commands return arrays of flat objects:

Report values are always strings (GA4 API returns strings for all values).

### JSON output with `--include-metadata`

When `--include-metadata` is passed to `reports run`, output wraps rows with metadata:

```json
{
"rows": [
{"city": "New York", "activeUsers": "1234"}
],
"metadata": {
"currencyCode": "USD",
"timeZone": "America/New_York",
"samplingMetadatas": [
{"samplesReadCount": "500000", "samplingSpaceSize": "1000000"}
]
},
"rowCount": 1
}
```

Use this when you need sampling information (e.g., MDE calculator workflows).

### Properties list JSON

```json
Expand Down
163 changes: 163 additions & 0 deletions src/__tests__/reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,169 @@ describe('reports commands', () => {
).rejects.toThrow(HttpError);
});

it('passes dimension filter through to API request body', async () => {
const dimensionFilter = {
filter: {
fieldName: 'pagePath',
stringFilter: { matchType: 'CONTAINS', value: '/pricing', caseSensitive: false },
},
};

const mockResponse = {
dimensionHeaders: [{ name: 'pagePath' }],
metricHeaders: [{ name: 'totalUsers', type: 'TYPE_INTEGER' }],
rows: [
{
dimensionValues: [{ value: '/www.example.com/pricing' }],
metricValues: [{ value: '500' }],
},
],
rowCount: 1,
metadata: {},
};

const scope = nock(DATA_API_BASE)
.post('/v1beta/properties/123456:runReport', (body) => {
return (
body.dimensionFilter?.filter?.fieldName === 'pagePath' &&
body.dimensionFilter?.filter?.stringFilter?.matchType === 'CONTAINS'
);
})
.reply(200, mockResponse);

const { request } = await import('../lib/http.js');
const data = await request<typeof mockResponse>(
`${DATA_API_BASE}/v1beta/properties/123456:runReport`,
{
method: 'POST',
headers: { Authorization: 'Bearer test-token-123' },
body: {
metrics: [{ name: 'totalUsers' }],
dimensions: [{ name: 'pagePath' }],
dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }],
dimensionFilter,
limit: 100,
offset: 0,
},
},
);

expect(data.rows).toHaveLength(1);
expect(data.rows[0].dimensionValues[0].value).toBe('/www.example.com/pricing');
expect(scope.isDone()).toBe(true);
});

it('passes and_group dimension filter through to API', async () => {
const dimensionFilter = {
andGroup: {
expressions: [
{
filter: {
fieldName: 'pagePath',
stringFilter: { matchType: 'CONTAINS', value: '/pricing' },
},
},
{
filter: {
fieldName: 'eventName',
stringFilter: { matchType: 'EXACT', value: 'form_submit' },
},
},
],
},
};

const mockResponse = {
dimensionHeaders: [{ name: 'date' }],
metricHeaders: [{ name: 'eventCount', type: 'TYPE_INTEGER' }],
rows: [
{
dimensionValues: [{ value: '20240115' }],
metricValues: [{ value: '12' }],
},
],
rowCount: 1,
metadata: {},
};

const scope = nock(DATA_API_BASE)
.post('/v1beta/properties/123456:runReport', (body) => {
return (
body.dimensionFilter?.andGroup?.expressions?.length === 2 &&
body.dimensionFilter.andGroup.expressions[0].filter.fieldName === 'pagePath' &&
body.dimensionFilter.andGroup.expressions[1].filter.fieldName === 'eventName'
);
})
.reply(200, mockResponse);

const { request } = await import('../lib/http.js');
const data = await request<typeof mockResponse>(
`${DATA_API_BASE}/v1beta/properties/123456:runReport`,
{
method: 'POST',
headers: { Authorization: 'Bearer test-token-123' },
body: {
metrics: [{ name: 'eventCount' }],
dimensions: [{ name: 'date' }],
dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }],
dimensionFilter,
limit: 100,
offset: 0,
},
},
);

expect(data.rows).toHaveLength(1);
expect(scope.isDone()).toBe(true);
});

it('returns metadata envelope when include-metadata is used', async () => {
const mockResponse = {
dimensionHeaders: [{ name: 'date' }],
metricHeaders: [{ name: 'totalUsers', type: 'TYPE_INTEGER' }],
rows: [
{
dimensionValues: [{ value: '20240115' }],
metricValues: [{ value: '250' }],
},
],
rowCount: 1,
metadata: {
currencyCode: 'USD',
timeZone: 'America/New_York',
samplingMetadatas: [
{ samplesReadCount: '500000', samplingSpaceSize: '1000000' },
],
},
};

const scope = nock(DATA_API_BASE)
.post('/v1beta/properties/123456:runReport')
.reply(200, mockResponse);

const { request } = await import('../lib/http.js');
const data = await request<typeof mockResponse>(
`${DATA_API_BASE}/v1beta/properties/123456:runReport`,
{
method: 'POST',
headers: { Authorization: 'Bearer test-token-123' },
body: {
metrics: [{ name: 'totalUsers' }],
dimensions: [{ name: 'date' }],
dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }],
limit: 100,
offset: 0,
},
},
);

// Verify sampling metadata is present in response
expect(data.metadata.samplingMetadatas).toHaveLength(1);
expect(data.metadata.samplingMetadatas![0].samplesReadCount).toBe('500000');
expect(data.metadata.samplingMetadatas![0].samplingSpaceSize).toBe('1000000');
expect(scope.isDone()).toBe(true);
});

it('handles auth error', async () => {
nock(DATA_API_BASE)
.post('/v1beta/properties/123456:runReport')
Expand Down
35 changes: 34 additions & 1 deletion src/commands/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ interface RunReportResponse {
metadata: {
currencyCode?: string;
timeZone?: string;
samplingMetadatas?: Array<{
samplesReadCount: string;
samplingSpaceSize: string;
}>;
dataLossFromOtherRow?: boolean;
};
}

Expand All @@ -50,6 +55,9 @@ export function registerReportsCommands(program: Command): void {
.option('--limit <n>', 'Maximum number of rows to return', '100')
.option('--offset <n>', 'Row offset for pagination', '0')
.option('--order-by <field>', 'Field to order by (prefix with "-" for descending)')
.option('--dimension-filter <json>', 'Dimension filter as JSON (GA4 REST API format)')
.option('--metric-filter <json>', 'Metric filter as JSON (GA4 REST API format)')
.option('--include-metadata', 'Include metadata envelope in output (rows, metadata, rowCount)')
.option('--access-token <token>', 'Access token for authentication')
.option('-o, --output <format>', 'Output format (json, table, csv)', 'json')
.option('-q, --quiet', 'Suppress non-essential output')
Expand Down Expand Up @@ -77,6 +85,24 @@ export function registerReportsCommands(program: Command): void {
offset: parseInt(options.offset),
};

if (options.dimensionFilter) {
try {
body.dimensionFilter = JSON.parse(options.dimensionFilter);
} catch {
console.error('Error: --dimension-filter must be valid JSON');
process.exit(1);
}
}

if (options.metricFilter) {
try {
body.metricFilter = JSON.parse(options.metricFilter);
} catch {
console.error('Error: --metric-filter must be valid JSON');
process.exit(1);
}
}

if (options.orderBy) {
const desc = options.orderBy.startsWith('-');
const fieldName = desc ? options.orderBy.slice(1) : options.orderBy;
Expand Down Expand Up @@ -120,7 +146,14 @@ export function registerReportsCommands(program: Command): void {
console.error(`Rows returned: ${rows.length} (total: ${data.rowCount ?? rows.length})`);
}

printOutput(rows, format);
if (options.includeMetadata) {
printOutput(
{ rows, metadata: data.metadata ?? {}, rowCount: data.rowCount ?? rows.length },
format,
);
} else {
printOutput(rows, format);
}
} catch (error) {
if (error instanceof HttpError) {
printError(
Expand Down