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.
- ga_cli/__init__.py +8 -0
- ga_cli/api/__init__.py +0 -0
- ga_cli/api/client.py +142 -0
- ga_cli/auth/__init__.py +35 -0
- ga_cli/auth/credentials.py +126 -0
- ga_cli/auth/oauth.py +322 -0
- ga_cli/auth/service_account.py +155 -0
- ga_cli/commands/__init__.py +0 -0
- ga_cli/commands/access_bindings.py +254 -0
- ga_cli/commands/access_reports.py +201 -0
- ga_cli/commands/account_summaries.py +68 -0
- ga_cli/commands/accounts.py +297 -0
- ga_cli/commands/agent_cmd.py +776 -0
- ga_cli/commands/annotations.py +264 -0
- ga_cli/commands/audiences.py +223 -0
- ga_cli/commands/auth_cmd.py +205 -0
- ga_cli/commands/bigquery_links.py +309 -0
- ga_cli/commands/calculated_metrics.py +312 -0
- ga_cli/commands/channel_groups.py +223 -0
- ga_cli/commands/completions_cmd.py +55 -0
- ga_cli/commands/config_cmd.py +113 -0
- ga_cli/commands/custom_dimensions.py +272 -0
- ga_cli/commands/custom_metrics.py +305 -0
- ga_cli/commands/data_retention.py +153 -0
- ga_cli/commands/data_streams.py +277 -0
- ga_cli/commands/event_create_rules.py +250 -0
- ga_cli/commands/event_edit_rules.py +292 -0
- ga_cli/commands/firebase_links.py +142 -0
- ga_cli/commands/google_ads_links.py +225 -0
- ga_cli/commands/key_events.py +269 -0
- ga_cli/commands/mp_secrets.py +265 -0
- ga_cli/commands/properties.py +330 -0
- ga_cli/commands/property_settings.py +287 -0
- ga_cli/commands/reports.py +726 -0
- ga_cli/commands/upgrade_cmd.py +148 -0
- ga_cli/config/__init__.py +0 -0
- ga_cli/config/constants.py +61 -0
- ga_cli/config/store.py +115 -0
- ga_cli/main.py +110 -0
- ga_cli/utils/__init__.py +20 -0
- ga_cli/utils/describe.py +129 -0
- ga_cli/utils/dry_run.py +40 -0
- ga_cli/utils/errors.py +150 -0
- ga_cli/utils/output.py +209 -0
- ga_cli/utils/pagination.py +93 -0
- google_analytics_cli-0.1.0.dist-info/METADATA +321 -0
- google_analytics_cli-0.1.0.dist-info/RECORD +49 -0
- google_analytics_cli-0.1.0.dist-info/WHEEL +4 -0
- 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)
|