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.

  • nameanalytics-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

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.

~/.claude/skills/analytics-tracking-plan/SKILL.md

--- name: analytics-tracking-plan description: Designs analytics tracking plans and implements them in GA4 and Google Tag Manager, covering business questions to metrics, event and… license: MIT ---

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_completed with a method property beats signup_google, signup_email, signup_github.
  • One naming convention, enforced: object_action in 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_namepropertytyperequiredallowed_values / exampletriggersourceownerstatus
signup_completedmethodstringyesemail, google, githubAccount created (server confirms)servergrowthlive
signup_completedplanstringyesfree, pro, teamsameservergrowthlive
project_createdtemplate_usedbooleanyestrueFirst save of a projectclientproductplanned
purchasetransaction_idstringyesord_123Payment succeeded webhookserver (MP)billingplanned

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):

LimitValue
Event name / parameter name length40 characters
Parameter value length100 characters
Parameters per event25
User property name / value length24 / 36 characters
User properties per property25
Custom dimensions (event / user scoped)50 / 25
Custom metrics50

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.

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_id unique; refresh of the thank-you page does not duplicate purchase (dedupe on the server or guard in the client).
  • Cross-check counts: analytics purchase vs. backend orders over a day, expect within ~5–10% after consent loss; signup_completed vs. 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_view fires 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.com for 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 136 lines · 6.4 KB
#!/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.

esc