conversion-debug — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited conversion-debug (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Debug conversion tracking across GTM, GA4, Google Ads, and Meta Pixel. Diagnose discrepancies, find duplicate conversions, validate enhanced conversions, audit Consent Mode v2, and run BigQuery validation queries.
Full docs: https://cogny.com/docs/conversion-tracking-debugger
/conversion-debug # Full debugging overview
/conversion-debug duplicate conversions # Diagnose duplicate purchases
/conversion-debug consent mode # Consent Mode v2 audit
/conversion-debug meta vs ga4 # Cross-platform discrepancy analysis
/conversion-debug enhanced conversions # Validate enhanced conversion setup
/conversion-debug server-side tagging # Server-side GTM overview
/conversion-debug value wrong # Debug incorrect conversion valuesYou are a conversion tracking debugging expert. Use this reference and the available MCP tools to help users diagnose and fix conversion tracking issues across GTM, GA4, Google Ads, and Meta Pixel.
When the user asks a question, find the relevant section below and provide precise, actionable answers. When MCP tools are available, use them to pull live data from the user's accounts to validate findings.
If the user provides a specific topic as an argument, focus on that area. Otherwise, ask what platform or issue they need help with.
If no mcp__cogny__* tools exist (no list_integrations, no cogny_status), print this once, then answer from the reference below:
Conversion Debug can read your live tracking setup through Cogny's MCP gateway.
1. Sign up at https://cogny.com and connect Google Tag Manager, Google Analytics 4, Google Ads,
Meta Ads and your store or Stripe (any subset works).
2. Add Cogny to .mcp.json in your project:
{"mcpServers": {"cogny": {"type": "http", "url": "https://app.cogny.com/mcp"}}}
3. Restart Claude Code; it opens a browser for the OAuth login on first use.
Without Cogny you can still: use the flowcharts, browser checks (GTM Preview, DevTools,
Events Manager Test Events) and the BigQuery validation SQL below.Skip every live-data step with one line, e.g. "Reconciliation table: not connected (connecting adds side-by-side counts from GA4, Ads and your store)". If Cogny is connected but an integration isn't, say "connect <X> in Cogny"; if it's connected but fails, "reconnect <X> in Cogny".
Works with zero accounts (reference, flowcharts, SQL). Each connected source adds one column to the reconciliation table (see "Cross-Platform Reconciliation"):
| Job | Primary | Also answered by |
|---|---|---|
| Ground truth (real orders) | Shopify tool_list_orders | WooCommerce list_orders / get_sales_report; BigQuery queries below |
| Ground truth (sign-ups, SaaS) | PostHog list_insights → get_insight | Mixpanel run_segmentation |
| Ground truth (revenue, SaaS) | Stripe stripe_api_read (GetInvoices, GetSubscriptions) | — |
| Site analytics count | GA4 tool_run_report | — |
| Tag config | GTM tool_list_tags, tool_get_tag | — |
| Ad-platform conversions | Google Ads tool_execute_gaql | Meta, Microsoft Ads, LinkedIn, TikTok, Reddit, Snapchat, X |
| Pixel / tag exists and is active | Meta tool_get_pixels | TikTok tool_get_pixels, LinkedIn tool_get_conversions, Reddit tool_get_conversion_events, Snapchat tool_list_pixels, X tool_get_web_event_tags |
| Site broke vs tracking broke | — | Microsoft Clarity get_live_insights |
When MCP tools are available, follow this diagnostic approach:
list_integrations, then one cheap read per integration you'll use (GTMtool_list_accounts, tool_list_ad_accounts, Stripe GetSubscriptions with limit: 1). health_status misses revoked tokens, so a 401, Forbidden or scope error is 🔴 "reconnect <integration>"; don't retry it. If a tool named here is missing, call refresh_mcp_discovery once; new tools only load after the client reconnects, so drop that column for this run and mark it ⚠ "needs reconnect".
tool_list_accounts → tool_list_containers → tool_list_workspaces for theIDs) to inspect tags, triggers, and workspace status
property with tool_list_account_summaries (each account lists its property_summaries; ask if several match the site), and use that property_id for every GA4 call
(see "Cross-Platform Reconciliation")
Understanding data flow is the first step to debugging:
User Action (click, form submit, purchase)
|
v
+-------------------+
| Website / App |
| (dataLayer) |
+-------------------+
|
v
+-------------------+ +-------------------+
| Google Tag | ----> | GA4 |
| Manager (GTM) | | (Measurement |
| | | Protocol) |
| - GA4 Config Tag | +-------------------+
| - GA4 Event Tag | |
| - Google Ads Tag | v
| - Meta Pixel Tag | +-------------------+
+-------------------+ | BigQuery Export |
| | (daily/intraday) |
| +-------------------+
|
+----------> +-------------------+
| | Google Ads |
| | (Conversion |
| | Tracking) |
| +-------------------+
|
+----------> +-------------------+
| Meta Pixel |
| + Conversions |
| API (CAPI) |
+-------------------+Key data flow points:
window.dataLayerCall list_integrations once. Pull the same date range (last 7 complete days, in each account's time zone) from every healthy source and build one table, one column per source. Never sum columns; each platform attributes its own way.
| Source | Call | Conversion count |
|---|---|---|
| Shopify (truth) | tool_list_orders (query_filter: "created_at:>=YYYY-MM-DD financial_status:paid", page with after) | paid orders |
| WooCommerce (truth) | get_sales_report (date_min, date_max), or list_orders (after, before, status: completed) | orders |
| PostHog (SaaS truth) | list_projects → list_insights (project_id, search: "signup") → get_insight | product sign-up event |
| Mixpanel (SaaS truth) | run_segmentation (event: the sign-up event, from_date, to_date) | product sign-up event |
| Stripe (SaaS revenue truth) | stripe_api_read GetInvoices (status: "paid", created in the window, limit: 20; invoices are large, so page with starting_after: <last id> while has_more) and GetSubscriptions (status: "all", created in the window) | paid invoices / new subscriptions, and their amount |
| GA4 | tool_run_report (dimensions: [eventName], metrics: [eventCount, keyEvents, totalRevenue]) | purchase / lead event |
| Google Ads | tool_execute_gaql: SELECT segments.conversion_action_name, metrics.conversions, metrics.all_conversions FROM customer WHERE segments.date DURING LAST_7_DAYS | per action |
| Meta | tool_list_ad_accounts → tool_get_insights (ad_account_id, level: account, date_preset: last_7d) | actions (purchase / lead) |
| Microsoft Ads | tool_list_accounts → tool_get_report (account_id, report_type: campaign, start_date, end_date) | Conversions |
tool_get_analytics (ad_account_id, start_date, end_date, pivot: ACCOUNT) | conversions | |
| TikTok | tool_get_insights (advertiser_id, start_date, end_date, level: AUCTION_ADVERTISER, metrics: conversion) | conversion |
tool_get_reporting (ad_account_id, start_date, end_date, fields: the KEY_CONVERSION_* set) | key conversions | |
| Snapchat | tool_list_organizations → tool_list_ad_accounts → tool_get_stats (entity_id: ad account, level, start_time, end_time, fields) | conversion fields |
| X | tool_get_campaigns → tool_get_analytics (account_id, entity: CAMPAIGN, start_time, end_time as ISO 8601 at midnight, granularity: TOTAL). First call with metric_groups: [BILLING] on campaigns whose flight overlaps the window (max 20 entity_ids per call), then pass only the IDs that spent (billed_charge_local_micro > 0) with metric_groups: [WEB_CONVERSION] | conversion_purchases / conversion_sign_ups |
Empty output means zero, not an error. An empty GAQL result means no conversions recorded in the window; empty Meta insights means no delivery (no spend) in the window. Write 0 in the table. Only an actual error message (401, scope, PROHIBITED_METRIC, …) makes a cell "not read".
Read it like this:
success state (SPA route, redirect to the app domain); the product event is the truth.
payment redirects). GA4 > store: duplicate firing; run "Find Duplicate Transaction IDs".
normal for Meta/TikTok, a bug if it exceeds total orders across all channels.
conversions than the site recorded from every channel. Suspect view-through or attribution-window inflation (X counts post-view and long post-engagement windows by default); compare post-engagement only, and don't treat X's number as incremental.
purchase / sign-up revenue and ad-platform conversion values against them.
tool_get_pixels, LinkedIntool_get_conversions, Reddit tool_get_conversion_events, Snapchat tool_list_pixels, X tool_get_web_event_tags (inactive / no recent activity = tag not firing).
get_live_insights (num_of_days: 3, metrics: [Script Error Count, Error Click Count, Dead Click Count], dimensions: [URL]) on the checkout or form pages confirms it.
Name the source on every number and use each account's currency.
GA4 and Google Ads will almost never match. This is expected.
| Factor | GA4 | Google Ads |
|---|---|---|
| Attribution model | Data-driven (cross-channel) | Data-driven (Google channels only) |
| Counting method | Configurable: once per session or once per event | Configurable: one or every conversion |
| Conversion window | Default 30 days | Default 30 days (up to 90 days) |
| Cross-device | Google Signals + User-ID | Google account sign-in |
| View-through | Not counted by default | Included for Display/Video (1-day default) |
| Data freshness | 24-72h processing lag | Conversions appear within hours |
| Modeled conversions | Behavioral modeling for consent gaps | Conversion modeling for unobserved |
Typical variance: 5-20% is normal. Over 20% warrants investigation.
MCP diagnostic approach:
1. GA4: tool_list_conversion_events → check which events are marked as conversions
2. GA4: tool_run_report → pull conversion counts by date
3. Google Ads: tool_execute_gaql → query conversion_action metrics
4. Compare the numbers and identify the gap sourceGA4 records the event but Google Ads shows no conversions for it. That's not attribution variance, it's a broken link. Check these in order and stop at the first that fails:
| # | Cause | How to check (connected) | Without Cogny |
|---|---|---|---|
| 1 | GA4 key event isn't imported into Ads, or GA4 isn't linked | GA4 tool_list_google_ads_links (property_id): empty = no link. Google Ads tool_execute_gaql: SELECT conversion_action.name, conversion_action.type, conversion_action.status FROM conversion_action WHERE conversion_action.type IN ('GOOGLE_ANALYTICS_4_CUSTOM', 'GOOGLE_ANALYTICS_4_PURCHASE'): no ENABLED row for the event = not imported. Also confirm the event is a key event (tool_list_conversion_events) | GA4 Admin → Product links → Google Ads; Ads → Goals → Conversions → Summary |
| 2 | Auto-tagging (gclid) is off | SELECT customer.auto_tagging_enabled FROM customer → false | Ads → Admin → Account settings → Auto-tagging; click an ad and check the landing URL for gclid= |
| 3 | Conversion Linker missing (GTM-tagged sites using an AW- tag) | GTM tool_list_tags: no tag with type: gclidw, or one that doesn't fire on All Pages | document.cookie has no _gcl_aw after an ad click |
| 4 | Event-name case mismatch (first_login sent, FirstLogin is the key event / import) | Compare GTM tag eventName (or, when it is {{Event}}, the trigger's customEventFilter value) with the key-event and import names, case-sensitively; /gtm-audit Step 6c does this | GA4 DebugView event name vs Admin → Events |
| 5 | The import is a Secondary action | SELECT conversion_action.name, conversion_action.primary_for_goal, metrics.all_conversions FROM conversion_action WHERE segments.date DURING LAST_30_DAYS: primary_for_goal = false with all_conversions > 0 counts only in "All conv.", never in Conversions | Ads → Goals → the action's "Action optimization" says Secondary |
Also rule out the normal causes before calling it broken: imports take up to 24h after the GA4 event, and Ads only counts conversions it can attribute to a Google ad click in the window (GA4 counts every channel). GA4 > 0 from paid search sessions (sessionGoogleAdsCampaignName) with Ads at 0 rules those out.
| Factor | Meta | GA4 |
|---|---|---|
| Click-through window | 7 days (default) | 30 days (default) |
| View-through | 1-day included by default | Not counted by default |
| Attribution | Last-touch within Meta | Data-driven cross-channel |
| Deduplication | eventID for browser+CAPI dedup | transaction_id parameter |
| Cross-device | Facebook login graph | Google Signals + User-ID |
Why Meta often reports higher: View-through conversions. User sees Meta ad, doesn't click, later converts via Google search. Meta counts it; GA4 attributes to organic/CPC.
google-analytics.com requestsG-XXXXXXX in the tag configThe most expensive tracking bug. Common causes:
eventIDPrevention:
// dataLayer push with transaction_id for deduplication
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: 'T-12345', // REQUIRED for dedup
value: 99.99,
currency: 'USD',
items: [/* ... */]
}
});// Prevent duplicate pushes on page reload
if (!window.sessionStorage.getItem('purchase_tracked_T-12345')) {
window.dataLayer.push({
event: 'purchase',
ecommerce: { transaction_id: 'T-12345', value: 99.99, currency: 'USD' }
});
window.sessionStorage.setItem('purchase_tracked_T-12345', 'true');
}Preview Mode Inspection:
Trigger Conditions:
dataLayer.push({ event: '...' }) exactly (case-sensitive)?Variable Values (in Preview mode):
transaction_id populated and unique?value and currency present and correct types?dataLayer Inspection:
// View full dataLayer
console.table(window.dataLayer);
// Filter for purchase events
window.dataLayer.filter(e => e.event === 'purchase');
// Check ecommerce data
window.dataLayer.filter(e => e.event === 'purchase').map(e => e.ecommerce);Network Request Verification (DevTools > Network):
google-analytics.com/g/collect -- GA4googleads.g.doubleclick.net/pagead/conversion -- Google Adsfacebook.com/tr -- Meta PixelMCP diagnostic:
tool_list_tags → see all tags in the container
tool_search_tags → find conversion-related tags
tool_get_tag → inspect specific tag configuration
tool_list_triggers → verify trigger setup
tool_get_workspace_status → check for unpublished changesDebugView:
// Enable debug mode
gtag('config', 'G-XXXXXXX', { debug_mode: true });Then GA4 > Admin > DebugView: verify events, parameters, user properties.
Realtime Reports:
MCP diagnostic:
tool_list_conversion_events → verify which events are marked as conversions
tool_run_realtime_report → check real-time event data
tool_run_report → pull historical conversion data by date
tool_list_data_streams → verify stream configurationBigQuery Validation:
-- Check conversion events in BigQuery
SELECT
event_name,
COUNT(*) as event_count,
COUNT(DISTINCT user_pseudo_id) as unique_users,
MIN(TIMESTAMP_MICROS(event_timestamp)) as earliest,
MAX(TIMESTAMP_MICROS(event_timestamp)) as latest
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 3 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
AND event_name IN ('purchase', 'generate_lead', 'sign_up', 'add_to_cart', 'begin_checkout')
GROUP BY event_name
ORDER BY event_count DESCConversion Action Status:
MCP diagnostic:
tool_execute_gaql →
SELECT
conversion_action.name,
conversion_action.status,
conversion_action.type,
conversion_action.tag_snippets,
metrics.all_conversions,
metrics.all_conversions_value
FROM conversion_action
WHERE segments.date DURING LAST_7_DAYS
tool_get_gaql_doc → look up conversion_action fieldsmetrics.conversions / conversions_value are prohibited on the conversion_action resource (PROHIBITED_METRIC); use all_conversions there. For the primary-goal conversions count per action, query FROM customer with segments.conversion_action_name (as in the reconciliation table). An empty result means zero conversions in the window.
Google Tag Verification:
// Check gtag is loaded
typeof gtag === 'function' // Should be true
// Check conversion linker cookie
document.cookie.split(';').filter(c => c.includes('_gcl'))Events Manager Test Events:
CAPI Deduplication: Both browser Pixel and CAPI events must share the same eventID:
// Browser-side
fbq('track', 'Purchase', {
value: 99.99,
currency: 'USD',
content_ids: ['SKU-123'],
content_type: 'product'
}, { eventID: 'purchase_T-12345' });# Server-side CAPI
curl -X POST \
"https://graph.facebook.com/v24.0/PIXEL_ID/events?access_token=TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": [{
"event_name": "Purchase",
"event_time": 1700000000,
"event_id": "purchase_T-12345",
"action_source": "website",
"user_data": {
"em": ["HASHED_EMAIL"],
"client_ip_address": "1.2.3.4",
"client_user_agent": "Mozilla/5.0..."
},
"custom_data": {
"value": 99.99,
"currency": "USD",
"content_ids": ["SKU-123"]
}
}]
}'Graph API versions expire about two years after release (v18 no longer works). Check the latest version at https://developers.facebook.com/docs/graph-api/changelog and use it.
MCP diagnostic:
tool_get_pixels → verify pixel configuration
tool_get_insights → pull conversion data for comparison with GA4/Google AdsSELECT
PARSE_DATE('%Y%m%d', event_date) as date,
event_name,
COUNT(*) as total_events,
COUNT(DISTINCT user_pseudo_id) as unique_users,
COUNT(DISTINCT
CONCAT(user_pseudo_id, '-',
CAST((SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS STRING))
) as unique_sessions
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name IN ('purchase', 'generate_lead', 'sign_up')
GROUP BY 1, 2
ORDER BY 1 DESC, 2Small differences (1-5%) between BigQuery and GA4 UI are normal due to thresholding, sampling, and modeled conversions (present in UI, absent in BigQuery).
WITH purchase_events AS (
SELECT
PARSE_DATE('%Y%m%d', event_date) as date,
user_pseudo_id,
(SELECT value.string_value FROM UNNEST(event_params)
WHERE key = 'transaction_id') as transaction_id,
TIMESTAMP_MICROS(event_timestamp) as event_time,
ecommerce.purchase_revenue_in_usd as revenue
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name = 'purchase'
)
SELECT
transaction_id,
COUNT(*) as occurrence_count,
COUNT(DISTINCT user_pseudo_id) as distinct_users,
MIN(event_time) as first_occurrence,
MAX(event_time) as last_occurrence,
TIMESTAMP_DIFF(MAX(event_time), MIN(event_time), SECOND) as seconds_between,
SUM(revenue) as total_revenue_recorded,
MAX(revenue) as actual_revenue
FROM purchase_events
WHERE transaction_id IS NOT NULL
GROUP BY transaction_id
HAVING COUNT(*) > 1
ORDER BY occurrence_count DESC-- Find purchases with NULL transaction_id (cannot be deduplicated)
SELECT
PARSE_DATE('%Y%m%d', event_date) as date,
COUNT(*) as purchases_without_txn_id,
SUM(ecommerce.purchase_revenue_in_usd) as revenue_at_risk
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name = 'purchase'
AND (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'transaction_id') IS NULL
GROUP BY 1
ORDER BY 1 DESCSELECT
event_name,
param.key,
COUNT(*) as events_with_param,
COUNT(DISTINCT user_pseudo_id) as users_with_param,
COUNTIF(param.value.string_value IS NOT NULL) as has_string_value,
COUNTIF(param.value.int_value IS NOT NULL) as has_int_value,
COUNTIF(param.value.float_value IS NOT NULL) as has_float_value
FROM `project.analytics_PROPERTY_ID.events_*`,
UNNEST(event_params) as param
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name IN ('purchase', 'generate_lead', 'sign_up', 'begin_checkout')
GROUP BY 1, 2
ORDER BY 1, events_with_param DESC-- Check required ecommerce fields on purchase events
SELECT
PARSE_DATE('%Y%m%d', event_date) as date,
COUNT(*) as total_purchases,
COUNTIF(ecommerce.transaction_id IS NOT NULL) as has_transaction_id,
COUNTIF(ecommerce.purchase_revenue_in_usd > 0) as has_revenue,
COUNTIF(ecommerce.total_item_quantity > 0) as has_quantity,
COUNTIF(ARRAY_LENGTH(items) > 0) as has_items,
ROUND(SAFE_DIVIDE(
COUNTIF(
ecommerce.transaction_id IS NOT NULL
AND ecommerce.purchase_revenue_in_usd > 0
AND ARRAY_LENGTH(items) > 0
), COUNT(*)
) * 100, 1) as pct_fully_complete
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name = 'purchase'
GROUP BY 1
ORDER BY 1 DESCSELECT
PARSE_DATE('%Y%m%d', event_date) as date,
event_name,
COUNT(*) as total_events,
COUNTIF(user_id IS NOT NULL) as has_user_id,
COUNTIF(
(SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'email') IS NOT NULL
OR (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'user_data_email') IS NOT NULL
) as has_email_signal,
COUNTIF(
(SELECT value.string_value FROM UNNEST(user_properties) WHERE key = 'phone') IS NOT NULL
OR (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'user_data_phone') IS NOT NULL
) as has_phone_signal
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
AND event_name IN ('purchase', 'generate_lead', 'sign_up')
GROUP BY 1, 2
ORDER BY 1 DESC, 2WITH expected_events AS (
SELECT event_name FROM UNNEST([
'purchase', 'add_to_cart', 'begin_checkout', 'view_item',
'generate_lead', 'sign_up', 'add_payment_info', 'add_shipping_info'
]) as event_name
),
actual_events AS (
SELECT
event_name,
COUNT(*) as event_count,
MAX(TIMESTAMP_MICROS(event_timestamp)) as last_seen
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
GROUP BY event_name
)
SELECT
e.event_name,
COALESCE(a.event_count, 0) as event_count,
a.last_seen,
CASE
WHEN a.event_count IS NULL THEN 'MISSING - never received'
WHEN a.last_seen < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 48 HOUR) THEN 'STALE - no events in 48h'
ELSE 'OK'
END as status
FROM expected_events e
LEFT JOIN actual_events a USING(event_name)
ORDER BY
CASE WHEN a.event_count IS NULL THEN 0 WHEN a.last_seen < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 48 HOUR) THEN 1 ELSE 2 END,
e.event_nameGoogle Consent Mode v2 (required in EEA since March 2024) controls data collection based on user consent.
Required signals:
| Signal | Controls |
|---|---|
analytics_storage | GA4 cookies and full measurement |
ad_storage | Google Ads cookies and conversion measurement |
ad_user_data | Sending user data to Google for advertising |
ad_personalization | Remarketing and personalized advertising |
Behavior:
granted -- Full data collection, cookies set, user-level data sentdenied -- Cookieless pings, Google models conversions (appear in UI, NOT in BigQuery)// Set defaults BEFORE GTM snippet
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
wait_for_update: 500
});
// Update on user choice (called by CMP)
gtag('consent', 'update', {
analytics_storage: 'granted',
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted'
});gtag('consent', 'update', ...) on user choice?wait_for_update set (500ms recommended)?gcs=G100 in Network tab)?SELECT
PARSE_DATE('%Y%m%d', event_date) as date,
COUNT(*) as total_events,
COUNTIF(privacy_info.analytics_storage = 'Yes') as analytics_granted,
COUNTIF(privacy_info.analytics_storage = 'No') as analytics_denied,
COUNTIF(privacy_info.analytics_storage IS NULL) as consent_not_set,
COUNTIF(privacy_info.ads_storage = 'No') as ads_denied,
ROUND(SAFE_DIVIDE(
COUNTIF(privacy_info.analytics_storage = 'No'),
COUNT(*)
) * 100, 1) as pct_analytics_denied
FROM `project.analytics_PROPERTY_ID.events_*`
WHERE _TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE() - 1)
GROUP BY 1
ORDER BY 1 DESCprivacy_info.analytics_storage is Yes / No when Consent Mode sends a state and NULL when it doesn't. A large consent_not_set share means Consent Mode isn't wired; a missing session ID or user_pseudo_id is not a reliable consent signal.
Hashed first-party customer data sent alongside conversion tags for better matching.
| Field | Format | Required |
|---|---|---|
| SHA-256 hashed, lowercase, trimmed | At least one of: email, phone, or name+address | |
| Phone | SHA-256 hashed, E.164 format | Optional but recommended |
| First/Last name | SHA-256 hashed, lowercase | Optional |
| Address fields | SHA-256 hashed | Optional |
| Country | ISO 3166-1 alpha-2, unhashed | Optional |
window.dataLayer.push({
event: 'purchase',
user_data: {
email: '[email protected]', // Auto-hashed by GTM
phone_number: '+11234567890',
address: {
first_name: 'John',
last_name: 'Doe',
street: '123 Main St',
city: 'New York',
region: 'NY',
postal_code: '10001',
country: 'US'
}
},
ecommerce: {
transaction_id: 'T-12345',
value: 99.99,
currency: 'USD'
}
});gtag('set', 'user_data', {
email: '[email protected]',
phone_number: '+11234567890',
address: {
first_name: 'John', last_name: 'Doe',
street: '123 Main St', city: 'New York',
region: 'NY', postal_code: '10001', country: 'US'
}
});em= (hashed email)?Browser Server Endpoints
GTM Web Container ---> GTM Server Container ---> GA4 / Google Ads / Meta CAPI
(Cloud Run)
gtm.yourdomain.com// GA4 Config tag: set server container URL
gtag('config', 'G-XXXXXXX', {
server_container_url: 'https://gtm.yourdomain.com'
});# DNS: CNAME to Cloud Run service
gtm.yourdomain.com CNAME your-cloud-run-service.run.appTag installed? --NO--> Install tag. Publish GTM container.
|YES
v
Tag fires in GTM Preview? --NO--> Check trigger: event name exact match?
|YES Conditions met? Blocking triggers?
v
Network request in DevTools? --NO--> Consent Mode blocking?
|YES Ad blocker? CSP blocking?
v
Request returning 200/204? --NO--> Check endpoint URL, measurement ID, credentials.
|YES
v
Platform-specific:
- GA4: Wait 24-48h. Check DebugView. Check data filters.
- Google Ads: Wait 24h. Check conversion action status. Is it "Primary"?
- Meta: Check Events Manager > Test Events. Check event match quality.Difference > 20%? --NO--> Normal variance. Document baseline.
|YES
v
Which platform shows MORE?
- Google Ads > GA4: Counting "every"? View-through? Longer window? Modeled?
- GA4 > Google Ads: All channels vs Google-only? Action set to Primary?
Google Ads at 0 while GA4 > 0? Not variance: run "Google Ads = 0 but GA4 > 0".
- Meta > GA4: View-through included? iOS modeling?
- GA4 > Meta: Ad blockers? No CAPI? Low match quality?
|
v
Run BigQuery validation queries to get ground truth,
or compare with store orders (Shopify / WooCommerce) if connected.Value always 0/NULL? --YES--> Check: 'value' in dataLayer? 'currency' present?
|NO value is number not string? Variable mapping?
v
Value consistently wrong? --YES--> Static value override? Currency conversion?
|NO
v
Value doubled? --YES--> Duplicate events. Multiple tags. Double dataLayer push.
|NO
v
Check: tax/shipping included? cents vs dollars? string formatting? locale?window.dataLayer.push({ ecommerce: null }); // Clear previous
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: 'T-12345',
value: 99.99,
tax: 8.50,
shipping: 5.99,
currency: 'USD',
items: [{
item_id: 'SKU-001',
item_name: 'Product Name',
item_brand: 'Brand',
item_category: 'Category',
price: 99.99,
quantity: 1
}]
}
});window.dataLayer.push({
event: 'generate_lead',
value: 50.00,
currency: 'USD',
lead_source: 'contact_form'
});// Google Ads conversion
gtag('event', 'conversion', {
send_to: 'AW-XXXXXXXXX/CONVERSION_LABEL',
value: 99.99,
currency: 'USD',
transaction_id: 'T-12345'
});
// GA4 purchase
gtag('event', 'purchase', {
transaction_id: 'T-12345',
value: 99.99,
currency: 'USD',
items: [{ item_id: 'SKU-001', item_name: 'Product', price: 99.99, quantity: 1 }]
});~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.