adloop 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.
adloop/crossref.py ADDED
@@ -0,0 +1,509 @@
1
+ """Cross-reference tools — combine Google Ads and GA4 data for unified insights."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, timedelta
6
+ from typing import TYPE_CHECKING
7
+ from urllib.parse import urlparse
8
+
9
+ if TYPE_CHECKING:
10
+ from adloop.config import AdLoopConfig
11
+
12
+
13
+ def _default_date_range(
14
+ start: str, end: str
15
+ ) -> tuple[str, str]:
16
+ """Return (start, end) as YYYY-MM-DD strings, defaulting to last 30 days."""
17
+ if not start or not end:
18
+ today = date.today()
19
+ return (today - timedelta(days=30)).isoformat(), today.isoformat()
20
+ return start, end
21
+
22
+
23
+ def _safe_div(numerator: float, denominator: float) -> float | None:
24
+ """Divide or return None when denominator is zero."""
25
+ if not denominator:
26
+ return None
27
+ return round(numerator / denominator, 4)
28
+
29
+
30
+ def _safe_int(val) -> int:
31
+ try:
32
+ return int(val)
33
+ except (TypeError, ValueError):
34
+ return 0
35
+
36
+
37
+ def _safe_float(val) -> float:
38
+ try:
39
+ return float(val)
40
+ except (TypeError, ValueError):
41
+ return 0.0
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Tool 1: analyze_campaign_conversions
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ def analyze_campaign_conversions(
50
+ config: AdLoopConfig,
51
+ *,
52
+ customer_id: str = "",
53
+ property_id: str = "",
54
+ date_range_start: str = "",
55
+ date_range_end: str = "",
56
+ campaign_name: str = "",
57
+ ) -> dict:
58
+ """Campaign clicks -> GA4 conversions mapping.
59
+
60
+ Combines Google Ads campaign metrics with GA4 session/conversion data to
61
+ reveal the real cost-per-conversion (using GA4 as source of truth) and
62
+ detect GDPR consent gaps via click-to-session ratios.
63
+ """
64
+ from adloop.ads.read import get_campaign_performance
65
+ from adloop.ga4.reports import run_ga4_report
66
+
67
+ start, end = _default_date_range(date_range_start, date_range_end)
68
+
69
+ ads_result = get_campaign_performance(
70
+ config, customer_id=customer_id, date_range_start=start, date_range_end=end
71
+ )
72
+ if "error" in ads_result:
73
+ return ads_result
74
+
75
+ ga4_result = run_ga4_report(
76
+ config,
77
+ property_id=property_id,
78
+ dimensions=["sessionCampaignName", "sessionSource", "sessionMedium"],
79
+ metrics=["sessions", "conversions", "engagedSessions", "totalUsers"],
80
+ date_range_start=start,
81
+ date_range_end=end,
82
+ limit=1000,
83
+ )
84
+ if "error" in ga4_result:
85
+ return ga4_result
86
+
87
+ # Index GA4 rows by (campaign, source, medium)
88
+ paid_by_campaign: dict[str, dict] = {}
89
+ non_paid: dict[tuple[str, str], dict] = {}
90
+
91
+ for row in ga4_result.get("rows", []):
92
+ campaign = row.get("sessionCampaignName", "(not set)")
93
+ source = row.get("sessionSource", "")
94
+ medium = row.get("sessionMedium", "")
95
+ sessions = _safe_int(row.get("sessions", 0))
96
+ conversions = _safe_int(row.get("conversions", 0))
97
+ engaged = _safe_int(row.get("engagedSessions", 0))
98
+
99
+ is_paid = source == "google" and medium == "cpc"
100
+
101
+ if is_paid:
102
+ bucket = paid_by_campaign.setdefault(campaign, {
103
+ "sessions": 0, "conversions": 0, "engaged": 0,
104
+ })
105
+ bucket["sessions"] += sessions
106
+ bucket["conversions"] += conversions
107
+ bucket["engaged"] += engaged
108
+ else:
109
+ key = (source, medium)
110
+ bucket = non_paid.setdefault(key, {"sessions": 0, "conversions": 0})
111
+ bucket["sessions"] += sessions
112
+ bucket["conversions"] += conversions
113
+
114
+ campaigns = []
115
+ insights = []
116
+
117
+ for camp in ads_result.get("campaigns", []):
118
+ name = camp.get("campaign.name", "")
119
+ if campaign_name and campaign_name.lower() not in name.lower():
120
+ continue
121
+
122
+ ads_clicks = _safe_int(camp.get("metrics.clicks", 0))
123
+ ads_cost = _safe_float(camp.get("metrics.cost", 0))
124
+ ads_conversions = _safe_float(camp.get("metrics.conversions", 0))
125
+
126
+ ga4 = paid_by_campaign.get(name, {"sessions": 0, "conversions": 0, "engaged": 0})
127
+ ga4_sessions = ga4["sessions"]
128
+ ga4_conversions = ga4["conversions"]
129
+
130
+ ratio = _safe_div(ads_clicks, ga4_sessions)
131
+ conv_rate = _safe_div(ga4_conversions, ga4_sessions)
132
+ cost_per_conv = _safe_div(ads_cost, ga4_conversions)
133
+
134
+ entry = {
135
+ "campaign_name": name,
136
+ "campaign_status": camp.get("campaign.status", ""),
137
+ "ads_clicks": ads_clicks,
138
+ "ads_cost": ads_cost,
139
+ "ads_conversions": ads_conversions,
140
+ "ga4_paid_sessions": ga4_sessions,
141
+ "ga4_paid_conversions": ga4_conversions,
142
+ "click_to_session_ratio": ratio,
143
+ "ga4_conversion_rate": conv_rate,
144
+ "cost_per_ga4_conversion": cost_per_conv,
145
+ }
146
+ campaigns.append(entry)
147
+
148
+ if ratio is not None and ratio > 2.0 and ads_clicks > 5:
149
+ lost_pct = round((1 - 1 / ratio) * 100)
150
+ insights.append(
151
+ f"GDPR: click-to-session ratio is {ratio:.1f}:1 for '{name}' "
152
+ f"— ~{lost_pct}% of clicks not tracked in GA4 (likely consent rejection)"
153
+ )
154
+
155
+ if ads_cost > 0 and ga4_conversions == 0 and ads_conversions == 0:
156
+ insights.append(
157
+ f"Zero conversions for '{name}' despite €{ads_cost:.2f} spend "
158
+ f"— check conversion tracking setup in both Google Ads and GA4"
159
+ )
160
+
161
+ if ads_conversions > 0 and ga4_conversions == 0:
162
+ insights.append(
163
+ f"Ads reports {ads_conversions} conversions for '{name}' but GA4 shows 0 "
164
+ f"from paid traffic — possible attribution model mismatch"
165
+ )
166
+
167
+ non_paid_channels = []
168
+ for (source, medium), data in sorted(non_paid.items(), key=lambda x: -x[1]["sessions"]):
169
+ s = data["sessions"]
170
+ c = data["conversions"]
171
+ non_paid_channels.append({
172
+ "source": source,
173
+ "medium": medium,
174
+ "sessions": s,
175
+ "conversions": c,
176
+ "conversion_rate": _safe_div(c, s),
177
+ })
178
+
179
+ return {
180
+ "campaigns": campaigns,
181
+ "non_paid_channels": non_paid_channels,
182
+ "insights": insights,
183
+ "date_range": {"start": start, "end": end},
184
+ }
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Tool 2: landing_page_analysis
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def landing_page_analysis(
193
+ config: AdLoopConfig,
194
+ *,
195
+ customer_id: str = "",
196
+ property_id: str = "",
197
+ date_range_start: str = "",
198
+ date_range_end: str = "",
199
+ ) -> dict:
200
+ """Analyze landing page performance by combining ad final URLs with GA4 page data.
201
+
202
+ Shows which landing pages receive ad traffic, their conversion rates, bounce
203
+ rates, and identifies pages that get clicks but don't convert.
204
+ """
205
+ from adloop.ads.read import get_ad_performance
206
+ from adloop.ga4.reports import run_ga4_report
207
+
208
+ start, end = _default_date_range(date_range_start, date_range_end)
209
+
210
+ ads_result = get_ad_performance(
211
+ config, customer_id=customer_id, date_range_start=start, date_range_end=end
212
+ )
213
+ if "error" in ads_result:
214
+ return ads_result
215
+
216
+ ga4_result = run_ga4_report(
217
+ config,
218
+ property_id=property_id,
219
+ dimensions=["pagePath", "sessionSource", "sessionMedium"],
220
+ metrics=["sessions", "conversions", "engagedSessions", "bounceRate"],
221
+ date_range_start=start,
222
+ date_range_end=end,
223
+ limit=1000,
224
+ )
225
+ if "error" in ga4_result:
226
+ return ga4_result
227
+
228
+ # Build map: path -> list of ads pointing there
229
+ ads_by_path: dict[str, list[dict]] = {}
230
+ for ad in ads_result.get("ads", []):
231
+ urls = ad.get("ad_group_ad.ad.final_urls", [])
232
+ if isinstance(urls, str):
233
+ urls = [urls]
234
+ for url in urls:
235
+ path = urlparse(url).path or "/"
236
+ path = path.rstrip("/") or "/"
237
+ ads_by_path.setdefault(path, []).append({
238
+ "ad_id": str(ad.get("ad_group_ad.ad.id", "")),
239
+ "campaign": ad.get("campaign.name", ""),
240
+ "ad_group": ad.get("ad_group.name", ""),
241
+ "clicks": _safe_int(ad.get("metrics.clicks", 0)),
242
+ "cost": _safe_float(ad.get("metrics.cost", 0)),
243
+ })
244
+
245
+ # Build map: path -> GA4 paid metrics
246
+ ga4_by_path: dict[str, dict] = {}
247
+ for row in ga4_result.get("rows", []):
248
+ source = row.get("sessionSource", "")
249
+ medium = row.get("sessionMedium", "")
250
+ if source != "google" or medium != "cpc":
251
+ continue
252
+ path = row.get("pagePath", "/")
253
+ bucket = ga4_by_path.setdefault(path, {
254
+ "sessions": 0, "conversions": 0, "engaged": 0, "bounce_rate_sum": 0.0, "count": 0,
255
+ })
256
+ bucket["sessions"] += _safe_int(row.get("sessions", 0))
257
+ bucket["conversions"] += _safe_int(row.get("conversions", 0))
258
+ bucket["engaged"] += _safe_int(row.get("engagedSessions", 0))
259
+ bucket["bounce_rate_sum"] += _safe_float(row.get("bounceRate", 0))
260
+ bucket["count"] += 1
261
+
262
+ all_paths = set(ads_by_path.keys()) | set(ga4_by_path.keys())
263
+
264
+ landing_pages = []
265
+ orphaned = []
266
+ insights = []
267
+
268
+ for path in sorted(all_paths):
269
+ ads_list = ads_by_path.get(path, [])
270
+ ga4 = ga4_by_path.get(path, {"sessions": 0, "conversions": 0, "engaged": 0, "bounce_rate_sum": 0.0, "count": 0})
271
+
272
+ total_ad_clicks = sum(a["clicks"] for a in ads_list)
273
+ total_ad_cost = sum(a["cost"] for a in ads_list)
274
+ ga4_sessions = ga4["sessions"]
275
+ ga4_conversions = ga4["conversions"]
276
+
277
+ conv_rate = _safe_div(ga4_conversions, ga4_sessions)
278
+ bounce = round(ga4["bounce_rate_sum"] / ga4["count"], 4) if ga4["count"] else None
279
+ engagement = _safe_div(ga4["engaged"], ga4_sessions)
280
+
281
+ entry = {
282
+ "page_path": path,
283
+ "ads_pointing_here": ads_list if ads_list else None,
284
+ "total_ad_clicks": total_ad_clicks,
285
+ "total_ad_cost": round(total_ad_cost, 2),
286
+ "ga4_paid_sessions": ga4_sessions,
287
+ "ga4_paid_conversions": ga4_conversions,
288
+ "conversion_rate": conv_rate,
289
+ "bounce_rate": bounce,
290
+ "engagement_rate": engagement,
291
+ }
292
+ landing_pages.append(entry)
293
+
294
+ if ads_list and ga4_sessions == 0 and total_ad_clicks > 0:
295
+ orphaned.append(path)
296
+ insights.append(
297
+ f"'{path}' receives ad clicks ({total_ad_clicks}) but has 0 GA4 paid sessions "
298
+ f"— GDPR consent may be blocking all tracking, or the page redirects"
299
+ )
300
+
301
+ if ga4_sessions > 10 and ga4_conversions == 0:
302
+ insights.append(
303
+ f"'{path}' has {ga4_sessions} paid sessions but 0 conversions "
304
+ f"— landing page conversion problem"
305
+ )
306
+
307
+ if bounce is not None and bounce > 0.70 and ga4_sessions > 5:
308
+ insights.append(
309
+ f"'{path}' has {bounce:.0%} bounce rate from paid traffic "
310
+ f"— ad message may not match page content"
311
+ )
312
+
313
+ landing_pages.sort(key=lambda p: -(p["ga4_paid_sessions"] or 0))
314
+
315
+ return {
316
+ "landing_pages": landing_pages,
317
+ "orphaned_ad_urls": orphaned,
318
+ "insights": insights,
319
+ "date_range": {"start": start, "end": end},
320
+ }
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # Tool 3: attribution_check
325
+ # ---------------------------------------------------------------------------
326
+
327
+
328
+ def attribution_check(
329
+ config: AdLoopConfig,
330
+ *,
331
+ customer_id: str = "",
332
+ property_id: str = "",
333
+ date_range_start: str = "",
334
+ date_range_end: str = "",
335
+ conversion_events: list[str] | None = None,
336
+ ) -> dict:
337
+ """Compare Ads-reported conversions vs GA4 conversion events.
338
+
339
+ Identifies discrepancies between the two systems and diagnoses whether
340
+ they're caused by GDPR consent, attribution model differences, or
341
+ misconfigured conversion actions.
342
+ """
343
+ from adloop.ads.read import get_campaign_performance
344
+ from adloop.ga4.reports import run_ga4_report
345
+
346
+ start, end = _default_date_range(date_range_start, date_range_end)
347
+
348
+ ads_result = get_campaign_performance(
349
+ config, customer_id=customer_id, date_range_start=start, date_range_end=end
350
+ )
351
+ if "error" in ads_result:
352
+ return ads_result
353
+
354
+ ga4_events_result = run_ga4_report(
355
+ config,
356
+ property_id=property_id,
357
+ dimensions=["eventName"],
358
+ metrics=["eventCount"],
359
+ date_range_start=start,
360
+ date_range_end=end,
361
+ limit=500,
362
+ )
363
+ if "error" in ga4_events_result:
364
+ return ga4_events_result
365
+
366
+ ga4_source_result = run_ga4_report(
367
+ config,
368
+ property_id=property_id,
369
+ dimensions=["sessionSource", "sessionMedium"],
370
+ metrics=["sessions", "conversions"],
371
+ date_range_start=start,
372
+ date_range_end=end,
373
+ limit=200,
374
+ )
375
+ if "error" in ga4_source_result:
376
+ return ga4_source_result
377
+
378
+ # Ads totals
379
+ ads_total_conversions = sum(
380
+ _safe_float(c.get("metrics.conversions", 0))
381
+ for c in ads_result.get("campaigns", [])
382
+ )
383
+ ads_total_cost = sum(
384
+ _safe_float(c.get("metrics.cost", 0))
385
+ for c in ads_result.get("campaigns", [])
386
+ )
387
+ ads_total_clicks = sum(
388
+ _safe_int(c.get("metrics.clicks", 0))
389
+ for c in ads_result.get("campaigns", [])
390
+ )
391
+
392
+ # GA4 events index
393
+ event_index: dict[str, int] = {}
394
+ for row in ga4_events_result.get("rows", []):
395
+ name = row.get("eventName", "")
396
+ count = _safe_int(row.get("eventCount", 0))
397
+ event_index[name] = event_index.get(name, 0) + count
398
+
399
+ # GA4 conversions by source
400
+ ga4_paid_conversions = 0
401
+ ga4_paid_sessions = 0
402
+ ga4_all_conversions = 0
403
+ by_source = []
404
+
405
+ for row in ga4_source_result.get("rows", []):
406
+ source = row.get("sessionSource", "")
407
+ medium = row.get("sessionMedium", "")
408
+ sessions = _safe_int(row.get("sessions", 0))
409
+ conversions = _safe_int(row.get("conversions", 0))
410
+
411
+ ga4_all_conversions += conversions
412
+
413
+ if source == "google" and medium == "cpc":
414
+ ga4_paid_conversions += conversions
415
+ ga4_paid_sessions += sessions
416
+
417
+ by_source.append({
418
+ "source": source,
419
+ "medium": medium,
420
+ "sessions": sessions,
421
+ "conversions": conversions,
422
+ })
423
+
424
+ by_source.sort(key=lambda x: -x["sessions"])
425
+
426
+ # Check requested conversion events
427
+ events_to_check = conversion_events or []
428
+ conversion_event_details = []
429
+ for ev in events_to_check:
430
+ count = event_index.get(ev, 0)
431
+ conversion_event_details.append({
432
+ "event_name": ev,
433
+ "total_count": count,
434
+ "exists": count > 0,
435
+ })
436
+
437
+ # Discrepancy
438
+ denom = max(ads_total_conversions, ga4_paid_conversions, 1)
439
+ discrepancy_pct = round(
440
+ abs(ads_total_conversions - ga4_paid_conversions) / denom * 100, 1
441
+ )
442
+
443
+ # Insights
444
+ insights = []
445
+
446
+ if ads_total_conversions == 0 and ga4_paid_conversions == 0:
447
+ if ads_total_cost > 0:
448
+ insights.append(
449
+ f"Zero conversions in both Google Ads and GA4 despite "
450
+ f"€{ads_total_cost:.2f} ad spend — conversion tracking is likely "
451
+ f"not configured or conversion actions are not linked to campaigns"
452
+ )
453
+ elif ads_total_conversions > 0 and ga4_paid_conversions == 0:
454
+ insights.append(
455
+ f"Google Ads reports {ads_total_conversions} conversions but GA4 shows 0 "
456
+ f"from paid traffic — possible causes: GDPR consent blocking GA4, "
457
+ f"different attribution models, or GA4 conversion events not marked as conversions"
458
+ )
459
+ elif ads_total_conversions == 0 and ga4_paid_conversions > 0:
460
+ insights.append(
461
+ f"GA4 shows {ga4_paid_conversions} conversions from paid traffic but "
462
+ f"Google Ads reports 0 — conversion actions may not be imported into Google Ads"
463
+ )
464
+ elif discrepancy_pct > 20:
465
+ insights.append(
466
+ f"Attribution discrepancy: Ads reports {ads_total_conversions} conversions "
467
+ f"vs GA4 {ga4_paid_conversions} from paid ({discrepancy_pct}% difference) "
468
+ f"— expected causes: GDPR consent gaps, attribution window differences "
469
+ f"(Ads: 30-day click, GA4: data-driven)"
470
+ )
471
+
472
+ click_session_ratio = _safe_div(ads_total_clicks, ga4_paid_sessions)
473
+ if click_session_ratio is not None and click_session_ratio > 2.0 and ads_total_clicks > 10:
474
+ lost_pct = round((1 - 1 / click_session_ratio) * 100)
475
+ insights.append(
476
+ f"Overall click-to-session ratio is {click_session_ratio:.1f}:1 "
477
+ f"— ~{lost_pct}% of ad clicks are not tracked in GA4 (GDPR consent)"
478
+ )
479
+
480
+ for ev_detail in conversion_event_details:
481
+ if not ev_detail["exists"]:
482
+ insights.append(
483
+ f"Conversion event '{ev_detail['event_name']}' has zero occurrences "
484
+ f"in GA4 for this period — the event may not be firing or is misconfigured"
485
+ )
486
+ elif ev_detail["total_count"] > 0 and ga4_paid_conversions == 0:
487
+ insights.append(
488
+ f"Event '{ev_detail['event_name']}' fires {ev_detail['total_count']}x "
489
+ f"but none from paid traffic — users may convert through other channels, "
490
+ f"or the event is not marked as a conversion in GA4"
491
+ )
492
+
493
+ return {
494
+ "ads_total_conversions": ads_total_conversions,
495
+ "ads_total_cost": ads_total_cost,
496
+ "ads_total_clicks": ads_total_clicks,
497
+ "ga4_paid_conversions": ga4_paid_conversions,
498
+ "ga4_paid_sessions": ga4_paid_sessions,
499
+ "ga4_all_conversions": ga4_all_conversions,
500
+ "discrepancy_pct": discrepancy_pct,
501
+ "conversion_events": conversion_event_details if conversion_event_details else None,
502
+ "all_ga4_events": [
503
+ {"event_name": k, "count": v}
504
+ for k, v in sorted(event_index.items(), key=lambda x: -x[1])
505
+ ],
506
+ "by_source": by_source,
507
+ "insights": insights,
508
+ "date_range": {"start": start, "end": end},
509
+ }
adloop/ga4/__init__.py ADDED
File without changes
adloop/ga4/client.py ADDED
@@ -0,0 +1,31 @@
1
+ """GA4 API client wrapper — thin layer over google-analytics-data and google-analytics-admin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from google.analytics.admin_v1beta import AnalyticsAdminServiceClient
9
+ from google.analytics.data_v1beta import BetaAnalyticsDataClient
10
+
11
+ from adloop.config import AdLoopConfig
12
+
13
+
14
+ def get_data_client(config: AdLoopConfig) -> BetaAnalyticsDataClient:
15
+ """Return an authenticated GA4 Data API client."""
16
+ from google.analytics.data_v1beta import BetaAnalyticsDataClient
17
+
18
+ from adloop.auth import get_ga4_credentials
19
+
20
+ credentials = get_ga4_credentials(config)
21
+ return BetaAnalyticsDataClient(credentials=credentials)
22
+
23
+
24
+ def get_admin_client(config: AdLoopConfig) -> AnalyticsAdminServiceClient:
25
+ """Return an authenticated GA4 Admin API client."""
26
+ from google.analytics.admin_v1beta import AnalyticsAdminServiceClient
27
+
28
+ from adloop.auth import get_ga4_credentials
29
+
30
+ credentials = get_ga4_credentials(config)
31
+ return AnalyticsAdminServiceClient(credentials=credentials)
adloop/ga4/reports.py ADDED
@@ -0,0 +1,141 @@
1
+ """GA4 report tools — account summaries and custom reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from adloop.config import AdLoopConfig
9
+
10
+
11
+ def get_account_summaries(config: AdLoopConfig) -> dict:
12
+ """List GA4 accounts and properties accessible by the authenticated user."""
13
+ from adloop.ga4.client import get_admin_client
14
+
15
+ client = get_admin_client(config)
16
+ summaries = client.list_account_summaries()
17
+
18
+ accounts = []
19
+ for summary in summaries:
20
+ properties = []
21
+ for prop in summary.property_summaries:
22
+ properties.append({
23
+ "property": prop.property,
24
+ "display_name": prop.display_name,
25
+ })
26
+ accounts.append({
27
+ "account": summary.account,
28
+ "display_name": summary.display_name,
29
+ "properties": properties,
30
+ })
31
+
32
+ return {
33
+ "accounts": accounts,
34
+ "total_accounts": len(accounts),
35
+ "total_properties": sum(len(a["properties"]) for a in accounts),
36
+ }
37
+
38
+
39
+ def run_ga4_report(
40
+ config: AdLoopConfig,
41
+ *,
42
+ property_id: str = "",
43
+ dimensions: list[str] | None = None,
44
+ metrics: list[str] | None = None,
45
+ date_range_start: str = "7daysAgo",
46
+ date_range_end: str = "today",
47
+ limit: int = 100,
48
+ ) -> dict:
49
+ """Run a GA4 report with specified dimensions, metrics, and date range."""
50
+ from google.analytics.data_v1beta.types import (
51
+ DateRange,
52
+ Dimension,
53
+ Metric,
54
+ RunReportRequest,
55
+ )
56
+
57
+ from adloop.ga4.client import get_data_client
58
+
59
+ if not dimensions and not metrics:
60
+ return {"error": "At least one dimension or metric must be specified."}
61
+
62
+ client = get_data_client(config)
63
+
64
+ request = RunReportRequest(
65
+ property=property_id,
66
+ dimensions=[Dimension(name=d) for d in (dimensions or [])],
67
+ metrics=[Metric(name=m) for m in (metrics or [])],
68
+ date_ranges=[DateRange(start_date=date_range_start, end_date=date_range_end)],
69
+ limit=limit,
70
+ )
71
+
72
+ response = client.run_report(request)
73
+
74
+ dim_headers = [h.name for h in response.dimension_headers]
75
+ met_headers = [h.name for h in response.metric_headers]
76
+
77
+ rows = []
78
+ for row in response.rows:
79
+ r = {}
80
+ for i, val in enumerate(row.dimension_values):
81
+ r[dim_headers[i]] = val.value
82
+ for i, val in enumerate(row.metric_values):
83
+ r[met_headers[i]] = val.value
84
+ rows.append(r)
85
+
86
+ return {
87
+ "property": property_id,
88
+ "date_range": {"start": date_range_start, "end": date_range_end},
89
+ "dimensions": dim_headers,
90
+ "metrics": met_headers,
91
+ "rows": rows,
92
+ "row_count": len(rows),
93
+ "total_row_count": response.row_count,
94
+ }
95
+
96
+
97
+ def run_realtime_report(
98
+ config: AdLoopConfig,
99
+ *,
100
+ property_id: str = "",
101
+ dimensions: list[str] | None = None,
102
+ metrics: list[str] | None = None,
103
+ ) -> dict:
104
+ """Run a GA4 realtime report."""
105
+ from google.analytics.data_v1beta.types import (
106
+ Dimension,
107
+ Metric,
108
+ RunRealtimeReportRequest,
109
+ )
110
+
111
+ from adloop.ga4.client import get_data_client
112
+
113
+ client = get_data_client(config)
114
+
115
+ request = RunRealtimeReportRequest(
116
+ property=property_id,
117
+ dimensions=[Dimension(name=d) for d in (dimensions or [])],
118
+ metrics=[Metric(name=m) for m in (metrics or ["activeUsers"])],
119
+ )
120
+
121
+ response = client.run_realtime_report(request)
122
+
123
+ dim_headers = [h.name for h in response.dimension_headers]
124
+ met_headers = [h.name for h in response.metric_headers]
125
+
126
+ rows = []
127
+ for row in response.rows:
128
+ r = {}
129
+ for i, val in enumerate(row.dimension_values):
130
+ r[dim_headers[i]] = val.value
131
+ for i, val in enumerate(row.metric_values):
132
+ r[met_headers[i]] = val.value
133
+ rows.append(r)
134
+
135
+ return {
136
+ "property": property_id,
137
+ "dimensions": dim_headers,
138
+ "metrics": met_headers,
139
+ "rows": rows,
140
+ "row_count": len(rows),
141
+ }