diff --git a/AGENTS.md b/AGENTS.md index d920d12..c5c3a45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ ga4 properties get --property-id [-o json|table|csv] ### Reports (Data API) ``` -ga4 reports run --property-id --metrics --start-date --end-date [--dimensions ] [--limit ] [--offset ] [--order-by ] [-o json|table|csv] +ga4 reports run --property-id --metrics --start-date --end-date [--dimensions ] [--limit ] [--offset ] [--order-by ] [--dimension-filter ] [--metric-filter ] [--include-metadata] [-o json|table|csv] ``` ### Realtime Reports (Data API) @@ -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 @@ -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 diff --git a/src/__tests__/reports.test.ts b/src/__tests__/reports.test.ts index f0094a7..6ed5c38 100644 --- a/src/__tests__/reports.test.ts +++ b/src/__tests__/reports.test.ts @@ -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( + `${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( + `${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( + `${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') diff --git a/src/commands/reports.ts b/src/commands/reports.ts index 1a075ae..8a6a36d 100644 --- a/src/commands/reports.ts +++ b/src/commands/reports.ts @@ -31,6 +31,11 @@ interface RunReportResponse { metadata: { currencyCode?: string; timeZone?: string; + samplingMetadatas?: Array<{ + samplesReadCount: string; + samplingSpaceSize: string; + }>; + dataLossFromOtherRow?: boolean; }; } @@ -50,6 +55,9 @@ export function registerReportsCommands(program: Command): void { .option('--limit ', 'Maximum number of rows to return', '100') .option('--offset ', 'Row offset for pagination', '0') .option('--order-by ', 'Field to order by (prefix with "-" for descending)') + .option('--dimension-filter ', 'Dimension filter as JSON (GA4 REST API format)') + .option('--metric-filter ', 'Metric filter as JSON (GA4 REST API format)') + .option('--include-metadata', 'Include metadata envelope in output (rows, metadata, rowCount)') .option('--access-token ', 'Access token for authentication') .option('-o, --output ', 'Output format (json, table, csv)', 'json') .option('-q, --quiet', 'Suppress non-essential output') @@ -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; @@ -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(