Analytics Tracking Plan skill for Claude Code
Every event traced to a decision and verified live. Claude builds tracking plans backwards from the decisions stakeholders need to make, then turns them into exact dataLayer and Measurement Protocol payloads. A bundled validator catches GA4 reserved names, length and parameter limits, inconsistent property types, and likely PII before anything ships, and every event is checked in DebugView against the plan and reconciled with backend counts.
- name
analytics-tracking-plan - size212 lines · ≈2,300 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs analytics tracking plans and implements them in GA4 and Google Tag Manager, covering business questions to metrics, event and property naming conventions, the plan spreadsheet, dataLayer contracts, GA4 limits and reserved names, ecommerce events, Consent Mode v2, server-side Measurement Protocol, and implementation QA with DebugView. Use when creating or auditing a tracking plan, naming events, setting up GA4 or GTM, adding conversion or purchase tracking, when reports show duplicate or missing events, or when consent banners break analytics. Try asking
- Write a tracking plan for our SaaS signup and onboarding funnel and the GTM setup to match
- Our GA4 purchase count is 30% higher than Stripe. Audit the tracking
- Set up Consent Mode v2 and server-side purchase events with the Measurement Protocol
Use it when
- Creating or auditing a tracking plan and naming conventions
- Setting up GA4 and Google Tag Manager events or ecommerce tracking
- Analytics numbers disagree with backend records
- Adding Consent Mode v2 or server-side conversion events
Skip it when
- You are wiring PostHog or Mixpanel SDKs, identify, and group calls in app code — use product-analytics
- You need to interpret an experiment's results — use experiment-analysis
How to check the result
validate_tracking_plan.py exits 0 on the plan CSV, every event is confirmed in DebugView with correct properties, and key conversions reconcile with backend counts within a few percent.
The common mistake it prevents
Enabling GA4 enhanced measurement and also adding custom page-view or click tags, which double counts events and silently inflates every funnel.
A good tracking plan starts from decisions the business needs to make, defines the smallest set of events that answers them, names everything by one convention, and is the contract that code, tag manager, and reports are verified against. If an event is not in the plan, it is not shipped; if it is in the plan, there is a QA record proving it fires correctly.
Ground rules
- Questions first, events second. Every event traces to a metric someone will act on.
- Fewer, richer events:
signup_completedwith amethodproperty beatssignup_google,signup_email,signup_github. - One naming convention, enforced:
object_actionin snake_case, past tense (form_submitted,plan_upgraded). Properties snake_case, typed, with allowed values. - No PII in analytics: no emails, names, phone numbers, or free-text fields in event properties or URLs sent to GA4 (Google’s terms prohibit it). Use internal IDs.
- Conversions that matter for revenue are sent from the server (or confirmed by it), not only from the browser, where ad blockers and consent drop 10–30% of hits.
- Analytics loads under the consent rules of the user’s region. Consent Mode v2 is required for Google ads measurement of EEA/UK users.
Workflow
- [ ] 1. Collect business questions and KPIs
- [ ] 2. Map the user journey to candidate events
- [ ] 3. Write the plan (events, properties, triggers, owners)
- [ ] 4. Validate the plan against conventions and GA4 limits
- [ ] 5. Define the dataLayer / SDK contract for engineers
- [ ] 6. Implement (GTM + GA4, or SDK) with consent
- [ ] 7. QA every event against the plan
- [ ] 8. Register dimensions, key events, and govern changes
1. Questions and KPIs
Interview stakeholders: “What decision will you make with this number?” Turn answers into a metrics list: acquisition (signups by channel), activation (% reaching aha event in 7 days), engagement, conversion (trial → paid), revenue, retention. Each metric names its numerator and denominator events.
2. Journey to events
Walk the funnel screen by screen. Mark only the steps that are numerator/denominator of a metric, or a known drop-off point. Use GA4 enhanced measurement for generic interactions (page views, scrolls, outbound clicks, file downloads, site search, video) instead of custom tags — but disable any enhanced event you re-implement, or you double count.
Prefer Google’s recommended event names where they fit (sign_up, login, generate_lead,
view_item, add_to_cart, begin_checkout, purchase, refund): GA4 reports and ads
integrations understand them. Use custom object_action names for product events.
3. Write the plan
One row per event-property pair (long format) so it can be validated and diffed:
| event_name | property | type | required | allowed_values / example | trigger | source | owner | status |
|---|---|---|---|---|---|---|---|---|
| signup_completed | method | string | yes | email, google, github | Account created (server confirms) | server | growth | live |
| signup_completed | plan | string | yes | free, pro, team | same | server | growth | live |
| project_created | template_used | boolean | yes | true | First save of a project | client | product | planned |
| purchase | transaction_id | string | yes | ord_123 | Payment succeeded webhook | server (MP) | billing | planned |
Also record: user properties (plan, company_size, signup_cohort), identity rules
(when user_id is set and cleared), and which events are key events (conversions).
4. Validate the plan
python3 scripts/validate_tracking_plan.py tracking-plan.csv
The script checks the CSV for: snake_case and length ≤ 40 for event and property names,
GA4 reserved event names and prefixes (_, ga_, google_, firebase_, gtag.),
more than 25 properties per event, the same property typed differently across events,
case-variant duplicates, likely-PII property names, and missing triggers. It exits 1 on
errors. Required columns: event_name, property, type; optional trigger, required.
GA4 limits to design within (standard properties):
| Limit | Value |
|---|---|
| Event name / parameter name length | 40 characters |
| Parameter value length | 100 characters |
| Parameters per event | 25 |
| User property name / value length | 24 / 36 characters |
| User properties per property | 25 |
| Custom dimensions (event / user scoped) | 50 / 25 |
| Custom metrics | 50 |
Parameters not registered as custom dimensions are collected but do not appear in standard reports (they do appear in the BigQuery export).
5. dataLayer contract
Give engineers exact payloads. Product events:
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: "project_created",
template_used: true,
project_type: "kanban",
});
GA4 ecommerce (clear the previous ecommerce object first so items do not merge):
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({
event: "purchase",
ecommerce: {
transaction_id: "ord_8F2K1",
value: 49.0,
currency: "USD",
items: [{ item_id: "pro_annual", item_name: "Pro (annual)", price: 49.0, quantity: 1 }],
},
});
SPA route changes: push a virtual page view on history change, or rely on GA4 enhanced measurement’s “page changes based on browser history events” — not both.
6. Implement with consent
Consent Mode v2 defaults must run before any Google tag fires (GTM: Consent Initialization trigger, or inline before the tag):
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied',
analytics_storage: 'denied', wait_for_update: 500
});
</script>
On the user’s choice, the consent platform calls
gtag('consent', 'update', { analytics_storage: 'granted', ... }). Apply region-specific
defaults (denied for EEA/UK; check local law elsewhere).
GTM setup: one Google tag (GA4 measurement ID) on all pages; one GA4 Event tag per plan
event (or a single tag using {{Event}} with a lookup-table allowlist); Data Layer
Variables for each property; triggers on Custom Event names matching the plan exactly.
Server-side events via GA4 Measurement Protocol (keep api_secret in a server secret
store; never ship it to the browser):
curl -s -X POST "https://www.google-analytics.com/debug/mp/collect?measurement_id=$GA4_MEASUREMENT_ID&api_secret=$GA4_API_SECRET" \
-H "Content-Type: application/json" \
-d '{"client_id":"123.456","user_id":"u_42","events":[{"name":"purchase","params":{"transaction_id":"ord_8F2K1","value":49,"currency":"USD"}}]}'
The /debug/mp/collect endpoint returns validation messages; switch to /mp/collect in
production. Reuse the browser’s client_id (from the _ga cookie) so server events join
the same user and session.
7. QA against the plan
For every event in the plan:
- GTM Preview / Tag Assistant: tag fires once on the trigger, not on page load or twice.
- GA4 DebugView: event name and every required property present with the right type and an allowed value.
- Consent denied: no analytics cookies set; with Consent Mode, cookieless pings only.
- Ecommerce:
transaction_idunique; refresh of the thank-you page does not duplicatepurchase(dedupe on the server or guard in the client). - Cross-check counts: analytics
purchasevs. backend orders over a day, expect within ~5–10% after consent loss;signup_completedvs. database signups. - No PII in page URLs or properties (check query strings like
?email=).
Record each result in the plan’s status column (live, broken, deprecated).
8. Register and govern
- Register needed parameters as custom dimensions/metrics in GA4 Admin; mark key events.
- Set data retention to 14 months for explorations (default is 2) and enable BigQuery export if raw data is needed.
- Changes go through the plan first (PR on the CSV or a review in the plan tool), then code. Deprecate events by marking them, not deleting history.
Gotchas
- Renaming an event breaks historical continuity in reports; add the new one, run both, then retire the old.
page_viewfires twice when both the Google tag and a custom page-view tag are active.- High-cardinality values (IDs, URLs with parameters) in custom dimensions produce “(other)” rows in reports.
- GA4 thresholding and sampling hide small segments in the UI; use BigQuery for exact counts.
- Ad blockers block
googletagmanager.comfor a sizable share of technical audiences; server-side tagging or backend events are the mitigation for critical metrics.
Output: plan summary
## Tracking plan v3 — <product> (<date>)
KPIs → events: Activation = project_created(template_used) within 7d of signup_completed
Events: 14 (6 server, 8 client) · User properties: 4 · Key events: signup_completed, purchase
Validation: validate_tracking_plan.py 0 errors · QA: 14/14 verified in DebugView
Open issues: purchase double-fires on thank-you refresh (fix in order-confirm guard) Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/validate_tracking_plan.py
#!/usr/bin/env python3
"""Validate a tracking plan CSV against naming conventions and GA4 limits (stdlib only).
Usage: python3 validate_tracking_plan.py tracking-plan.csv [--no-ga4]
CSV columns (header row): event_name, property, type [, trigger, required, ...]
One row per event/property pair. An event with no properties may have an empty property.
Errors (exit 1): bad snake_case, names > 40 chars, GA4 reserved names/prefixes,
> 25 properties on an event, a property typed differently across events,
case-variant duplicate names. Warnings: likely PII property names, missing trigger,
event names that are neither object_action nor GA4 recommended names.
"""
from __future__ import annotations
import csv
import re
import sys
from collections import defaultdict
SNAKE = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")
RESERVED_PREFIXES = ("_", "ga_", "google_", "firebase_", "gtag.")
RESERVED_EVENTS = {
"ad_activeview", "ad_click", "ad_exposure", "ad_impression", "ad_query", "ad_reward",
"adunit_exposure", "app_background", "app_clear_data", "app_exception", "app_remove",
"app_store_refund", "app_store_subscription_cancel", "app_store_subscription_convert",
"app_store_subscription_renew", "app_update", "app_upgrade", "dynamic_link_app_open",
"dynamic_link_app_update", "dynamic_link_first_open", "error", "first_open",
"first_visit", "in_app_purchase", "notification_dismiss", "notification_foreground",
"notification_open", "notification_receive", "os_update", "session_start",
"session_start_with_rollout", "user_engagement",
}
GA4_RECOMMENDED = {
"add_payment_info", "add_shipping_info", "add_to_cart", "add_to_wishlist", "begin_checkout",
"close_convert_lead", "close_unconvert_lead", "disqualify_lead", "earn_virtual_currency",
"generate_lead", "join_group", "level_end", "level_start", "level_up", "login",
"page_view", "post_score", "purchase", "qualify_lead", "refund", "remove_from_cart",
"search", "select_content", "select_item", "select_promotion", "share", "sign_up",
"spend_virtual_currency", "tutorial_begin", "tutorial_complete", "unlock_achievement",
"view_cart", "view_item", "view_item_list", "view_promotion", "view_search_results",
"working_lead",
}
PII_HINTS = ("email", "phone", "first_name", "last_name", "full_name", "address",
"ssn", "password", "dob", "birth", "ip_address", "credit_card")
TYPES = {"string", "number", "integer", "boolean", "array", "object", "datetime", "date", "float", "currency"}
PAST_TENSE = re.compile(r"_(\w+ed|sent|seen|shown|won|lost|begun|done|made|paid|set|built|run|read)$")
def main(argv: list[str]) -> int:
if not argv:
print(__doc__)
return 2
ga4 = "--no-ga4" not in argv
errors: list[str] = []
warns: list[str] = []
props_by_event: dict[str, set[str]] = defaultdict(set)
types_by_prop: dict[str, set[str]] = defaultdict(set)
triggers: dict[str, str] = {}
with open(argv[0], newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
cols = {c.strip().lower() for c in reader.fieldnames or []}
missing = {"event_name", "property", "type"} - cols
if missing:
print(f"ERROR: missing columns: {', '.join(sorted(missing))}")
return 1
for i, raw in enumerate(reader, start=2):
row = {k.strip().lower(): (v or "").strip() for k, v in raw.items() if k}
ev, prop, typ = row["event_name"], row["property"], row["type"].lower()
if not ev:
errors.append(f"line {i}: empty event_name")
continue
if row.get("trigger"):
triggers[ev] = row["trigger"]
else:
triggers.setdefault(ev, "")
if prop:
props_by_event[ev].add(prop)
if typ:
types_by_prop[prop].add(typ)
if typ not in TYPES:
warns.append(f"line {i}: unusual type {typ!r} for {ev}.{prop}")
else:
errors.append(f"line {i}: {ev}.{prop} has no type")
else:
props_by_event.setdefault(ev, set())
def check_name(kind: str, name: str):
if not SNAKE.match(name):
errors.append(f"{kind} {name!r}: not snake_case")
if len(name) > 40:
errors.append(f"{kind} {name!r}: {len(name)} chars (GA4 max 40)")
if ga4 and name.lower().startswith(RESERVED_PREFIXES):
errors.append(f"{kind} {name!r}: reserved GA4 prefix")
for ev, props in sorted(props_by_event.items()):
check_name("event", ev)
if ga4 and ev in RESERVED_EVENTS:
errors.append(f"event {ev!r}: reserved by GA4 (collected automatically)")
if ev not in GA4_RECOMMENDED and "_" in ev and not PAST_TENSE.search(ev):
warns.append(f"event {ev!r}: convention is object_action in past tense (e.g. form_submitted)")
if "_" not in ev and ev not in GA4_RECOMMENDED:
warns.append(f"event {ev!r}: single word; prefer object_action")
if ga4 and len(props) > 25:
errors.append(f"event {ev!r}: {len(props)} properties (GA4 max 25 per event)")
if not triggers.get(ev):
warns.append(f"event {ev!r}: no trigger described")
for p in props:
check_name("property", p)
if any(h in p.lower() for h in PII_HINTS):
warns.append(f"property {ev}.{p}: looks like PII; send an internal ID instead")
for prop, types in sorted(types_by_prop.items()):
if len(types) > 1:
errors.append(f"property {prop!r}: inconsistent types across events {sorted(types)}")
seen: dict[str, str] = {}
for name in list(props_by_event) + list(types_by_prop):
key = name.lower().replace("-", "_")
if key in seen and seen[key] != name:
errors.append(f"case/format variants: {seen[key]!r} vs {name!r}")
seen.setdefault(key, name)
unique_props = len(types_by_prop)
print(f"{len(props_by_event)} events, {unique_props} distinct properties")
if ga4 and unique_props > 50:
warns.append(f"{unique_props} distinct properties: only 50 event-scoped custom dimensions can be registered")
for w in warns:
print(f"WARN {w}")
for e in errors:
print(f"ERROR {e}")
print(f"\n{len(errors)} error(s), {len(warns)} warning(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.