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/__init__.py +21 -0
- adloop/__main__.py +5 -0
- adloop/ads/__init__.py +0 -0
- adloop/ads/client.py +41 -0
- adloop/ads/forecast.py +156 -0
- adloop/ads/gaql.py +178 -0
- adloop/ads/read.py +238 -0
- adloop/ads/write.py +950 -0
- adloop/auth.py +132 -0
- adloop/cli.py +375 -0
- adloop/config.py +102 -0
- adloop/crossref.py +509 -0
- adloop/ga4/__init__.py +0 -0
- adloop/ga4/client.py +31 -0
- adloop/ga4/reports.py +141 -0
- adloop/ga4/tracking.py +36 -0
- adloop/safety/__init__.py +0 -0
- adloop/safety/audit.py +40 -0
- adloop/safety/guards.py +56 -0
- adloop/safety/preview.py +58 -0
- adloop/server.py +778 -0
- adloop/tracking.py +244 -0
- adloop-0.1.0.dist-info/METADATA +382 -0
- adloop-0.1.0.dist-info/RECORD +26 -0
- adloop-0.1.0.dist-info/WHEEL +4 -0
- adloop-0.1.0.dist-info/entry_points.txt +3 -0
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
|
+
}
|