google-analytics-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. ga_cli/__init__.py +8 -0
  2. ga_cli/api/__init__.py +0 -0
  3. ga_cli/api/client.py +142 -0
  4. ga_cli/auth/__init__.py +35 -0
  5. ga_cli/auth/credentials.py +126 -0
  6. ga_cli/auth/oauth.py +322 -0
  7. ga_cli/auth/service_account.py +155 -0
  8. ga_cli/commands/__init__.py +0 -0
  9. ga_cli/commands/access_bindings.py +254 -0
  10. ga_cli/commands/access_reports.py +201 -0
  11. ga_cli/commands/account_summaries.py +68 -0
  12. ga_cli/commands/accounts.py +297 -0
  13. ga_cli/commands/agent_cmd.py +776 -0
  14. ga_cli/commands/annotations.py +264 -0
  15. ga_cli/commands/audiences.py +223 -0
  16. ga_cli/commands/auth_cmd.py +205 -0
  17. ga_cli/commands/bigquery_links.py +309 -0
  18. ga_cli/commands/calculated_metrics.py +312 -0
  19. ga_cli/commands/channel_groups.py +223 -0
  20. ga_cli/commands/completions_cmd.py +55 -0
  21. ga_cli/commands/config_cmd.py +113 -0
  22. ga_cli/commands/custom_dimensions.py +272 -0
  23. ga_cli/commands/custom_metrics.py +305 -0
  24. ga_cli/commands/data_retention.py +153 -0
  25. ga_cli/commands/data_streams.py +277 -0
  26. ga_cli/commands/event_create_rules.py +250 -0
  27. ga_cli/commands/event_edit_rules.py +292 -0
  28. ga_cli/commands/firebase_links.py +142 -0
  29. ga_cli/commands/google_ads_links.py +225 -0
  30. ga_cli/commands/key_events.py +269 -0
  31. ga_cli/commands/mp_secrets.py +265 -0
  32. ga_cli/commands/properties.py +330 -0
  33. ga_cli/commands/property_settings.py +287 -0
  34. ga_cli/commands/reports.py +726 -0
  35. ga_cli/commands/upgrade_cmd.py +148 -0
  36. ga_cli/config/__init__.py +0 -0
  37. ga_cli/config/constants.py +61 -0
  38. ga_cli/config/store.py +115 -0
  39. ga_cli/main.py +110 -0
  40. ga_cli/utils/__init__.py +20 -0
  41. ga_cli/utils/describe.py +129 -0
  42. ga_cli/utils/dry_run.py +40 -0
  43. ga_cli/utils/errors.py +150 -0
  44. ga_cli/utils/output.py +209 -0
  45. ga_cli/utils/pagination.py +93 -0
  46. google_analytics_cli-0.1.0.dist-info/METADATA +321 -0
  47. google_analytics_cli-0.1.0.dist-info/RECORD +49 -0
  48. google_analytics_cli-0.1.0.dist-info/WHEEL +4 -0
  49. google_analytics_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,726 @@
1
+ """Report commands: run, realtime, build.
2
+
3
+ Uses the Analytics Data API v1beta.
4
+ """
5
+
6
+ import json
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import questionary
12
+ import typer
13
+
14
+ from ..api.client import get_data_alpha_client, get_data_client
15
+ from ..config.store import get_effective_value
16
+ from ..utils import console, handle_error, info, output, require_options, resolve_output_format
17
+
18
+ reports_app = typer.Typer(
19
+ name="reports", help="Run GA4 reports", no_args_is_help=True
20
+ )
21
+
22
+ # Fallback metrics/dimensions when metadata API is unavailable
23
+ _FALLBACK_METRICS = [
24
+ "sessions",
25
+ "users",
26
+ "newUsers",
27
+ "screenPageViews",
28
+ "eventCount",
29
+ "engagementRate",
30
+ "averageSessionDuration",
31
+ "conversions",
32
+ "totalRevenue",
33
+ ]
34
+
35
+ _FALLBACK_DIMENSIONS = [
36
+ "date",
37
+ "country",
38
+ "city",
39
+ "deviceCategory",
40
+ "operatingSystem",
41
+ "browser",
42
+ "sourceMedium",
43
+ "sessionDefaultChannelGroup",
44
+ "pagePath",
45
+ "pageTitle",
46
+ ]
47
+
48
+
49
+ def _transform_report_rows(result: dict) -> tuple[list[dict], list[str], list[str]]:
50
+ """Transform a GA Data API report response into a list of dicts.
51
+
52
+ Returns (rows, column_keys, column_headers).
53
+ """
54
+ dim_headers = [h.get("name", "") for h in result.get("dimensionHeaders", [])]
55
+ met_headers = [h.get("name", "") for h in result.get("metricHeaders", [])]
56
+
57
+ all_keys = dim_headers + met_headers
58
+ rows = []
59
+ for row in result.get("rows", []):
60
+ entry = {}
61
+ for i, name in enumerate(dim_headers):
62
+ entry[name] = row["dimensionValues"][i]["value"]
63
+ for i, name in enumerate(met_headers):
64
+ entry[name] = row["metricValues"][i]["value"]
65
+ rows.append(entry)
66
+
67
+ return rows, all_keys, all_keys
68
+
69
+
70
+ def _fetch_metadata(data_client, effective_property: str) -> tuple[list[str], list[str]]:
71
+ """Fetch available metrics and dimensions from the API metadata endpoint.
72
+
73
+ Returns (metrics, dimensions). Falls back to hardcoded lists on error.
74
+ """
75
+ try:
76
+ metadata = (
77
+ data_client.properties()
78
+ .getMetadata(name=f"properties/{effective_property}/metadata")
79
+ .execute()
80
+ )
81
+ metrics = [m["apiName"] for m in metadata.get("metrics", [])]
82
+ dimensions = [d["apiName"] for d in metadata.get("dimensions", [])]
83
+ return metrics, dimensions
84
+ except Exception:
85
+ return _FALLBACK_METRICS, _FALLBACK_DIMENSIONS
86
+
87
+
88
+ @reports_app.command("run")
89
+ def run_cmd(
90
+ property_id: Optional[str] = typer.Option(
91
+ None, "--property-id", "-p", help="Property ID (numeric)"
92
+ ),
93
+ metrics: str = typer.Option(
94
+ "sessions,users", "--metrics", "-m", help="Comma-separated metrics"
95
+ ),
96
+ dimensions: Optional[str] = typer.Option(
97
+ None, "--dimensions", "-d", help="Comma-separated dimensions"
98
+ ),
99
+ start_date: str = typer.Option("7daysAgo", "--start-date", help="Start date"),
100
+ end_date: str = typer.Option("today", "--end-date", help="End date"),
101
+ limit: int = typer.Option(100, "--limit", help="Max rows to return"),
102
+ output_format: Optional[str] = typer.Option(
103
+ None, "--output", "-o", help="Output format (json, table, compact)"
104
+ ),
105
+ ):
106
+ """Run a custom report."""
107
+ try:
108
+ effective_property = get_effective_value(property_id, "default_property_id")
109
+ require_options({"property_id": effective_property}, ["property_id"])
110
+ effective_format = resolve_output_format(output_format)
111
+
112
+ data = get_data_client()
113
+
114
+ body = {
115
+ "metrics": [{"name": m.strip()} for m in metrics.split(",")],
116
+ "dateRanges": [{"startDate": start_date, "endDate": end_date}],
117
+ "limit": limit,
118
+ }
119
+ if dimensions:
120
+ body["dimensions"] = [{"name": d.strip()} for d in dimensions.split(",")]
121
+
122
+ result = data.properties().runReport(
123
+ property=f"properties/{effective_property}",
124
+ body=body,
125
+ ).execute()
126
+
127
+ rows, columns, headers = _transform_report_rows(result)
128
+ row_count = result.get("rowCount", len(rows))
129
+
130
+ output(rows, effective_format, columns=columns, headers=headers)
131
+
132
+ if effective_format == "table" and row_count > 0:
133
+ console.print(f"\n[dim]{row_count} total rows[/dim]")
134
+
135
+ except Exception as e:
136
+ handle_error(e)
137
+
138
+
139
+ @reports_app.command("realtime")
140
+ def realtime_cmd(
141
+ property_id: Optional[str] = typer.Option(
142
+ None, "--property-id", "-p", help="Property ID (numeric)"
143
+ ),
144
+ metrics: str = typer.Option(
145
+ "activeUsers", "--metrics", "-m", help="Comma-separated metrics"
146
+ ),
147
+ dimensions: Optional[str] = typer.Option(
148
+ None, "--dimensions", "-d", help="Comma-separated dimensions"
149
+ ),
150
+ interval: Optional[int] = typer.Option(
151
+ None, "--interval", help="Refresh interval in seconds (enables polling)"
152
+ ),
153
+ output_format: Optional[str] = typer.Option(
154
+ None, "--output", "-o", help="Output format (json, table, compact)"
155
+ ),
156
+ ):
157
+ """Get real-time analytics data."""
158
+ try:
159
+ effective_property = get_effective_value(property_id, "default_property_id")
160
+ require_options({"property_id": effective_property}, ["property_id"])
161
+ effective_format = resolve_output_format(output_format)
162
+
163
+ data_client = get_data_client()
164
+
165
+ body = {
166
+ "metrics": [{"name": m.strip()} for m in metrics.split(",")],
167
+ }
168
+ if dimensions:
169
+ body["dimensions"] = [{"name": d.strip()} for d in dimensions.split(",")]
170
+
171
+ if interval:
172
+ info(f"Monitoring real-time data (refresh every {interval}s). Press Ctrl+C to stop.")
173
+ try:
174
+ while True:
175
+ result = data_client.properties().runRealtimeReport(
176
+ property=f"properties/{effective_property}",
177
+ body=body,
178
+ ).execute()
179
+
180
+ console.clear()
181
+ rows, columns, headers = _transform_report_rows(result)
182
+ output(rows, effective_format, columns=columns, headers=headers)
183
+
184
+ time.sleep(interval)
185
+ except KeyboardInterrupt:
186
+ info("Stopped monitoring.")
187
+ else:
188
+ result = data_client.properties().runRealtimeReport(
189
+ property=f"properties/{effective_property}",
190
+ body=body,
191
+ ).execute()
192
+
193
+ rows, columns, headers = _transform_report_rows(result)
194
+ output(rows, effective_format, columns=columns, headers=headers)
195
+
196
+ except Exception as e:
197
+ handle_error(e)
198
+
199
+
200
+ @reports_app.command("pivot")
201
+ def pivot_cmd(
202
+ property_id: Optional[str] = typer.Option(
203
+ None, "--property-id", "-p", help="Property ID (numeric)"
204
+ ),
205
+ metrics: str = typer.Option(..., "--metrics", "-m", help="Comma-separated metrics"),
206
+ dimensions: str = typer.Option(
207
+ ..., "--dimensions", "-d", help="Comma-separated dimensions (all used in pivots)"
208
+ ),
209
+ pivot_field: str = typer.Option(
210
+ ..., "--pivot-field", help="Dimension to pivot on (must be in --dimensions)"
211
+ ),
212
+ start_date: str = typer.Option("28daysAgo", "--start-date", help="Start date"),
213
+ end_date: str = typer.Option("yesterday", "--end-date", help="End date"),
214
+ limit: int = typer.Option(100, "--limit", "-l", help="Max rows per pivot group"),
215
+ output_format: Optional[str] = typer.Option(
216
+ None, "--output", "-o", help="Output format (json, table, compact)"
217
+ ),
218
+ ):
219
+ """Run a pivot report (cross-tabulation)."""
220
+ try:
221
+ effective_property = get_effective_value(property_id, "default_property_id")
222
+ require_options({"property_id": effective_property}, ["property_id"])
223
+ effective_format = resolve_output_format(output_format)
224
+
225
+ dim_list = [d.strip() for d in dimensions.split(",")]
226
+ pivot_field_clean = pivot_field.strip()
227
+
228
+ if pivot_field_clean not in dim_list:
229
+ raise typer.BadParameter(
230
+ f"--pivot-field '{pivot_field_clean}' must be one "
231
+ f"of the --dimensions: {', '.join(dim_list)}"
232
+ )
233
+
234
+ row_dims = [d for d in dim_list if d != pivot_field_clean]
235
+
236
+ data = get_data_client()
237
+
238
+ pivots = [
239
+ {"fieldNames": [pivot_field_clean], "limit": 5},
240
+ ]
241
+ if row_dims:
242
+ pivots.append({"fieldNames": row_dims, "limit": limit})
243
+
244
+ body = {
245
+ "metrics": [{"name": m.strip()} for m in metrics.split(",")],
246
+ "dimensions": [{"name": d} for d in dim_list],
247
+ "dateRanges": [{"startDate": start_date, "endDate": end_date}],
248
+ "pivots": pivots,
249
+ }
250
+
251
+ result = data.properties().runPivotReport(
252
+ property=f"properties/{effective_property}",
253
+ body=body,
254
+ ).execute()
255
+
256
+ if effective_format != "table":
257
+ output(result, effective_format)
258
+ return
259
+
260
+ rows, columns, headers = _transform_pivot_rows(result, pivot_field_clean)
261
+ if not rows:
262
+ info("No data returned.")
263
+ else:
264
+ output(rows, effective_format, columns=columns, headers=headers)
265
+
266
+ except typer.BadParameter:
267
+ raise
268
+ except Exception as e:
269
+ handle_error(e)
270
+
271
+
272
+ def _transform_pivot_rows(
273
+ result: dict, pivot_field: str
274
+ ) -> tuple[list[dict], list[str], list[str]]:
275
+ """Transform pivot report response into flat rows for table display."""
276
+ pivot_headers = result.get("pivotHeaders", [])
277
+ dim_headers = [h.get("name", "") for h in result.get("dimensionHeaders", [])]
278
+ met_headers = [h.get("name", "") for h in result.get("metricHeaders", [])]
279
+
280
+ # Build pivot column values from pivot headers
281
+ pivot_values = []
282
+ if pivot_headers:
283
+ for group in pivot_headers[0].get("pivotDimensionHeaders", []):
284
+ vals = group.get("dimensionValues", [])
285
+ label = vals[0].get("value", "") if vals else ""
286
+ pivot_values.append(label)
287
+
288
+ # Non-pivot dimensions
289
+ row_dims = [d for d in dim_headers if d != pivot_field]
290
+
291
+ # Build column keys: row dims + pivot_value/metric combos
292
+ columns = list(row_dims)
293
+ headers = list(row_dims)
294
+ for pv in pivot_values:
295
+ for m in met_headers:
296
+ col_key = f"{pv}_{m}"
297
+ columns.append(col_key)
298
+ headers.append(f"{pv} / {m}")
299
+
300
+ rows = []
301
+ for row in result.get("rows", []):
302
+ entry = {}
303
+ # Fill row dimensions (skip the pivot field dimension)
304
+ dim_vals = row.get("dimensionValues", [])
305
+ for i, dname in enumerate(dim_headers):
306
+ if dname != pivot_field and i < len(dim_vals):
307
+ entry[dname] = dim_vals[i].get("value", "")
308
+
309
+ # Fill metric values per pivot group
310
+ met_vals = row.get("metricValues", [])
311
+ idx = 0
312
+ for pv in pivot_values:
313
+ for m in met_headers:
314
+ col_key = f"{pv}_{m}"
315
+ entry[col_key] = met_vals[idx].get("value", "") if idx < len(met_vals) else ""
316
+ idx += 1
317
+
318
+ rows.append(entry)
319
+
320
+ return rows, columns, headers
321
+
322
+
323
+ @reports_app.command("check-compatibility")
324
+ def check_compatibility_cmd(
325
+ property_id: Optional[str] = typer.Option(
326
+ None, "--property-id", "-p", help="Property ID (numeric)"
327
+ ),
328
+ metrics: Optional[str] = typer.Option(
329
+ None, "--metrics", "-m", help="Comma-separated metrics to check"
330
+ ),
331
+ dimensions: Optional[str] = typer.Option(
332
+ None, "--dimensions", "-d", help="Comma-separated dimensions to check"
333
+ ),
334
+ output_format: Optional[str] = typer.Option(
335
+ None, "--output", "-o", help="Output format (json, table, compact)"
336
+ ),
337
+ ):
338
+ """Check compatibility of dimensions and metrics."""
339
+ try:
340
+ effective_property = get_effective_value(property_id, "default_property_id")
341
+ require_options({"property_id": effective_property}, ["property_id"])
342
+ effective_format = resolve_output_format(output_format)
343
+
344
+ if not metrics and not dimensions:
345
+ raise typer.BadParameter(
346
+ "At least one of --metrics or --dimensions must be specified."
347
+ )
348
+
349
+ body = {}
350
+ if metrics:
351
+ body["metrics"] = [{"name": m.strip()} for m in metrics.split(",")]
352
+ if dimensions:
353
+ body["dimensions"] = [{"name": d.strip()} for d in dimensions.split(",")]
354
+
355
+ data = get_data_client()
356
+ result = data.properties().checkCompatibility(
357
+ property=f"properties/{effective_property}",
358
+ body=body,
359
+ ).execute()
360
+
361
+ if effective_format != "table":
362
+ output(result, effective_format)
363
+ return
364
+
365
+ # Build a flat list for table output
366
+ rows = []
367
+ for item in result.get("dimensionCompatibilities", []):
368
+ dim_meta = item.get("dimensionMetadata", {})
369
+ rows.append({
370
+ "type": "dimension",
371
+ "apiName": dim_meta.get("apiName", ""),
372
+ "uiName": dim_meta.get("uiName", ""),
373
+ "compatibility": item.get("compatibility", "UNKNOWN"),
374
+ })
375
+ for item in result.get("metricCompatibilities", []):
376
+ met_meta = item.get("metricMetadata", {})
377
+ rows.append({
378
+ "type": "metric",
379
+ "apiName": met_meta.get("apiName", ""),
380
+ "uiName": met_meta.get("uiName", ""),
381
+ "compatibility": item.get("compatibility", "UNKNOWN"),
382
+ })
383
+
384
+ output(
385
+ rows,
386
+ effective_format,
387
+ columns=["type", "apiName", "uiName", "compatibility"],
388
+ headers=["Type", "API Name", "UI Name", "Compatibility"],
389
+ )
390
+
391
+ except typer.BadParameter:
392
+ raise
393
+ except Exception as e:
394
+ handle_error(e)
395
+
396
+
397
+ @reports_app.command("metadata")
398
+ def metadata_cmd(
399
+ property_id: Optional[str] = typer.Option(
400
+ None, "--property-id", "-p", help="Property ID (numeric)"
401
+ ),
402
+ filter_type: Optional[str] = typer.Option(
403
+ None, "--type", "-t", help="Filter by 'metrics' or 'dimensions'"
404
+ ),
405
+ search: Optional[str] = typer.Option(
406
+ None, "--search", "-s", help="Filter names containing this string"
407
+ ),
408
+ output_format: Optional[str] = typer.Option(
409
+ None, "--output", "-o", help="Output format (json, table, compact)"
410
+ ),
411
+ ):
412
+ """Browse available metrics and dimensions for a property."""
413
+ try:
414
+ effective_property = get_effective_value(property_id, "default_property_id")
415
+ require_options({"property_id": effective_property}, ["property_id"])
416
+ effective_format = resolve_output_format(output_format)
417
+
418
+ data = get_data_client()
419
+ metadata = (
420
+ data.properties()
421
+ .getMetadata(name=f"properties/{effective_property}/metadata")
422
+ .execute()
423
+ )
424
+
425
+ rows = []
426
+
427
+ if filter_type != "metrics":
428
+ for d in metadata.get("dimensions", []):
429
+ rows.append({
430
+ "type": "dimension",
431
+ "apiName": d.get("apiName", ""),
432
+ "uiName": d.get("uiName", ""),
433
+ "category": d.get("category", ""),
434
+ "custom": str(d.get("customDefinition", False)),
435
+ })
436
+
437
+ if filter_type != "dimensions":
438
+ for m in metadata.get("metrics", []):
439
+ rows.append({
440
+ "type": "metric",
441
+ "apiName": m.get("apiName", ""),
442
+ "uiName": m.get("uiName", ""),
443
+ "category": m.get("category", ""),
444
+ "custom": str(m.get("customDefinition", False)),
445
+ })
446
+
447
+ if search:
448
+ search_lower = search.lower()
449
+ rows = [
450
+ r for r in rows
451
+ if search_lower in r["apiName"].lower()
452
+ or search_lower in r["uiName"].lower()
453
+ ]
454
+
455
+ if not rows:
456
+ info("No metadata found.")
457
+ else:
458
+ output(
459
+ rows,
460
+ effective_format,
461
+ columns=["type", "apiName", "uiName", "category", "custom"],
462
+ headers=["Type", "API Name", "UI Name", "Category", "Custom"],
463
+ )
464
+
465
+ except Exception as e:
466
+ handle_error(e)
467
+
468
+
469
+ @reports_app.command("batch")
470
+ def batch_cmd(
471
+ property_id: Optional[str] = typer.Option(
472
+ None, "--property-id", "-p", help="Property ID (numeric)"
473
+ ),
474
+ config_file: str = typer.Option(
475
+ ..., "--config", "-c", help="Path to JSON batch config file"
476
+ ),
477
+ output_format: Optional[str] = typer.Option(
478
+ None, "--output", "-o", help="Output format (json, table, compact)"
479
+ ),
480
+ ):
481
+ """Run multiple reports in a single API call (max 5)."""
482
+ try:
483
+ effective_property = get_effective_value(property_id, "default_property_id")
484
+ require_options({"property_id": effective_property}, ["property_id"])
485
+ effective_format = resolve_output_format(output_format)
486
+
487
+ # Read and parse config file
488
+ config_path = Path(config_file)
489
+ if not config_path.exists():
490
+ raise typer.BadParameter(f"Config file not found: {config_file}")
491
+
492
+ try:
493
+ config = json.loads(config_path.read_text())
494
+ except json.JSONDecodeError as exc:
495
+ raise typer.BadParameter(f"Invalid JSON in config file: {exc}")
496
+
497
+ reports = config.get("reports")
498
+ if not isinstance(reports, list) or len(reports) == 0:
499
+ raise typer.BadParameter("Config must contain a non-empty 'reports' array.")
500
+
501
+ if len(reports) > 5:
502
+ raise typer.BadParameter(
503
+ f"Batch supports at most 5 reports, got {len(reports)}."
504
+ )
505
+
506
+ # Validate each report has metrics, then build request bodies
507
+ requests = []
508
+ for i, spec in enumerate(reports):
509
+ if not spec.get("metrics"):
510
+ raise typer.BadParameter(
511
+ f"Report {i + 1} is missing required 'metrics' field."
512
+ )
513
+ # Normalise shorthand: list of strings → list of dicts
514
+ if isinstance(spec["metrics"][0], str):
515
+ spec["metrics"] = [{"name": m} for m in spec["metrics"]]
516
+ dims = spec.get("dimensions")
517
+ if dims and isinstance(dims[0], str):
518
+ spec["dimensions"] = [{"name": d} for d in spec["dimensions"]]
519
+ requests.append(spec)
520
+
521
+ data = get_data_client()
522
+ result = (
523
+ data.properties()
524
+ .batchRunReports(
525
+ property=f"properties/{effective_property}",
526
+ body={"requests": requests},
527
+ )
528
+ .execute()
529
+ )
530
+
531
+ if effective_format != "table":
532
+ output(result, effective_format)
533
+ return
534
+
535
+ for idx, report in enumerate(result.get("reports", [])):
536
+ console.print(f"\n[bold]--- Report {idx + 1} ---[/bold]")
537
+ rows, columns, headers = _transform_report_rows(report)
538
+ row_count = report.get("rowCount", len(rows))
539
+ output(rows, effective_format, columns=columns, headers=headers)
540
+ if row_count > 0:
541
+ console.print(f"[dim]{row_count} total rows[/dim]")
542
+
543
+ except typer.BadParameter:
544
+ raise
545
+ except Exception as e:
546
+ handle_error(e)
547
+
548
+
549
+ def _transform_funnel_rows(result: dict) -> tuple[list[dict], list[str], list[str]]:
550
+ """Transform a funnel report response into flat rows for table display."""
551
+ rows = []
552
+ funnel_table = result.get("funnelTable", {})
553
+
554
+ dim_headers = [h.get("name", "") for h in funnel_table.get("dimensionHeaders", [])]
555
+ raw_met_headers = [h.get("name", "") for h in funnel_table.get("metricHeaders", [])]
556
+
557
+ for row in funnel_table.get("rows", []):
558
+ entry = {}
559
+ for i, name in enumerate(dim_headers):
560
+ vals = row.get("dimensionValues", [])
561
+ entry[name] = vals[i].get("value", "") if i < len(vals) else ""
562
+ metric_vals = row.get("metricValues", [])
563
+ # Deduplicate metric headers — API may return duplicates; use
564
+ # only as many headers as there are values in this row.
565
+ met_headers = raw_met_headers[:len(metric_vals)]
566
+ # Make header keys unique by appending suffix for duplicates
567
+ seen: dict[str, int] = {}
568
+ unique_headers: list[str] = []
569
+ for name in met_headers:
570
+ if name in seen:
571
+ seen[name] += 1
572
+ unique_headers.append(f"{name}_{seen[name]}")
573
+ else:
574
+ seen[name] = 0
575
+ unique_headers.append(name)
576
+ for i, name in enumerate(unique_headers):
577
+ entry[name] = metric_vals[i].get("value", "") if i < len(metric_vals) else ""
578
+ rows.append(entry)
579
+
580
+ columns = [
581
+ "funnelStepName", "activeUsers",
582
+ "funnelStepCompletionRate", "funnelStepAbandonments",
583
+ "funnelStepAbandonmentRate",
584
+ ]
585
+ headers = ["Step Name", "Active Users", "Completion Rate", "Abandonments", "Abandonment Rate"]
586
+
587
+ # Only include columns that actually exist in the data
588
+ if rows:
589
+ available = set(rows[0].keys())
590
+ filtered = [(c, h) for c, h in zip(columns, headers) if c in available]
591
+ # Add any extra columns not in our predefined list
592
+ for key in rows[0]:
593
+ if key not in columns:
594
+ filtered.append((key, key))
595
+ columns, headers = zip(*filtered) if filtered else ([], [])
596
+ columns, headers = list(columns), list(headers)
597
+
598
+ return rows, columns, headers
599
+
600
+
601
+ @reports_app.command("funnel")
602
+ def funnel_cmd(
603
+ property_id: Optional[str] = typer.Option(
604
+ None, "--property-id", "-p", help="Property ID (numeric)"
605
+ ),
606
+ config_file: str = typer.Option(
607
+ ..., "--config", "-c", help="Path to JSON funnel config file"
608
+ ),
609
+ output_format: Optional[str] = typer.Option(
610
+ None, "--output", "-o", help="Output format (json, table, compact)"
611
+ ),
612
+ ):
613
+ """Run a funnel report (v1alpha)."""
614
+ try:
615
+ effective_property = get_effective_value(property_id, "default_property_id")
616
+ require_options({"property_id": effective_property}, ["property_id"])
617
+ effective_format = resolve_output_format(output_format)
618
+
619
+ config_path = Path(config_file)
620
+ if not config_path.exists():
621
+ raise typer.BadParameter(f"Config file not found: {config_file}")
622
+
623
+ try:
624
+ config = json.loads(config_path.read_text())
625
+ except json.JSONDecodeError as exc:
626
+ raise typer.BadParameter(f"Invalid JSON in config file: {exc}")
627
+
628
+ funnel = config.get("funnel")
629
+ if not isinstance(funnel, dict) or not funnel.get("steps"):
630
+ raise typer.BadParameter(
631
+ "Config must contain a 'funnel' object with a non-empty 'steps' array."
632
+ )
633
+
634
+ data_alpha = get_data_alpha_client()
635
+ result = (
636
+ data_alpha.properties()
637
+ .runFunnelReport(
638
+ property=f"properties/{effective_property}",
639
+ body=config,
640
+ )
641
+ .execute()
642
+ )
643
+
644
+ if effective_format != "table":
645
+ output(result, effective_format)
646
+ return
647
+
648
+ rows, columns, headers = _transform_funnel_rows(result)
649
+ if not rows:
650
+ info("No funnel data returned.")
651
+ else:
652
+ output(rows, effective_format, columns=columns, headers=headers)
653
+
654
+ except typer.BadParameter:
655
+ raise
656
+ except Exception as e:
657
+ handle_error(e)
658
+
659
+
660
+ @reports_app.command("build")
661
+ def build_cmd(
662
+ property_id: Optional[str] = typer.Option(
663
+ None, "--property-id", "-p", help="Property ID (numeric)"
664
+ ),
665
+ output_format: Optional[str] = typer.Option(
666
+ None, "--output", "-o", help="Output format (json, table, compact)"
667
+ ),
668
+ ):
669
+ """Interactive report builder with available metrics and dimensions."""
670
+ try:
671
+ effective_property = get_effective_value(property_id, "default_property_id")
672
+ require_options({"property_id": effective_property}, ["property_id"])
673
+ effective_format = resolve_output_format(output_format)
674
+
675
+ data_client = get_data_client()
676
+
677
+ # Fetch available metrics/dimensions from the API
678
+ info("Fetching available metrics and dimensions...")
679
+ available_metrics, available_dimensions = _fetch_metadata(
680
+ data_client, effective_property
681
+ )
682
+
683
+ selected_metrics = questionary.checkbox(
684
+ "Select metrics:",
685
+ choices=available_metrics,
686
+ ).ask()
687
+
688
+ if not selected_metrics:
689
+ info("No metrics selected. Aborting.")
690
+ return
691
+
692
+ selected_dims = questionary.checkbox(
693
+ "Select dimensions (optional, press Enter to skip):",
694
+ choices=available_dimensions,
695
+ ).ask()
696
+
697
+ date_range = questionary.select(
698
+ "Date range:",
699
+ choices=["7daysAgo", "30daysAgo", "90daysAgo"],
700
+ default="7daysAgo",
701
+ ).ask()
702
+
703
+ info(f"Running report: metrics={selected_metrics}, dimensions={selected_dims or []}")
704
+
705
+ body = {
706
+ "metrics": [{"name": m} for m in selected_metrics],
707
+ "dateRanges": [{"startDate": date_range, "endDate": "today"}],
708
+ }
709
+ if selected_dims:
710
+ body["dimensions"] = [{"name": d} for d in selected_dims]
711
+
712
+ result = data_client.properties().runReport(
713
+ property=f"properties/{effective_property}",
714
+ body=body,
715
+ ).execute()
716
+
717
+ rows, columns, headers = _transform_report_rows(result)
718
+ row_count = result.get("rowCount", len(rows))
719
+
720
+ output(rows, effective_format, columns=columns, headers=headers)
721
+
722
+ if effective_format == "table" and row_count > 0:
723
+ console.print(f"\n[dim]{row_count} total rows[/dim]")
724
+
725
+ except Exception as e:
726
+ handle_error(e)