Analytics Tracking Plan skill for Codex

One event taxonomy, enforced in code. Codex derives events from the questions the team needs answered, names them in a strict object_action past-tense convention, and specifies typed properties, triggers, owners and versions. A bundled Python linter flags naming drift, duplicates, untyped or personal-data properties and events tracked in code but absent from the plan, for Segment, PostHog, Amplitude, Mixpanel or GA4 setups.

  • Product Management
  • $analytics-tracking-plan
  • 170 lines
  • 3 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- analytics-tracking-plan

Installs to ~/.agents/skills/analytics-tracking-plan/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $analytics-tracking-plan, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs and maintains product analytics tracking plans - an event taxonomy in object_action form, typed properties, user and group traits, triggers defined at the source of truth, owners and versioning - and audits instrumented code against the plan with a bundled linter that catches naming drift, duplicates, untyped or PII-looking properties and events tracked in code but missing from the plan. Works for Segment, PostHog, Amplitude, Mixpanel and GA4. Use when asked to create a tracking plan, define analytics events for a feature, clean up messy event names, or check that instrumentation matches the spec.

Use it when

  • A new feature needs analytics events defined before it ships
  • Event names in the analytics tool are inconsistent or duplicated
  • You want to check instrumented code matches the agreed spec
  • Metrics cannot be computed because events are missing

Use something else when

  • You are configuring GA4 properties, conversions and consent mode: use ga4-analytics
  • You are setting up PostHog funnels, flags or session replay: use posthog-analytics
  • You are building the event pipeline and destinations: use segment-cdp

How to check the result

The linter reports zero errors with --scan, and each new event fires exactly once in staging with the planned property types.

The mistake it prevents

Tracking button clicks instead of outcomes, so events fire on failed actions and double-count retries.

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

--- name: analytics-tracking-plan description: Designs and maintains product analytics tracking plans - an event taxonomy in object_action form, typed properties, user and group traits, triggers… license: MIT ---

A tracking plan is done well when every event answers a named question, names follow one convention without exceptions, each event fires exactly once at a defined trigger from the most reliable source, properties are typed with allowed values, personal data is deliberate rather than accidental, and the code matches the plan.

Start from questions, not events

List the questions the plan must answer, then derive events. An event nobody will query is cost and noise.

| Question | Metric | Events needed |
|---|---|---|
| Where do new workspaces drop off before activation? | 7-day activation funnel | workspace_created, member_invited, invite_accepted, document_edited |
| Which templates lead to retained teams? | Week-4 retention by template | project_created (template), document_edited |

Metric definitions come from $product-metrics; this skill turns them into events.

Naming convention

Default: object_action, snake_case, past tense, object first so related events sort together.

GoodBadWhy bad
project_createdcreateProject, Create Project ClickedMixed case, UI-centric
invite_sentsend_invite, inviteNot past tense / no action
subscription_upgraded with from_plan, to_planupgraded_to_pro, upgraded_to_teamValues encoded in names
report_exported with formatcsv_export_clicked, pdf_export_clickedOne event per variant

If the workspace already uses “Object Actioned” title case (common in Segment setups), keep it; consistency beats preference. Never mix styles.

Rules:

  • Track outcomes, not clicks. invite_sent fires when the invite exists, not when a button is pressed. Click tracking is a separate, optional layer.
  • Put variants in properties (format: "csv"), never in names.
  • Limit the vocabulary of actions: created, updated, deleted, viewed, sent, accepted, started, completed, failed, upgraded, downgraded, canceled. Add new verbs deliberately.

Event spec

{
  "name": "invite_sent",
  "description": "A workspace member sent an invitation to an email address",
  "trigger": "Server, after the invitation row is committed in InvitationService.create",
  "source": "server",
  "owner": "growth",
  "version": 2,
  "properties": {
    "workspace_id": { "type": "string", "required": true },
    "inviter_role": { "type": "string", "required": true, "enum": ["owner", "admin", "member"] },
    "invite_method": { "type": "string", "required": true, "enum": ["email", "link", "slack"] },
    "invitee_count": { "type": "integer", "required": true }
  }
}

Client or server?

Fire fromWhen
ServerAnything about state changes, money, permissions; it cannot be blocked by ad blockers and fires once
ClientPure UI behaviour (viewed, opened, scrolled) and anything with no server round trip
Both, deliberatelyRarely; give them different names (checkout_viewed client, order_completed server)

Browser events are routinely lost to ad blockers, privacy settings and page unloads, so server counts run higher; never mix sources within one funnel step.

Identity and groups

  • Call identify once at signup and login with a stable internal user ID, never an email as the ID.
  • For B2B, attach the account/workspace as a group (Segment group, PostHog groups, Amplitude groups) so metrics can be computed per account.
  • Traits (plan, role, company_size) live on the user or group, not repeated on every event, unless the value at event time matters (then add it as an event property).

Personal data

  • Do not send emails, names, phone numbers, free text or IP addresses as event properties unless there is a documented purpose and consent basis.
  • Use IDs; join to personal data in the warehouse under access control.
  • Respect consent state: no non-essential tracking before consent where the law or the company’s policy requires it.
  • Deleting user data in an analytics tool is irreversible; get explicit confirmation before running any deletion or suppression request.

Workflow

- [ ] 1. Read AGENTS.md and find the analytics wrapper in the code (rg "track\(|capture\(")
- [ ] 2. Export or reconstruct the current plan; list existing events
- [ ] 3. Write questions and the metrics they need
- [ ] 4. Specify new events and properties; reuse existing ones where possible
- [ ] 5. Lint the plan (script below); fix errors
- [ ] 6. Instrument through the shared wrapper, typed if the codebase is TypeScript
- [ ] 7. Lint again with --scan to diff code against plan
- [ ] 8. Verify events in a debug view or local logs before merging

Lint script

scripts/lint_tracking_plan.py validates a JSON plan (format shown in the spec above, wrapped in {"events": [...]}) and can scan code for track(, capture(, logEvent( and gtag('event', ...) calls:

python3 scripts/lint_tracking_plan.py tracking-plan.json
python3 scripts/lint_tracking_plan.py tracking-plan.json --scan src/ --style snake

It errors on missing description, trigger or owner, naming violations, normalized duplicates (project_created vs Project Created), non-snake_case or untyped properties, and events in code that the plan lacks. It warns on non-past-tense actions, PII-looking property names and planned events not found in code (they may be server-side in another repo). Calls built from variables cannot be detected; check those by hand.

Typed wrapper (TypeScript)

Route all tracking through one module so names and properties are checked at compile time:

type Events = {
  invite_sent: { workspace_id: string; inviter_role: 'owner' | 'admin' | 'member';
                 invite_method: 'email' | 'link' | 'slack'; invitee_count: number };
  project_created: { project_id: string; template?: 'blank' | 'kanban' };
};

export function track<E extends keyof Events>(event: E, props: Events[E]): void {
  analytics.track(event, props); // Segment; swap for posthog.capture, amplitude.track, etc.
}

Tool notes

  • GA4: event and parameter names have length and count limits and reserved prefixes; check the current GA4 collection limits before finalizing names. Custom parameters need registering as custom dimensions to appear in reports.
  • Segment: Protocols can enforce a tracking plan at ingestion if the workspace has it.
  • PostHog / Amplitude / Mixpanel: each supports marking events as verified or hiding unplanned ones; use that to keep the UI in sync with the plan.

Gotchas

  • Renaming an event breaks every chart and funnel using it. Add the new name, run both for a transition period or map in the tool, then retire the old one with a version note.
  • Events fired in React effects can double-fire under StrictMode in development and on re-renders; fire on the user action or the server outcome.
  • Free-text properties explode cardinality and leak personal data.
  • Do not send the same event from client and server under one name.

Verification

Run the linter with --scan and get zero errors; trigger each new event once in a local or staging build and confirm it arrives exactly once with the planned property types (tool debug view, network tab, or server logs); and confirm each plan question can now be answered by a query over the specified events.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Analytics Tracking Plan"
  short_description: "One event taxonomy, enforced in code"
  default_prompt: "$analytics-tracking-plan Create a tracking plan for our new team invites feature"
scripts/lint_tracking_plan.py136 lines, 6.0 KB
#!/usr/bin/env python3
"""Lint a JSON tracking plan and optionally diff it against tracking calls in code.

Usage:
  python3 lint_tracking_plan.py tracking-plan.json [--style snake|title] [--scan src/]

Plan format:
  {"events": [{"name": "project_created", "description": "...", "trigger": "...",
               "owner": "growth", "properties": {
                 "plan": {"type": "string", "required": true, "enum": ["free", "pro"]}}}]}

Checks: required fields, event naming style (snake: object_action, e.g.
project_created; title: "Project Created"), past-tense action, duplicates after
normalization, snake_case property names, valid property types, likely PII
property names, and (with --scan) events called in code but missing from the
plan or planned but never called. Exits 1 on errors; warnings alone exit 0.
Standard library only.
"""
import argparse
import json
import os
import re
import sys

TYPES = {"string", "number", "integer", "boolean", "array", "object", "datetime"}
PII = re.compile(r"(^|_)(email|phone|first_name|last_name|full_name|address|ip|ip_address|ssn|dob|birth|password|token)($|_)")
SNAKE_EVENT = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)+$")
TITLE_EVENT = re.compile(r"^[A-Z][a-z0-9]*( [A-Z][a-z0-9]*)+$")
SNAKE_PROP = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")
CALLS = re.compile(
    r"""(?:\.track|\.capture|\.logEvent|trackEvent|gtag\(\s*['"]event['"]\s*,)\s*\(?\s*['"]([^'"]{2,80})['"]"""
)
CODE_EXT = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte", ".py", ".rb", ".kt", ".swift", ".dart", ".go"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", "vendor", "__pycache__", ".venv"}


def norm(name):
    return re.sub(r"[^a-z0-9]", "", name.lower())


def past_tense(action):
    return action.endswith("ed") or action in {"sent", "viewed", "began", "won", "lost", "paid", "shown", "seen", "built", "made", "set", "reset", "upgrade_started"}


def lint(plan, style):
    errors, warns, seen = [], [], {}
    events = plan.get("events")
    if not isinstance(events, list) or not events:
        return ["plan must contain a non-empty 'events' list"], warns
    for i, ev in enumerate(events):
        name = ev.get("name", "")
        where = f"event[{i}] '{name}'"
        for field in ("name", "description", "trigger", "owner"):
            if not str(ev.get(field, "")).strip():
                errors.append(f"{where}: missing '{field}'")
        if style == "snake" and name and not SNAKE_EVENT.match(name):
            errors.append(f"{where}: expected object_action snake_case, e.g. project_created")
        if style == "title" and name and not TITLE_EVENT.match(name):
            errors.append(f"{where}: expected 'Object Actioned' title case, e.g. Project Created")
        if name:
            parts = name.replace(" ", "_").lower().split("_")
            if len(parts) >= 2 and not past_tense(parts[-1]):
                warns.append(f"{where}: action '{parts[-1]}' is not past tense; events describe what happened")
            key = norm(name)
            if key in seen:
                errors.append(f"{where}: duplicates '{seen[key]}' after normalization")
            seen[key] = name
        props = ev.get("properties", {})
        if not isinstance(props, dict):
            errors.append(f"{where}: 'properties' must be an object")
            continue
        for pname, spec in props.items():
            pw = f"{where} property '{pname}'"
            if not SNAKE_PROP.match(pname):
                errors.append(f"{pw}: use snake_case")
            ptype = (spec or {}).get("type") if isinstance(spec, dict) else None
            if ptype not in TYPES:
                errors.append(f"{pw}: type must be one of {sorted(TYPES)}")
            if PII.search(re.sub(r"(?<=[a-z0-9])([A-Z])", r"_\1", pname).lower()):
                warns.append(f"{pw}: looks like personal data; confirm consent, hashing or removal")
            if isinstance(spec, dict) and "required" not in spec:
                warns.append(f"{pw}: state required true/false")
    return errors, warns


def scan(root):
    found = {}
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for fn in filenames:
            if os.path.splitext(fn)[1] not in CODE_EXT:
                continue
            path = os.path.join(dirpath, fn)
            try:
                text = open(path, encoding="utf-8", errors="ignore").read()
            except OSError:
                continue
            for m in CALLS.finditer(text):
                line = text.count("\n", 0, m.start()) + 1
                found.setdefault(m.group(1), []).append(f"{path}:{line}")
    return found


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("plan")
    ap.add_argument("--style", choices=["snake", "title"], default="snake")
    ap.add_argument("--scan", help="directory to search for tracking calls")
    args = ap.parse_args()
    try:
        with open(args.plan, encoding="utf-8") as fh:
            plan = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        sys.exit(f"error: cannot read plan: {exc}")

    errors, warns = lint(plan, args.style)
    if args.scan:
        planned = {e.get("name") for e in plan.get("events", []) if isinstance(e, dict)}
        in_code = scan(args.scan)
        for name, locs in sorted(in_code.items()):
            if name not in planned:
                errors.append(f"code calls unplanned event '{name}' at {locs[0]}" + (f" (+{len(locs) - 1} more)" if len(locs) > 1 else ""))
        for name in sorted(n for n in planned if n and n not in in_code):
            warns.append(f"planned event '{name}' not found in {args.scan} (may be server-side or not yet built)")

    for w in warns:
        print(f"warn:  {w}")
    for e in errors:
        print(f"error: {e}")
    total = len(plan.get("events", []) or [])
    print(f"\n{total} events checked: {len(errors)} error(s), {len(warns)} warning(s)")
    sys.exit(1 if errors else 0)


if __name__ == "__main__":
    main()

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc