
Analytics Tracking
- 93 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
analytics-tracking is a Claude skill that implements and audits GA4, Google Tag Manager, event taxonomy, and conversion tracking for web and SaaS products.
About
analytics-tracking is a Claude skill for implementing and auditing web and SaaS analytics. A developer or marketer uses it to design an event taxonomy, set up GA4 and Google Tag Manager, configure conversion tracking, and manage UTM and consent. It has three modes for building tracking from scratch, auditing existing tracking, and debugging missing events or mismatched conversions.
- End-to-end GA4 + Google Tag Manager implementation and event-taxonomy design
- Conversion tracking across Google Ads and Meta, cross-domain tracking, UTM and consent management
- Three operating modes: build from scratch, audit existing, debug specific issues
Analytics Tracking by the numbers
- 93 all-time installs (skills.sh)
- Ranked #1,156 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
analytics-tracking capabilities & compatibility
Free skill; GA4 and GTM themselves are free tools.
- Capabilities
- analytics audit · event taxonomy · conversion tracking
- Works with
- gmail
- Use cases
- data analysis · marketing
- Pricing
- Free
What analytics-tracking says it does
End-to-end analytics implementation for web and SaaS products.
Bad tracking is worse than no tracking -- duplicate events, missing parameters, unconsented data, and broken conversions lead to decisions based on bad data.
npx skills add https://github.com/borghei/claude-skills --skill analytics-trackingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 93 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Design, implement, and audit GA4 and GTM analytics tracking with a clean event taxonomy and reliable conversions.
Who is it for?
Developers and marketers building a tracking plan or auditing GA4/GTM data quality.
Skip if: Analyzing campaign performance or in-app product behavior (defers to campaign-analytics and product-team skills).
When should I use this skill?
When building a tracking plan, debugging missing events, setting up GTM, or auditing existing analytics.
What you get
A clean event taxonomy, correctly configured GA4/GTM, and trustworthy conversion data.
- event taxonomy
- GA4 + GTM configuration
- conversion and consent setup
By the numbers
- 3 operating modes (build, audit, debug)
- GA4 caps at 30 conversion events per property
- data retention set to 14 months (max for free GA4)
Files
Analytics Tracking - Implementation & Auditing
Category: Marketing Tags: GA4, Google Tag Manager, event tracking, conversion tracking, UTM, analytics audit, consent mode
Overview
Analytics Tracking is the implementation layer for marketing measurement. Bad tracking is worse than no tracking -- duplicate events, missing parameters, unconsented data, and broken conversions lead to decisions based on bad data. This skill covers building tracking right the first time and finding what is broken when it is not.
This skill handles implementation only. For analyzing campaign performance data, use campaign-analytics. For product analytics and in-app behavior, use the product-team skills.
---
Operating Modes
Mode 1: Build From Scratch
No analytics in place. Build the tracking plan, implement GA4 + GTM, define event taxonomy, configure conversions.
Mode 2: Audit Existing Tracking
Tracking exists but data cannot be trusted. Audit coverage, identify gaps, clean up duplicates, fix consent issues.
Mode 3: Debug Specific Issues
Events are missing, conversions do not match, GTM preview shows fires but GA4 does not record. Structured debugging workflow.
---
Event Taxonomy Design
Get this right before touching GA4 or GTM. Retrofitting taxonomy is painful and expensive.
Naming Convention
Format: object_action (snake_case, past tense verb)
| Correct | Wrong | Why Wrong |
|---|---|---|
form_submitted | submitForm | camelCase, verb-first |
plan_selected | clickPricingPlan | Implementation detail, not user action |
video_started | VideoStart | PascalCase, inconsistent tense |
checkout_completed | purchase | Ambiguous, not a verb phrase |
Rules: 1. Always noun_verb order, never verb_noun 2. Snake_case only -- no camelCase, no hyphens, no PascalCase 3. Past tense verbs: _started, _completed, _failed, _viewed 4. Specific enough to be unambiguous, not so verbose it is a sentence 5. Prefix with domain when needed: onboarding_step_completed, billing_plan_selected
Standard Event Parameters
Every custom event should include applicable parameters from this table:
| Parameter | Type | Example | Required When |
|---|---|---|---|
user_id | string | usr_abc123 | Always (if authenticated) |
plan_name | string | professional | Billing/pricing events |
value | number | 99.00 | Revenue events |
currency | string | USD | Always with value |
content_group | string | onboarding | Page/flow grouping |
method | string | google_oauth | Signup/login events |
step_name | string | connect_account | Multi-step flows |
step_number | number | 3 | Multi-step flows |
source | string | pricing_page | CTA click events |
SaaS Event Taxonomy (Reference)
Core Funnel:
visitor_arrived (automatic page_view in GA4)
signup_started (user clicked "Sign up")
signup_completed (account created)
trial_started (free trial began)
onboarding_step_completed (params: step_name, step_number)
feature_activated (params: feature_name)
plan_selected (params: plan_name, billing_period)
checkout_started (params: value, currency, plan_name)
checkout_completed (params: value, currency, transaction_id)
subscription_renewed (params: value, plan_name)
subscription_cancelled (params: cancel_reason, plan_name)Micro-Conversions:
pricing_viewed
demo_requested (params: source)
form_submitted (params: form_name, form_location)
content_downloaded (params: content_name, content_type)
video_started (params: video_title)
video_completed (params: video_title, percent_watched)
chat_opened
help_article_viewed (params: article_name)
invite_sent (params: recipient_role)
integration_connected (params: integration_name)---
GA4 Configuration
Data Stream Setup
1. Create property: GA4 Admin > Properties > Create 2. Add web data stream with your domain 3. Enhanced Measurement -- review each:
- Page views: Keep enabled
- Scrolls: Keep enabled
- Outbound clicks: Keep enabled
- Site search: Enable if you have search
- Video engagement: Disable if tracking videos manually (avoids duplicates)
- File downloads: Disable if tracking via GTM (for better parameters)
4. Configure domains: add all subdomains in your funnel 5. Data retention: Set to 14 months (maximum for free GA4)
Conversion Events
Mark as conversions in GA4 Admin > Conversions:
signup_completedcheckout_completeddemo_requestedtrial_started
Rules:
- Maximum 30 conversion events per property -- curate carefully
- GA4 conversions are retroactive for 6 months when enabled
- Do not mark micro-conversions as conversions unless optimizing ad campaigns for them
- Conversion counting: set to "once per session" for lead events, "every" for purchase events
Custom Dimensions
Register custom dimensions for any event parameter you want to filter/segment by:
| Parameter | Scope | Dimension Name |
|---|---|---|
plan_name | Event | Plan Name |
user_id | User | User ID |
content_group | Event | Content Group |
feature_name | Event | Feature Name |
Register in GA4 Admin > Custom definitions > Create custom dimension.
---
Google Tag Manager Implementation
Container Architecture
GTM Container
├── Tags
│ ├── GA4 Configuration (All Pages trigger)
│ ├── GA4 Event Tags (one per custom event)
│ ├── Google Ads Conversion Tags (per conversion action)
│ └── Meta Pixel / LinkedIn Insight (if running ads)
├── Triggers
│ ├── All Pages (Page View)
│ ├── DOM Ready
│ ├── Custom Event triggers (one per dataLayer event)
│ └── Element Click triggers (CSS selector based)
└── Variables
├── Data Layer Variables (one per dataLayer key)
├── Constants (GA4 Measurement ID, etc.)
└── Lookup Tables (if needed for mapping)Implementation Pattern: Data Layer Push
Your application pushes events to the data layer. GTM picks them up and sends to GA4.
Application code:
// Push event when user completes signup
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'signup_completed',
method: 'email',
user_id: userId,
plan_name: 'trial'
});GTM configuration:
Trigger:
Type: Custom Event
Event name: signup_completed
Tag:
Type: GA4 Event
Event name: signup_completed
Parameters:
method: {{DLV - method}}
user_id: {{DLV - user_id}}
plan_name: {{DLV - plan_name}}SPA Handling
Single Page Applications need special attention because page views do not fire automatically on route changes.
Option A: History change trigger (GTM built-in)
- Enable "History Change" trigger in GTM
- Fires GA4 page_view on every pushState/popState
Option B: DataLayer push on route change (more control)
// In your router (React Router, Next.js, etc.)
router.events.on('routeChangeComplete', (url) => {
window.dataLayer.push({
event: 'page_view',
page_location: url,
page_title: document.title
});
});---
Conversion Tracking: Ad Platforms
Google Ads
Recommended approach: Import GA4 conversions into Google Ads (single source of truth).
1. Link GA4 and Google Ads accounts 2. In Google Ads > Goals > Conversions > Import > Google Analytics 3. Select GA4 conversion events to import 4. Set attribution model: Data-driven (if 50+ conversions/month), otherwise Last-click 5. Conversion window: 30 days for lead gen, 90 days for high-consideration B2B
Enhanced Conversions: Enable for 15-30% better conversion measurement. Sends hashed first-party data (email, phone) to match conversions that cookies miss.
Meta (Facebook/Instagram)
1. Install Meta Pixel base code via GTM 2. Configure standard events: PageView, Lead, CompleteRegistration, Purchase 3. Conversions API (CAPI): strongly recommended -- client-side pixel loses approximately 30% of conversions due to ad blockers and iOS App Tracking Transparency 4. Deduplication: when using both pixel and CAPI, send the same event_id to prevent double-counting
LinkedIn Insight Tag
1. Install via GTM (Tag type: LinkedIn Insight) 2. Configure conversion events in LinkedIn Campaign Manager 3. Match events to your taxonomy: signup_completed -> LinkedIn "Sign-up" conversion
---
UTM Strategy
Convention Enforcement
| Parameter | Convention | Example |
|---|---|---|
utm_source | Platform name, lowercase | google, linkedin, newsletter |
utm_medium | Traffic type | cpc, email, social, organic |
utm_campaign | Campaign identifier | q1-trial-push, brand-awareness-2026 |
utm_content | Creative variant | hero-cta-blue, sidebar-text-link |
utm_term | Paid keyword (search only) | saas-analytics-tool |
Critical rules:
- Never tag organic traffic with UTMs (overrides GA4 automatic attribution)
- Never tag direct/internal links with UTMs
- Use a UTM builder spreadsheet or tool -- manual entry causes inconsistency
- Lowercase everything --
Googleandgoogleare different sources in GA4
Attribution Windows
| Platform | Default | Recommended for SaaS |
|---|---|---|
| GA4 | 30 days | 30-90 days (match your sales cycle) |
| Google Ads | 30 days | 30 days (trial), 90 days (enterprise) |
| Meta | 7-day click, 1-day view | 7-day click only (view-through inflates) |
| 30 days | 30 days |
---
Cross-Domain Tracking
For funnels crossing domains (e.g., acme.com to app.acme.com):
1. GA4 Admin > Data Streams > Configure tag settings > Configure your domains > Add both domains 2. GTM: GA4 Configuration tag > Fields to Set > linker > Add domains 3. Admin > Data Streams > List unwanted referrals > Add both domains
Verification: Visit domain A, click link to domain B, check GA4 DebugView. The session should NOT restart. If a new session starts, cross-domain tracking is broken.
---
Consent Management
Consent Mode v2
Required for EU compliance and for maintaining data quality in consent-heavy markets.
| Setting | No Consent Mode | Basic | Advanced |
|---|---|---|---|
| User declines cookies | Zero data | Zero data | Modeled data (GA4 estimates) |
| Data quality impact | 25-40% data loss in EU | 25-40% data loss | 5-15% data loss |
| Implementation effort | None | Medium | Medium-High |
Recommendation: Implement Advanced Consent Mode v2 via GTM with a CMP (Cookiebot, OneTrust, Usercentrics).
Expected consent rates by region:
- EU/EEA: 60-75%
- UK: 70-80%
- US: 85-95%
- Rest of world: 80-90%
Implementation via GTM
1. Install CMP tag (fires first, before any other tags)
2. Set default consent state:
- analytics_storage: denied
- ad_storage: denied
- ad_user_data: denied
- ad_personalization: denied
3. CMP updates consent state on user choice
4. GA4 and ad tags respect consent automatically---
Data Quality Auditing
Audit Checklist
Event Quality:
- [ ] No duplicate events (check GTM Preview for double-fires)
- [ ] All custom events have required parameters
- [ ] Event names follow naming convention
- [ ] No PII in event parameters (names, emails, phone numbers)
- [ ] Enhanced Measurement not duplicating GTM custom events
Configuration Quality:
- [ ] Data retention set to 14 months
- [ ] Internal traffic filter enabled (office and developer IPs)
- [ ] Bot filtering enabled (default in GA4)
- [ ] Cross-domain tracking working (if applicable)
- [ ] Custom dimensions registered for filtered parameters
- [ ] Conversion events marked correctly
Consent Quality:
- [ ] Consent Mode v2 implemented (if serving EU users)
- [ ] CMP banner appearing on first visit
- [ ] Tags respect consent state (no firing before consent)
- [ ] Consent state persisting across pages
Common Data Quality Issues
| Issue | Symptom | Root Cause | Fix |
|---|---|---|---|
| Inflated page views | 2x expected volume | GTM page_view + Enhanced Measurement | Disable Enhanced page_view |
| Missing conversions | GA4 and Ads numbers differ | Attribution window mismatch | Align windows |
| (not set) pages | Pages show as "/(not set)" | SPA routing not handled | Implement SPA tracking |
| Self-referrals | Own domain in referral report | Missing cross-domain config | Add domains to referral exclusion |
| Direct traffic spike | Paid traffic showing as direct | UTMs missing or stripped | Audit UTM usage |
| Zero EU data | No traffic from EU markets | Consent blocks all tracking | Implement Advanced Consent Mode |
Debugging Workflow
Step 1: Open GTM Preview mode
- Is the tag firing? Check triggers and conditions
- Is the data layer populated? Check dataLayer in console
Step 2: Check GA4 DebugView (Admin > DebugView)
- Is the event appearing? If yes, GTM is working
- Are parameters populated? Check parameter values
Step 3: Check GA4 Realtime report
- Events appearing with 5-minute delay? Normal
- Events not appearing at all? Check measurement ID
Step 4: Check Network tab (DevTools)
- Filter by "collect" or "analytics"
- Is the request being sent? Check status code
- Is the request being blocked? Check ad blockers / consent---
Proactive Triggers
Surface these findings without being asked:
- Events firing on every page load with identical parameters: misconfigured trigger causing data inflation
- No
user_idparameter on authenticated events: cannot connect analytics to CRM or understand cohorts - GA4 conversion count differs from Google Ads by more than 15%: attribution window or deduplication issue
- No consent mode in EU markets: legal exposure and 25-40% data underreporting
- All pages showing as
/(not set): SPA routing not handled properly utm_sourceshowing asdirectfor known paid campaigns: UTMs missing or being stripped by redirects
---
Related Skills
| Skill | Use When |
|---|---|
| campaign-analytics | Analyzing marketing performance and channel ROI (not implementation) |
| ab-test-setup | Designing experiments (this skill's events feed A/B tests) |
| launch-strategy | Tracking events for product launches |
| email-sequence | Setting up email click tracking and UTM parameters |
---
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
| GA4 shows 50% less traffic than expected after privacy changes | Client-side tracking blocked by ad blockers and ITP/ETP cookie expiry | Implement server-side GTM tagging — recovers 20-40% of lost attribution data within first quarter |
| Conversion counts differ between GA4 and Google Ads by >15% | Attribution window mismatch or deduplication failure between pixel and CAPI | Align attribution windows across platforms and ensure matching event_id for deduplication |
| Events fire in GTM Preview but do not appear in GA4 reports | Measurement ID mismatch, consent mode blocking, or data processing delay | Check Measurement ID in GA4 Configuration tag, verify consent state, wait 24-48 hours for standard reports |
| UTM parameters show as (not set) in GA4 | UTMs stripped by redirects, social platform link wrappers, or internal links overwriting | Audit redirect chains, use UTM-safe shorteners, never tag internal links with UTMs |
| Server-side container returns 400 errors | Malformed event payload or missing required fields in Measurement Protocol requests | Validate payload against GA4 Measurement Protocol schema, check required client_id and api_secret |
| Enhanced Measurement duplicating custom GTM events | Both Enhanced Measurement and GTM firing the same event type (e.g., page_view, scroll) | Disable the overlapping Enhanced Measurement toggle for events you track via GTM |
| Consent Mode v2 reporting zero EU data instead of modeled data | Default consent state not set before GA4 tag fires, or CMP not updating consent correctly | Ensure consent defaults fire as the very first tag in GTM before all other tags |
---
Success Criteria
- All custom events follow consistent
noun_verbsnake_case naming convention with zero violations in schema audit - GA4 conversion counts match ad platform conversion counts within 10% variance
- Server-side tracking recovers 20%+ of previously lost attribution data within 90 days of deployment
- UTM parameter validation passes 100% on all active campaigns (no mixed case, no spaces, no missing required params)
- Consent Mode v2 limits EU data loss to under 15% via behavioral modeling
- Event parameters contain zero PII violations as verified by automated schema checker
- Data retention set to 14 months, internal traffic filtered, and cross-domain tracking verified
---
Scope & Limitations
In Scope: GA4 configuration, GTM implementation, event taxonomy design, conversion tracking setup, UTM strategy, consent management, data quality auditing, server-side tagging architecture, cross-domain tracking, ad platform conversion integration (Google Ads, Meta, LinkedIn).
Out of Scope: Product analytics platforms (Amplitude, Mixpanel), data warehouse configuration, custom ETL pipelines, mobile app tracking (Firebase), marketing attribution modeling (see marketing-analyst skill), A/B test statistical analysis (see ab-test-setup skill).
Limitations: Server-side tracking requires a cloud-hosted GTM container (GCP, AWS, or third-party) with associated infrastructure costs. Privacy-first analytics with Consent Mode v2 produces modeled data for non-consented users — modeled data has 5-15% variance from actual. This skill does not make LLM or API calls; all validation is deterministic.
---
Scripts
| Script | Purpose | Usage |
|---|---|---|
scripts/utm_validator.py | Validate UTM parameters for consistency and naming conventions | python scripts/utm_validator.py urls.csv --json |
scripts/event_schema_checker.py | Validate event names and parameters against taxonomy, detect PII | python scripts/event_schema_checker.py events.json --json |
scripts/funnel_drop_off_analyzer.py | Analyze conversion funnels and identify biggest drop-off points | python scripts/funnel_drop_off_analyzer.py --stages "Visitors:10000,Signups:1200,Paid:120" |
#!/usr/bin/env python3
"""Event Schema Checker - Validate analytics event names and parameters against a taxonomy.
Checks event names follow naming conventions (snake_case, noun_verb),
validates required parameters per event type, and detects PII leaks.
Usage:
python event_schema_checker.py events.json
python event_schema_checker.py events.json --json
python event_schema_checker.py --generate-schema > schema.json
"""
import argparse
import json
import re
import sys
# Default SaaS event schema
DEFAULT_SCHEMA = {
"naming_rules": {
"case": "snake_case",
"pattern": "noun_verb",
"allowed_verbs": [
"started", "completed", "failed", "viewed", "clicked",
"submitted", "selected", "created", "updated", "deleted",
"cancelled", "renewed", "opened", "closed", "sent",
"received", "activated", "deactivated", "requested",
"downloaded", "uploaded", "connected", "disconnected",
],
},
"events": {
"signup_started": {"required_params": [], "optional_params": ["method", "source"]},
"signup_completed": {"required_params": ["method"], "optional_params": ["user_id", "plan_name"]},
"trial_started": {"required_params": ["plan_name"], "optional_params": ["user_id"]},
"onboarding_step_completed": {"required_params": ["step_name", "step_number"], "optional_params": ["user_id"]},
"feature_activated": {"required_params": ["feature_name"], "optional_params": ["user_id"]},
"plan_selected": {"required_params": ["plan_name", "billing_period"], "optional_params": ["value", "currency"]},
"checkout_started": {"required_params": ["value", "currency", "plan_name"], "optional_params": []},
"checkout_completed": {"required_params": ["value", "currency", "transaction_id"], "optional_params": ["plan_name"]},
"subscription_renewed": {"required_params": ["value", "plan_name"], "optional_params": ["currency"]},
"subscription_cancelled": {"required_params": ["cancel_reason", "plan_name"], "optional_params": []},
"pricing_viewed": {"required_params": [], "optional_params": ["source"]},
"demo_requested": {"required_params": [], "optional_params": ["source"]},
"form_submitted": {"required_params": ["form_name"], "optional_params": ["form_location"]},
"content_downloaded": {"required_params": ["content_name"], "optional_params": ["content_type"]},
"video_started": {"required_params": ["video_title"], "optional_params": []},
"video_completed": {"required_params": ["video_title"], "optional_params": ["percent_watched"]},
},
"global_params": {
"required_when_authenticated": ["user_id"],
"required_with_value": ["currency"],
},
}
# PII detection patterns
PII_PATTERNS = {
"email": re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"),
"phone": re.compile(r"(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}"),
"ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
"credit_card": re.compile(r"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b"),
"ip_address": re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"),
}
PII_PARAM_NAMES = {
"email", "mail", "e_mail", "user_email", "customer_email",
"phone", "phone_number", "mobile", "telephone",
"name", "first_name", "last_name", "full_name", "customer_name",
"address", "street", "city", "zip", "postal_code",
"ssn", "social_security", "tax_id",
"credit_card", "card_number", "cvv", "expiry",
"password", "secret", "token",
}
def check_naming_convention(event_name):
"""Check if event name follows snake_case noun_verb convention."""
issues = []
# Check snake_case
if event_name != event_name.lower():
issues.append({
"type": "naming",
"severity": "error",
"message": f"Event '{event_name}' is not lowercase snake_case",
"suggestion": event_name.lower(),
})
if "-" in event_name:
issues.append({
"type": "naming",
"severity": "error",
"message": f"Event '{event_name}' uses hyphens instead of underscores",
"suggestion": event_name.replace("-", "_"),
})
if " " in event_name:
issues.append({
"type": "naming",
"severity": "error",
"message": f"Event '{event_name}' contains spaces",
"suggestion": event_name.replace(" ", "_").lower(),
})
# Check camelCase
if re.match(r"^[a-z]+[A-Z]", event_name):
issues.append({
"type": "naming",
"severity": "error",
"message": f"Event '{event_name}' appears to be camelCase",
"suggestion": re.sub(r"([A-Z])", r"_\1", event_name).lower(),
})
# Check noun_verb pattern (last segment should be a verb)
parts = event_name.lower().split("_")
if len(parts) < 2:
issues.append({
"type": "naming",
"severity": "warning",
"message": f"Event '{event_name}' should follow noun_verb pattern (e.g., 'form_submitted')",
})
return issues
def check_pii(params):
"""Check event parameters for potential PII."""
issues = []
for key, value in params.items():
# Check param name
if key.lower() in PII_PARAM_NAMES:
issues.append({
"type": "pii",
"severity": "error",
"message": f"Parameter name '{key}' likely contains PII. Remove or hash before sending.",
"param": key,
})
# Check param value for PII patterns
if isinstance(value, str):
for pii_type, pattern in PII_PATTERNS.items():
if pattern.search(value):
issues.append({
"type": "pii",
"severity": "error",
"message": f"Parameter '{key}' value appears to contain {pii_type}: '{value[:20]}...'",
"param": key,
"pii_type": pii_type,
})
return issues
def check_schema_compliance(event_name, params, schema):
"""Check if event parameters match the expected schema."""
issues = []
events = schema.get("events", {})
if event_name not in events:
issues.append({
"type": "schema",
"severity": "info",
"message": f"Event '{event_name}' is not in the defined schema. This may be intentional.",
})
return issues
event_def = events[event_name]
# Check required params
for req_param in event_def.get("required_params", []):
if req_param not in params:
issues.append({
"type": "schema",
"severity": "error",
"message": f"Event '{event_name}' missing required parameter: '{req_param}'",
"param": req_param,
})
# Check for unknown params
known_params = set(event_def.get("required_params", []) + event_def.get("optional_params", []))
known_params.update(schema.get("global_params", {}).get("required_when_authenticated", []))
known_params.update(["currency", "value"]) # Common global params
for key in params:
if key not in known_params:
issues.append({
"type": "schema",
"severity": "info",
"message": f"Event '{event_name}' has undocumented parameter: '{key}'",
"param": key,
})
# Check value/currency pairing
global_params = schema.get("global_params", {})
if "value" in params and "currency" not in params:
if "currency" in global_params.get("required_with_value", []):
issues.append({
"type": "schema",
"severity": "error",
"message": f"Event '{event_name}' has 'value' without 'currency'",
})
return issues
def validate_events(events_data, schema=None):
"""Validate a list of events against naming conventions and schema."""
if schema is None:
schema = DEFAULT_SCHEMA
all_issues = []
event_counts = {}
for i, event in enumerate(events_data):
event_name = event.get("event", event.get("name", ""))
params = event.get("params", event.get("parameters", {}))
if not event_name:
all_issues.append({
"type": "structure",
"severity": "error",
"message": f"Event at index {i} has no event name",
"event_index": i,
})
continue
# Track event counts for duplicate detection
event_counts[event_name] = event_counts.get(event_name, 0) + 1
# Naming convention checks
naming_issues = check_naming_convention(event_name)
for issue in naming_issues:
issue["event"] = event_name
issue["event_index"] = i
all_issues.extend(naming_issues)
# PII checks
pii_issues = check_pii(params)
for issue in pii_issues:
issue["event"] = event_name
issue["event_index"] = i
all_issues.extend(pii_issues)
# Schema compliance checks
schema_issues = check_schema_compliance(event_name, params, schema)
for issue in schema_issues:
issue["event"] = event_name
issue["event_index"] = i
all_issues.extend(schema_issues)
return all_issues, event_counts
def format_report(all_issues, event_counts, total_events):
"""Format human-readable report."""
errors = [i for i in all_issues if i["severity"] == "error"]
warnings = [i for i in all_issues if i["severity"] == "warning"]
infos = [i for i in all_issues if i["severity"] == "info"]
lines = []
lines.append("=" * 60)
lines.append("EVENT SCHEMA VALIDATION REPORT")
lines.append("=" * 60)
lines.append(f"Events checked: {total_events}")
lines.append(f"Unique events: {len(event_counts)}")
lines.append(f"Errors: {len(errors)}")
lines.append(f"Warnings: {len(warnings)}")
lines.append(f"Info: {len(infos)}")
lines.append("")
if errors:
lines.append("--- ERRORS ---")
for issue in errors:
event_label = issue.get("event", "unknown")
lines.append(f" [{event_label}] {issue['message']}")
if "suggestion" in issue:
lines.append(f" Suggestion: {issue['suggestion']}")
lines.append("")
if warnings:
lines.append("--- WARNINGS ---")
for issue in warnings:
event_label = issue.get("event", "unknown")
lines.append(f" [{event_label}] {issue['message']}")
lines.append("")
if infos:
lines.append("--- INFO ---")
for issue in infos:
event_label = issue.get("event", "unknown")
lines.append(f" [{event_label}] {issue['message']}")
lines.append("")
pii_count = len([i for i in all_issues if i.get("type") == "pii"])
if pii_count > 0:
lines.append(f"PII ALERT: {pii_count} potential PII violations detected!")
lines.append("")
score = max(0, 100 - (len(errors) * 10) - (len(warnings) * 3) - (len(infos) * 1))
lines.append(f"Schema Compliance Score: {score}/100")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Validate analytics event names and parameters against a taxonomy"
)
parser.add_argument(
"input",
nargs="?",
help="JSON file containing events array",
)
parser.add_argument(
"--schema",
help="Custom schema JSON file (default: built-in SaaS schema)",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results in JSON format",
)
parser.add_argument(
"--generate-schema",
action="store_true",
help="Print the default schema as JSON for customization",
)
args = parser.parse_args()
if args.generate_schema:
print(json.dumps(DEFAULT_SCHEMA, indent=2))
sys.exit(0)
if not args.input:
parser.print_help()
sys.exit(1)
# Load events
try:
with open(args.input, "r", encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
print(f"Error: File not found: {args.input}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
events = data if isinstance(data, list) else data.get("events", [])
# Load custom schema
schema = DEFAULT_SCHEMA
if args.schema:
try:
with open(args.schema, "r", encoding="utf-8") as f:
schema = json.load(f)
except Exception as e:
print(f"Error loading schema: {e}", file=sys.stderr)
sys.exit(1)
all_issues, event_counts = validate_events(events, schema)
if args.json_output:
result = {
"total_events": len(events),
"unique_events": len(event_counts),
"event_counts": event_counts,
"total_issues": len(all_issues),
"errors": len([i for i in all_issues if i["severity"] == "error"]),
"warnings": len([i for i in all_issues if i["severity"] == "warning"]),
"info": len([i for i in all_issues if i["severity"] == "info"]),
"pii_violations": len([i for i in all_issues if i.get("type") == "pii"]),
"score": max(
0,
100
- len([i for i in all_issues if i["severity"] == "error"]) * 10
- len([i for i in all_issues if i["severity"] == "warning"]) * 3
- len([i for i in all_issues if i["severity"] == "info"]) * 1,
),
"issues": all_issues,
}
print(json.dumps(result, indent=2))
else:
print(format_report(all_issues, event_counts, len(events)))
sys.exit(1 if any(i["severity"] == "error" for i in all_issues) else 0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Funnel Drop-Off Analyzer - Analyze conversion funnels and identify drop-off points.
Reads funnel stage data (stage name + count), calculates stage-to-stage conversion
rates, identifies the biggest drop-off points, and suggests investigation priorities.
Usage:
python funnel_drop_off_analyzer.py funnel_data.json
python funnel_drop_off_analyzer.py funnel_data.json --json
python funnel_drop_off_analyzer.py --stages "Visitors:10000,Signups:1200,Activated:480,Paid:120"
"""
import argparse
import json
import sys
import math
def parse_stages_string(stages_str):
"""Parse stages from a comma-separated string like 'Name:Count,Name:Count'."""
stages = []
for pair in stages_str.split(","):
pair = pair.strip()
if ":" not in pair:
raise ValueError(f"Invalid stage format: '{pair}'. Use 'Name:Count'")
name, count = pair.rsplit(":", 1)
stages.append({"name": name.strip(), "count": int(count.strip())})
return stages
def analyze_funnel(stages):
"""Analyze funnel stages and return detailed metrics."""
if len(stages) < 2:
return {"error": "Need at least 2 stages for funnel analysis"}
results = []
total_entry = stages[0]["count"]
for i, stage in enumerate(stages):
entry = {
"stage": stage["name"],
"count": stage["count"],
"cumulative_conversion": (stage["count"] / total_entry * 100) if total_entry > 0 else 0,
}
if i > 0:
prev_count = stages[i - 1]["count"]
drop_off = prev_count - stage["count"]
conversion_rate = (stage["count"] / prev_count * 100) if prev_count > 0 else 0
drop_off_rate = 100 - conversion_rate
entry["from_stage"] = stages[i - 1]["name"]
entry["drop_off_count"] = drop_off
entry["stage_conversion_rate"] = round(conversion_rate, 2)
entry["drop_off_rate"] = round(drop_off_rate, 2)
entry["drop_off_pct_of_total"] = round(
(drop_off / total_entry * 100) if total_entry > 0 else 0, 2
)
results.append(entry)
# Find biggest drop-off points
drop_offs = [r for r in results if "drop_off_rate" in r]
if drop_offs:
# By absolute count
biggest_absolute = max(drop_offs, key=lambda x: x["drop_off_count"])
# By rate
biggest_rate = max(drop_offs, key=lambda x: x["drop_off_rate"])
else:
biggest_absolute = None
biggest_rate = None
# Calculate overall metrics
overall_conversion = (stages[-1]["count"] / total_entry * 100) if total_entry > 0 else 0
# Benchmark comparison
benchmarks = {
"overall": {"good": 5.0, "average": 2.5, "poor": 1.0},
"stage": {"good": 70.0, "average": 50.0, "poor": 30.0},
}
# Generate recommendations
recommendations = []
for r in drop_offs:
if r["drop_off_rate"] > 70:
recommendations.append({
"priority": "critical",
"stage": f"{r['from_stage']} -> {r['stage']}",
"drop_off_rate": r["drop_off_rate"],
"recommendation": f"Critical drop-off ({r['drop_off_rate']:.1f}%). "
f"Investigate {r['stage'].lower()} experience immediately. "
f"Check for UX friction, unclear value proposition, or technical issues.",
})
elif r["drop_off_rate"] > 50:
recommendations.append({
"priority": "high",
"stage": f"{r['from_stage']} -> {r['stage']}",
"drop_off_rate": r["drop_off_rate"],
"recommendation": f"High drop-off ({r['drop_off_rate']:.1f}%). "
f"Review {r['stage'].lower()} step for friction. "
f"Consider A/B testing simplified flow or adding social proof.",
})
elif r["drop_off_rate"] > 30:
recommendations.append({
"priority": "medium",
"stage": f"{r['from_stage']} -> {r['stage']}",
"drop_off_rate": r["drop_off_rate"],
"recommendation": f"Moderate drop-off ({r['drop_off_rate']:.1f}%). "
f"Optimize {r['stage'].lower()} with better copy, clearer CTAs, or incentives.",
})
# Revenue impact estimation
revenue_impact = []
for r in drop_offs:
if r["drop_off_rate"] > 30:
# If we improve this stage by 10%, how many more reach the end?
improvement_pct = 10
additional_passed = int(r["drop_off_count"] * improvement_pct / 100)
# Estimate downstream conversion
downstream_rate = (stages[-1]["count"] / r["count"]) if r["count"] > 0 else 0
additional_final = int(additional_passed * downstream_rate)
revenue_impact.append({
"stage": f"{r['from_stage']} -> {r['stage']}",
"improvement_scenario": f"10% improvement",
"additional_users_passed": additional_passed,
"estimated_additional_conversions": additional_final,
})
return {
"stages": results,
"summary": {
"total_stages": len(stages),
"entry_count": total_entry,
"exit_count": stages[-1]["count"],
"overall_conversion_rate": round(overall_conversion, 2),
"biggest_drop_off_absolute": {
"stage": f"{biggest_absolute['from_stage']} -> {biggest_absolute['stage']}",
"count": biggest_absolute["drop_off_count"],
"rate": biggest_absolute["drop_off_rate"],
} if biggest_absolute else None,
"biggest_drop_off_rate": {
"stage": f"{biggest_rate['from_stage']} -> {biggest_rate['stage']}",
"count": biggest_rate["drop_off_count"],
"rate": biggest_rate["drop_off_rate"],
} if biggest_rate else None,
},
"recommendations": sorted(recommendations, key=lambda x: x["drop_off_rate"], reverse=True),
"revenue_impact": revenue_impact,
}
def format_report(analysis):
"""Format human-readable funnel report."""
lines = []
lines.append("=" * 65)
lines.append("FUNNEL DROP-OFF ANALYSIS")
lines.append("=" * 65)
summary = analysis["summary"]
lines.append(f"Stages: {summary['total_stages']}")
lines.append(f"Entry count: {summary['entry_count']:,}")
lines.append(f"Final count: {summary['exit_count']:,}")
lines.append(f"Overall conversion: {summary['overall_conversion_rate']:.2f}%")
lines.append("")
# Stage breakdown
lines.append("--- STAGE BREAKDOWN ---")
lines.append(f"{'Stage':<25} {'Count':>10} {'Conv %':>8} {'Drop-off':>10} {'Drop %':>8}")
lines.append("-" * 65)
for stage in analysis["stages"]:
conv = f"{stage.get('stage_conversion_rate', 100):.1f}%" if "stage_conversion_rate" in stage else "entry"
drop = f"{stage.get('drop_off_count', 0):,}" if "drop_off_count" in stage else "-"
drop_pct = f"{stage.get('drop_off_rate', 0):.1f}%" if "drop_off_rate" in stage else "-"
lines.append(f"{stage['stage']:<25} {stage['count']:>10,} {conv:>8} {drop:>10} {drop_pct:>8}")
lines.append("")
# Biggest drop-offs
if summary.get("biggest_drop_off_absolute"):
lines.append("--- BIGGEST DROP-OFFS ---")
ba = summary["biggest_drop_off_absolute"]
lines.append(f" By count: {ba['stage']} ({ba['count']:,} users lost, {ba['rate']:.1f}%)")
br = summary["biggest_drop_off_rate"]
if br["stage"] != ba["stage"]:
lines.append(f" By rate: {br['stage']} ({br['count']:,} users lost, {br['rate']:.1f}%)")
lines.append("")
# Recommendations
if analysis["recommendations"]:
lines.append("--- RECOMMENDATIONS ---")
for rec in analysis["recommendations"]:
priority_marker = {"critical": "!!!", "high": "!!", "medium": "!"}.get(rec["priority"], "")
lines.append(f" [{rec['priority'].upper()}] {priority_marker} {rec['recommendation']}")
lines.append("")
# Revenue impact
if analysis["revenue_impact"]:
lines.append("--- REVENUE IMPACT SCENARIOS ---")
for impact in analysis["revenue_impact"]:
lines.append(
f" {impact['stage']}: {impact['improvement_scenario']} -> "
f"+{impact['additional_users_passed']:,} pass through, "
f"+{impact['estimated_additional_conversions']:,} final conversions"
)
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Analyze conversion funnels and identify drop-off points"
)
parser.add_argument(
"input",
nargs="?",
help="JSON file with funnel stages [{name, count}, ...]",
)
parser.add_argument(
"--stages",
help='Inline stages: "Visitors:10000,Signups:1200,Paid:120"',
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results in JSON format",
)
args = parser.parse_args()
if not args.input and not args.stages:
parser.print_help()
sys.exit(1)
if args.stages:
try:
stages = parse_stages_string(args.stages)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
try:
with open(args.input, "r", encoding="utf-8") as f:
data = json.load(f)
stages = data if isinstance(data, list) else data.get("stages", data.get("funnel", []))
except FileNotFoundError:
print(f"Error: File not found: {args.input}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON: {e}", file=sys.stderr)
sys.exit(1)
analysis = analyze_funnel(stages)
if "error" in analysis:
print(f"Error: {analysis['error']}", file=sys.stderr)
sys.exit(1)
if args.json_output:
print(json.dumps(analysis, indent=2))
else:
print(format_report(analysis))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""UTM Parameter Validator - Validate UTM parameters for consistency and best practices.
Checks URLs or UTM parameter sets against naming conventions, detects common
mistakes (mixed case, spaces, missing required params), and reports violations.
Usage:
python utm_validator.py urls.csv
python utm_validator.py urls.csv --json
python utm_validator.py --url "https://example.com?utm_source=Google&utm_medium=CPC"
"""
import argparse
import csv
import json
import re
import sys
from urllib.parse import urlparse, parse_qs
VALID_MEDIUMS = {
"cpc", "ppc", "email", "social", "organic", "referral", "display",
"affiliate", "video", "podcast", "sms", "push", "qr", "print",
"partner", "retargeting", "native", "cpm", "cpa", "cpl",
}
VALID_SOURCES = {
"google", "facebook", "meta", "linkedin", "twitter", "x", "instagram",
"youtube", "tiktok", "bing", "reddit", "pinterest", "newsletter",
"email", "partner", "direct", "referral", "quora", "snapchat",
}
REQUIRED_PARAMS = {"utm_source", "utm_medium", "utm_campaign"}
ALL_PARAMS = {"utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"}
def extract_utms_from_url(url):
"""Extract UTM parameters from a URL string."""
try:
parsed = urlparse(url if "://" in url else f"https://{url}")
params = parse_qs(parsed.query)
utms = {}
for key in ALL_PARAMS:
if key in params:
utms[key] = params[key][0]
return utms
except Exception:
return {}
def validate_utms(utms, row_id=None):
"""Validate a set of UTM parameters and return issues found."""
issues = []
label = f"Row {row_id}" if row_id else "URL"
# Check required params
for param in REQUIRED_PARAMS:
if param not in utms or not utms[param].strip():
issues.append({
"severity": "error",
"param": param,
"issue": f"Missing required parameter: {param}",
"label": label,
})
for param, value in utms.items():
# Check for uppercase characters
if value != value.lower():
issues.append({
"severity": "error",
"param": param,
"value": value,
"issue": f"Contains uppercase characters (should be lowercase): '{value}'",
"suggestion": value.lower(),
"label": label,
})
# Check for spaces
if " " in value:
issues.append({
"severity": "error",
"param": param,
"value": value,
"issue": f"Contains spaces: '{value}'",
"suggestion": value.replace(" ", "-"),
"label": label,
})
# Check for special characters (allow hyphens, underscores, dots)
if re.search(r"[^a-zA-Z0-9\-_.]", value.replace(" ", "")):
issues.append({
"severity": "warning",
"param": param,
"value": value,
"issue": f"Contains special characters: '{value}'",
"label": label,
})
# Check for common encoding issues
if "%" in value and not re.match(r".*%[0-9a-fA-F]{2}.*", value):
issues.append({
"severity": "warning",
"param": param,
"value": value,
"issue": f"Contains percent sign that may not be URL-encoded: '{value}'",
"label": label,
})
# Validate utm_medium against known values
if "utm_medium" in utms:
medium = utms["utm_medium"].lower().strip()
if medium and medium not in VALID_MEDIUMS:
issues.append({
"severity": "warning",
"param": "utm_medium",
"value": utms["utm_medium"],
"issue": f"Non-standard medium: '{medium}'. Consider using one of: {', '.join(sorted(VALID_MEDIUMS))}",
"label": label,
})
# Validate utm_source against known values
if "utm_source" in utms:
source = utms["utm_source"].lower().strip()
if source and source not in VALID_SOURCES:
issues.append({
"severity": "info",
"param": "utm_source",
"value": utms["utm_source"],
"issue": f"Custom source: '{source}'. Ensure this is intentional and documented.",
"label": label,
})
# Check utm_campaign naming pattern
if "utm_campaign" in utms:
campaign = utms["utm_campaign"]
if campaign and not re.match(r"^[a-z0-9][a-z0-9\-_.]*$", campaign.lower()):
issues.append({
"severity": "warning",
"param": "utm_campaign",
"value": campaign,
"issue": f"Campaign name may not follow naming convention: '{campaign}'",
"label": label,
})
# Check for utm_term on non-search mediums
if "utm_term" in utms and "utm_medium" in utms:
medium = utms["utm_medium"].lower()
if medium not in ("cpc", "ppc", "paid-search", "search"):
issues.append({
"severity": "info",
"param": "utm_term",
"value": utms["utm_term"],
"issue": f"utm_term is typically used for paid search only, but medium is '{medium}'",
"label": label,
})
return issues
def parse_csv(filepath):
"""Parse a CSV file containing URLs or UTM parameters."""
entries = []
with open(filepath, "r", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
headers = [h.lower().strip() for h in (reader.fieldnames or [])]
for i, row in enumerate(reader, start=2):
row_lower = {k.lower().strip(): v for k, v in row.items()}
if "url" in row_lower:
utms = extract_utms_from_url(row_lower["url"])
entries.append({"row": i, "source": row_lower["url"], "utms": utms})
else:
utms = {}
for param in ALL_PARAMS:
if param in row_lower and row_lower[param]:
utms[param] = row_lower[param]
# Also check without utm_ prefix
short = param.replace("utm_", "")
if short in row_lower and row_lower[short]:
utms[param] = row_lower[short]
entries.append({"row": i, "source": "csv_row", "utms": utms})
return entries
def format_report(all_issues, total_checked):
"""Format a human-readable report."""
errors = [i for i in all_issues if i["severity"] == "error"]
warnings = [i for i in all_issues if i["severity"] == "warning"]
infos = [i for i in all_issues if i["severity"] == "info"]
lines = []
lines.append("=" * 60)
lines.append("UTM VALIDATION REPORT")
lines.append("=" * 60)
lines.append(f"URLs/rows checked: {total_checked}")
lines.append(f"Errors: {len(errors)}")
lines.append(f"Warnings: {len(warnings)}")
lines.append(f"Info: {len(infos)}")
lines.append("")
if errors:
lines.append("--- ERRORS (must fix) ---")
for issue in errors:
lines.append(f" [{issue['label']}] {issue['issue']}")
if "suggestion" in issue:
lines.append(f" Suggestion: {issue['suggestion']}")
lines.append("")
if warnings:
lines.append("--- WARNINGS (should fix) ---")
for issue in warnings:
lines.append(f" [{issue['label']}] {issue['issue']}")
if "suggestion" in issue:
lines.append(f" Suggestion: {issue['suggestion']}")
lines.append("")
if infos:
lines.append("--- INFO (review) ---")
for issue in infos:
lines.append(f" [{issue['label']}] {issue['issue']}")
lines.append("")
if not all_issues:
lines.append("All UTM parameters pass validation checks.")
# Summary
score = max(0, 100 - (len(errors) * 15) - (len(warnings) * 5) - (len(infos) * 1))
lines.append(f"UTM Quality Score: {score}/100")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Validate UTM parameters for consistency and best practices"
)
parser.add_argument(
"input",
nargs="?",
help="CSV file with URLs or UTM parameters",
)
parser.add_argument(
"--url",
help="Single URL to validate",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results in JSON format",
)
args = parser.parse_args()
if not args.input and not args.url:
parser.print_help()
sys.exit(1)
all_issues = []
total_checked = 0
if args.url:
utms = extract_utms_from_url(args.url)
issues = validate_utms(utms, row_id="single")
all_issues.extend(issues)
total_checked = 1
elif args.input:
try:
entries = parse_csv(args.input)
except FileNotFoundError:
print(f"Error: File not found: {args.input}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error reading file: {e}", file=sys.stderr)
sys.exit(1)
for entry in entries:
issues = validate_utms(entry["utms"], row_id=entry["row"])
all_issues.extend(issues)
total_checked += 1
if args.json_output:
result = {
"total_checked": total_checked,
"total_issues": len(all_issues),
"errors": len([i for i in all_issues if i["severity"] == "error"]),
"warnings": len([i for i in all_issues if i["severity"] == "warning"]),
"info": len([i for i in all_issues if i["severity"] == "info"]),
"score": max(
0,
100
- len([i for i in all_issues if i["severity"] == "error"]) * 15
- len([i for i in all_issues if i["severity"] == "warning"]) * 5
- len([i for i in all_issues if i["severity"] == "info"]) * 1,
),
"issues": all_issues,
}
print(json.dumps(result, indent=2))
else:
print(format_report(all_issues, total_checked))
sys.exit(1 if any(i["severity"] == "error" for i in all_issues) else 0)
if __name__ == "__main__":
main()
Related skills
FAQ
What are the three operating modes?
Build from scratch, audit existing tracking, and debug specific issues like missing events or mismatched conversions.
What event naming convention does it use?
object_action in snake_case with past-tense verbs, e.g. form_submitted, checkout_completed.