Tracking & Analytics

Overview

The tracking system has two distinct layers that serve different purposes:

Layer Purpose Respects Consent? Can Be Disabled?
Internal (Visit table) Order validation, troubleshooting, analytics No Only for employees/bots
External (GA4, FB, Bing) Marketing, advertising, retargeting Yes Yes (cookie consent)

Why Two Layers?

  1. Internal tracking (Visit table) is essential for:

    • Validating purchases and attributing orders to marketing sources
    • Troubleshooting customer issues ("I visited last week and...")
    • Understanding customer journey for support
    • This is first-party data that stays in our database
  2. External tracking (GA4, Facebook, Bing) is for:

    • Marketing analytics and ad optimization
    • Retargeting campaigns
    • Third-party data sharing (subject to privacy laws)

Architecture

SIMPLIFIED SINGLE FLOW - JavaScript is the tracking engine, no edge-cache vs non-edge-cache branching.

┌──────────────────────────────────────────────────────────────────────────┐
│                          Request Lifecycle                                │
├──────────────────────────────────────────────────────────────────────────┤
│                                                                           │
│   Browser Request                                                         │
│        │                                                                  │
│        ▼                                                                  │
│   ┌─────────────────────────────────────────┐                            │
│   │         Cloudflare Edge                  │                            │
│   │  Adds visitor location headers:          │                            │
│   │  • cf-ipcountry (US, CA, GB, etc.)       │                            │
│   │  • cf-region-code (QC, ON, TX, etc.)     │                            │
│   │  • cf-ipcity, cf-postal-code             │                            │
│   │  • cf-iplatitude, cf-iplongitude         │                            │
│   └─────────────────────────────────────────┘                            │
│        │                                                                  │
│        ▼                                                                  │
│   ┌─────────────────────────────────────────┐                            │
│   │         Rails Application                │                            │
│   │                                          │                            │
│   │  1. _tracking_init.html.erb (MINIMAL)    │                            │
│   │     • Sets consent defaults to 'denied'  │                            │
│   │     • Sets placeholder globals (null)    │                            │
│   │     • NO geo detection here!             │                            │
│   │                                          │                            │
│   │  2. globals.json (SINGLE SOURCE OF TRUTH)│                            │
│   │     Returns tracking config:             │                            │
│   │     • visitor_country, visitor_region    │                            │
│   │     • consent_mode (opt_in/opt_out/implied)                           │
│   │     • existing_consent (from cc_cookie)  │                            │
│   │     • has_gpc (Global Privacy Control)   │                            │
│   │     Dispatches 'trackingConfigReady'     │                            │
│   │                                          │                            │
│   │  3. ConsentManager.init() (after globals)│                            │
│   │     • opt_out/implied: auto-accept       │                            │
│   │     • opt_in: show consent banner        │                            │
│   │     • Updates Google Consent Mode        │                            │
│   │     • Loads GA4, FB, Bing, Clarity       │                            │
│   │                                          │                            │
│   │  4. Tracking::Tracker (parallel)         │                            │
│   │     • Creates Visit + VisitEvent records │                            │
│   │     • Always runs (except bots/employees)│                            │
│   │                                          │                            │
│   └─────────────────────────────────────────┘                            │
│        │                                                                  │
│        ├────────────────┬───────────────┬───────────────┐                │
│        ▼                ▼               ▼               ▼                │
│   ┌──────────┐   ┌───────────┐   ┌───────────┐   ┌───────────┐          │
│   │ Visit    │   │ GA4       │   │ FB/Bing   │   │ Clarity   │          │
│   │ Table    │   │(if consent│   │(if consent│   │(if consent│          │
│   │ ALWAYS   │   │ analytics)│   │ marketing)│   │ analytics)│          │
│   └──────────┘   └───────────┘   └───────────┘   └───────────┘          │
│        │                                                                  │
│        ▼                                                                  │
│   ┌──────────────────────────────────────────┐                           │
│   │         VisitEvents Table                 │                           │
│   │  • $view   - Page views                   │                           │
│   │  • $consent - Cookie consent changes      │                           │
│   │  • $click  - Button/link clicks           │                           │
│   └──────────────────────────────────────────┘                           │
│                                                                           │
└──────────────────────────────────────────────────────────────────────────┘

Cloudflare Visitor Location Headers

We use Cloudflare's "Add visitor location headers" Managed Transform:
https://developers.cloudflare.com/rules/transform/managed-transforms/reference/#add-visitor-location-headers

Headers Available

Header Rails Env Key Example Used For
cf-ipcountry HTTP_CF_IPCOUNTRY US, CA, GB Country code
cf-region HTTP_CF_REGION California, Quebec Full region name
cf-region-code HTTP_CF_REGION_CODE CA, QC, TX Region code (Quebec detection!)
cf-ipcity HTTP_CF_IPCITY San Francisco City name
cf-postal-code HTTP_CF_POSTAL_CODE 94102, H2X 1Y4 Postal/ZIP code
cf-iplatitude HTTP_CF_IPLATITUDE 37.7749 Latitude
cf-iplongitude HTTP_CF_IPLONGITUDE -122.4194 Longitude
cf-timezone HTTP_CF_TIMEZONE America/Los_Angeles Timezone

Benefits Over Previous Approach

Before After
IP recorded, geocoding queued to worker Geo data available immediately
Worker calls external Geocoder API No external API call needed
Quebec detection: guessing from Accept-Language Quebec detection: exact cf-region-code == 'QC'
Async, eventual consistency Sync, immediate consistency

Why This Works for Edge-Cached Pages

With the simplified flow, edge caching is no longer a concern:

  • _tracking_init.html.erb only sets safe defaults (consent = 'denied')
  • ALL geo detection happens in globals.json (which is NEVER cached)
  • No user-specific data is baked into cached HTML

Internal Tracking (Visit Table)

What Gets Tracked

The Tracking::Tracker service creates/updates Visit records for every request except:

  • WarmlyYours employee IPs
  • Bot/crawler requests
  • Non-www subdomains
# app/services/tracking/tracker.rb
def track_visitor?(request: nil)
  # Skip employees
  return false if warmlyyours_ip?(request)
  
  # Skip bots
  return false if bot_request?(request)
  
  # Skip non-www subdomains
  return false unless request.subdomain&.match?(/^www/)
  
  # Track everyone else (regardless of consent cookie)
  true
end

Visit Record Fields

Field Source Purpose
ip Request Visitor IP
country, region, city Cloudflare headers Location
latitude, longitude Cloudflare headers Coordinates
postal_code Cloudflare headers ZIP/Postal
landing_page URL Entry point
referrer, referring_domain Headers Traffic source
utm_* Query params Campaign tracking
gclid, gbraid, wbraid Query params Google Ads clicks
browser, os, device_type User-Agent Device info

Geolocation Worker Fallback

If Cloudflare headers are missing or incomplete, the VisitGeocoderWorker is queued:

# app/services/tracking/tracker.rb
def geocode_visit(visit)
  # Skip if we have complete geo data from Cloudflare
  has_coordinates = visit.latitude.present? && visit.longitude.present?
  has_location_details = visit.country.present? && visit.region.present? && visit.city.present?
  
  return if has_coordinates || has_location_details
  
  # Fallback to async geocoding
  VisitGeocoderWorker.perform_async(visit.id)
end

External Tracking (Analytics)

Consent Flow (Simplified)

┌─────────────────────────────────────────────────────────────────┐
│                    Consent Decision Flow                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                   │
│   1. Page loads → _tracking_init.html.erb                        │
│      └─ Sets consent defaults to 'denied' (safe for all)         │
│      └─ Sets window.visitorCountry = null (placeholder)          │
│                                                                   │
│   2. globals.json returns (SINGLE SOURCE OF TRUTH)               │
│      └─ visitor_country, visitor_region (from Cloudflare)        │
│      └─ consent_mode: 'opt_in' | 'opt_out' | 'implied'           │
│      └─ existing_consent (parsed from cc_cookie)                 │
│      └─ has_gpc (Global Privacy Control)                         │
│                                                                   │
│   3. ConsentManager.init() with server config                    │
│      ├─ opt_out (US): auto-accept all, NO banner                 │
│      ├─ implied (CA non-QC): auto-accept all, NO banner          │
│      ├─ opt_in (Quebec, GDPR): show banner, wait for consent     │
│      └─ GPC: always blocks marketing regardless of consent       │
│                                                                   │
│   4. Update Google Consent Mode v2                               │
│      └─ gtag('consent', 'update', { ... })                       │
│                                                                   │
│   5. Load tracking scripts (if consent given)                    │
│      └─ GA4, Facebook Pixel, Bing UET, Microsoft Clarity         │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

Banner Display Logic

Region Mode Banner Shown? Default Tracking
United States Opt-out ❌ No - auto-accepted ✅ Enabled
Canada (Quebec) Opt-in ✅ Yes - must consent ❌ Disabled until consent
Canada (Other) Implied ❌ No - auto-accepted ✅ Enabled
EU/EEA/UK/CH Opt-in ✅ Yes - must consent ❌ Disabled until consent
Rest of World Opt-in ✅ Yes - must consent ❌ Disabled until consent

How it works:

For opt-out (US) and implied (non-Quebec Canada) regions:

  • ConsentManager.init() detects no prior consent + opt-out/implied mode
  • Calls CookieConsent.acceptCategory('all') automatically
  • This creates the consent cookie WITHOUT showing the banner
  • Users can still manage preferences via "Cookie Preferences" link in footer

For opt-in (Quebec, GDPR) regions:

  • Banner must be shown before any tracking
  • Users must explicitly accept/reject
  • window.tracVis = false until consent given

Cross-Region Consent (Re-consent Handling)

When a user travels between regions with different consent requirements, the system handles this gracefully:

Scenario: User first visits from Illinois (US, opt-out) → later visits from Germany (GDPR, opt-in)

┌─────────────────────────────────────────────────────────────────┐
│                  Cross-Region Consent Flow                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                   │
│   First Visit (US-IL):                                           │
│   └─ consent_mode: 'opt_out'                                     │
│   └─ consent_analytics: true (auto-granted)                      │
│   └─ consent_marketing: true (auto-granted)                      │
│   └─ No banner shown                                             │
│                                                                   │
│   Later Visit (Germany):                                         │
│   └─ Current location mode: 'opt_in' (GDPR)                      │
│   └─ Stored consent mode: 'opt_out' (US)                         │
│   └─ STRICTER! → requires_reconsent: true                        │
│   └─ effective_analytics: false (until re-consent)               │
│   └─ effective_marketing: false (until re-consent)               │
│   └─ BANNER SHOWN - must explicitly consent                      │
│                                                                   │
│   After Re-consent (in Germany):                                 │
│   └─ consent_mode: 'opt_in' (updated)                            │
│   └─ consent_country: 'DE'                                       │
│   └─ Tracking enabled per their choices                          │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

Logic in Tracking::ConsentPreferences:

  • requires_reconsent? checks if user moved to stricter region
  • MODE_STRICTNESS: opt_out (1) < implied (2) < opt_in (3)
  • If current location is stricter AND stored consent was from permissive region → re-consent required

Why this matters:

  • Implied consent from US doesn't satisfy GDPR's explicit consent requirement
  • User must actively opt-in when visiting from GDPR/Quebec regions
  • Once they consent in a strict region, that consent is valid everywhere

Analytics Service

// app/javascript/services/analytics.js
class Analytics {
  static get isEnabled() {
    return window.tracVis === true
  }
  
  static get isMarketingEnabled() {
    // GPC blocks marketing even if analytics is allowed
    if (navigator.globalPrivacyControl === true) return false
    return this.isEnabled
  }
  
  static track(eventName, properties = {}) {
    if (!this.isEnabled) return // Respects consent
    
    // Routes to GA4, Facebook, Bing, Google Ads
    this._fireEvent(eventName, properties)
  }
}

Testing with cURL

Base URL

BASE_URL="https://www.warmlyyours.me:3000"

Test 1: US Visitor (Opt-out Default)

# US visitor from California - should track by default
curl -k -v "$BASE_URL/en-US" \
  -H "CF-IPCountry: US" \
  -H "CF-Region: California" \
  -H "CF-Region-Code: CA" \
  -H "CF-IPCity: San Francisco" \
  -H "CF-Postal-Code: 94102" \
  -H "CF-IPLatitude: 37.7749" \
  -H "CF-IPLongitude: -122.4194" \
  -H "CF-Timezone: America/Los_Angeles" \
  -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"

# Expected: 
# - Visit record created with geo data
# - window.tracVis = true
# - window.visitorCountry = 'US'
# - window.visitorRegion = 'CA'

Test 2: Quebec Visitor (Law 25 - Opt-in)

# Quebec visitor - should NOT track until consent given
curl -k -v "$BASE_URL/en-CA" \
  -H "CF-IPCountry: CA" \
  -H "CF-Region: Quebec" \
  -H "CF-Region-Code: QC" \
  -H "CF-IPCity: Montreal" \
  -H "CF-Postal-Code: H2X 1Y4" \
  -H "CF-IPLatitude: 45.5017" \
  -H "CF-IPLongitude: -73.5673" \
  -H "CF-Timezone: America/Toronto" \
  -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"

# Expected:
# - Visit record created (internal tracking always happens)
# - window.tracVis = false (external tracking blocked until consent)
# - window.visitorCountry = 'CA'
# - window.visitorRegion = 'QC'
# - Cookie consent banner should show

Test 3: Ontario Visitor (Implied Consent)

# Ontario visitor - should track by default (implied consent)
curl -k -v "$BASE_URL/en-CA" \
  -H "CF-IPCountry: CA" \
  -H "CF-Region: Ontario" \
  -H "CF-Region-Code: ON" \
  -H "CF-IPCity: Toronto" \
  -H "CF-Postal-Code: M5V 1J2" \
  -H "CF-IPLatitude: 43.6532" \
  -H "CF-IPLongitude: -79.3832" \
  -H "CF-Timezone: America/Toronto" \
  -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"

# Expected:
# - Visit record created with geo data
# - window.tracVis = true
# - window.visitorCountry = 'CA'
# - window.visitorRegion = 'ON'

Test 4: EU/GDPR Visitor (Strict Opt-in)

# German visitor - should NOT track until consent given
curl -k -v "$BASE_URL/en-US" \
  -H "CF-IPCountry: DE" \
  -H "CF-Region: Bavaria" \
  -H "CF-Region-Code: BY" \
  -H "CF-IPCity: Munich" \
  -H "CF-Postal-Code: 80331" \
  -H "CF-IPLatitude: 48.1351" \
  -H "CF-IPLongitude: 11.5820" \
  -H "CF-Timezone: Europe/Berlin" \
  -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"

# Expected:
# - Visit record created (internal tracking)
# - window.tracVis = false (external tracking blocked)
# - window.isGdprCountry = true
# - Cookie consent banner should show

Test 5: No Cloudflare Headers (Fallback)

# Request without Cloudflare headers - should trigger geocoder worker
curl -k -v "$BASE_URL/en-US" \
  -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"

# Expected:
# - Visit record created with IP only
# - VisitGeocoderWorker enqueued (fallback)
# - window.visitorCountry = 'US' (default fallback)

Test 6: Bot Request (Should Not Track)

# Bot user agent - should NOT create visit record
curl -k -v "$BASE_URL/en-US" \
  -H "CF-IPCountry: US" \
  -H "User-Agent: Googlebot/2.1 (+http://www.google.com/bot.html)"

# Expected:
# - No visit record created
# - Page still renders normally

Test 7: WarmlyYours Employee IP (Should Not Track)

# From WarmlyYours office IP - should NOT create visit record
# (Replace with actual WarmlyYours IP range)
curl -k -v "$BASE_URL/en-US" \
  -H "X-Forwarded-For: 216.251.32.1" \
  -H "CF-IPCountry: US" \
  -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"

# Expected:
# - No visit record created
# - track_visitor? returns false

Test 8: Global Privacy Control (GPC)

# GPC header set - marketing pixels should be blocked
curl -k -v "$BASE_URL/en-US" \
  -H "CF-IPCountry: US" \
  -H "Sec-GPC: 1" \
  -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"

# Expected:
# - Visit record created (internal tracking)
# - window.tracVis = true (analytics OK)
# - Analytics.isMarketingEnabled = false (ads blocked)

Development Testing

Enable Tracking in Development

By default, tracking is completely disabled in development to avoid polluting the Visit table and to prevent external service errors. Enable it explicitly when needed:

# Basic: Enable tracking system
TRACK_VISITOR=y bin/dev

All Environment Flags

Flag Values Default Description
TRACK_VISITOR y, n n Master switch - enables the entire tracking system
TRACK_VISITOR_PROFILE See table below US-IL Simulates Cloudflare geo headers for a region
CLARITY_ENABLED true, false false (dev) Enable Microsoft Clarity session recording
CF_IPCOUNTRY ISO country code Override: Country code (US, CA, DE, etc.)
CF_REGION_CODE Region code Override: Region code (QC, ON, TX, etc.)
CF_REGION Region name Override: Full region name
CF_IPCITY City name Override: City name
CF_POSTAL_CODE Postal/ZIP Override: Postal code
CF_IPLATITUDE Decimal Override: Latitude
CF_IPLONGITUDE Decimal Override: Longitude
CF_TIMEZONE IANA timezone Override: Timezone

Available TRACK_VISITOR_PROFILE Values

Region Coding Format:

  • US States: US-XX format (e.g., US-IL, US-CA)
  • Canadian Provinces: CA-XX format (e.g., CA-QC, CA-ON)
  • Other Countries: ISO 3166-1 alpha-2 country codes (e.g., DE, FR, GB)

Note: Underscores are automatically converted to hyphens (e.g., US_ILUS-IL).

United States (Opt-out consent model)

Profile State City Usage
US-IL Illinois Lake Zurich Default - WarmlyYours HQ location
US-CA California San Francisco
US-TX Texas Austin
US-NY New York New York
US-FL Florida Miami

Canada

Profile Province City Consent Model
CA-QC Quebec Montreal Opt-in (Law 25)
CA-ON Ontario Toronto Implied consent
CA-AB Alberta Calgary Implied consent
CA-BC British Columbia Vancouver Implied consent

Europe (GDPR - Opt-in required)

Profile Country City
DE Germany Munich
FR France Paris
GB United Kingdom London
ES Spain Madrid
IT Italy Rome
NL Netherlands Amsterdam
CH Switzerland Zurich

Other Regions

Profile Country City Privacy Law
BR Brazil São Paulo LGPD
AU Australia Sydney Privacy Act
JP Japan Tokyo APPI
MX Mexico Mexico City LFPDPPP

Special/Test Profiles

Profile Description
UNKNOWN Country code XX - tests fallback behavior
NONE No Cloudflare headers - tests VisitGeocoderWorker fallback

Common Development Scenarios

# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 1: Basic tracking (defaults to Illinois - WarmlyYours HQ)
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y bin/dev
# Same as: TRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-IL bin/dev

# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 2: Test different US states
# All US states use opt-out consent model (tracks by default)
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-CA bin/dev  # California
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-FL bin/dev  # Florida
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-TX bin/dev  # Texas
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-NY bin/dev  # New York

# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 3: Test Quebec Law 25 (strict opt-in required)
# Cookie consent banner MUST appear before any tracking
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=CA-QC bin/dev

# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 4: Test GDPR (EU visitors)
# Cookie consent banner MUST appear before any tracking
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=DE bin/dev  # Germany
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=FR bin/dev  # France
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=GB bin/dev  # United Kingdom

# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 5: Test Canadian implied consent (non-Quebec)
# Tracks by default, but consent banner available via footer
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=CA-ON bin/dev

# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 6: Test with Microsoft Clarity enabled (normally disabled in dev)
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y CLARITY_ENABLED=true bin/dev

# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 7: Test geocoder worker fallback (no Cloudflare headers)
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=NONE bin/dev

# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 8: Custom location with CF_ overrides (takes precedence over profile)
# ─────────────────────────────────────────────────────────────────────────────
# Just country and region:
CF_IPCOUNTRY=CA CF_REGION_CODE=QC TRACK_VISITOR=y bin/dev

# Full custom location:
TRACK_VISITOR=y \
  CF_IPCOUNTRY=US \
  CF_REGION=Florida \
  CF_REGION_CODE=FL \
  CF_IPCITY=Orlando \
  CF_POSTAL_CODE=32801 \
  CF_IPLATITUDE=28.5383 \
  CF_IPLONGITUDE=-81.3792 \
  CF_TIMEZONE=America/New_York \
  bin/dev

Cloudflare Geo Simulator Middleware

The CloudflareGeoSimulator middleware (only active in development with TRACK_VISITOR=y) injects simulated Cloudflare visitor location headers. It only affects www subdomain requests - CRM requests are not affected.

Available Profiles

See the TRACK_VISITOR_PROFILE table above for all available profiles.

Quick reference:

  • US states: US_IL (default), US_CA, US_FL, US_TX, US_NY
  • Canada: QUEBEC (opt-in), ONTARIO, ALBERTA, BC (implied)
  • Europe: GERMANY, FRANCE, UK, SPAIN, ITALY, NETHERLANDS, SWITZERLAND
  • Other: BRAZIL, AUSTRALIA, JAPAN, MEXICO
  • Test: UNKNOWN, NONE

Custom Header Overrides

You can also set individual headers for fine-grained testing:

# Just set country and region code
CF_IPCOUNTRY=CA CF_REGION_CODE=QC TRACK_VISITOR=y bin/dev

# Full custom location
CF_IPCOUNTRY=US \
CF_REGION=Florida \
CF_REGION_CODE=FL \
CF_IPCITY=Miami \
CF_POSTAL_CODE=33101 \
CF_IPLATITUDE=25.7617 \
CF_IPLONGITUDE=-80.1918 \
CF_TIMEZONE=America/New_York \
TRACK_VISITOR=y bin/dev

Testing Workflow

  1. Start server with desired profile:

    TRACK_VISITOR=y TRACK_VISITOR_PROFILE=CA-QC bin/dev
    
  2. Open incognito browser (to start fresh without cookies)

  3. Navigate to: https://www.warmlyyours.me:3000/en-US

  4. Check console for injected headers:

    [CloudflareGeoSimulator] Injecting profile QUEBEC: CA/QC/Montreal
    
  5. Verify in browser console:

    window.trackingState
    // Should show: { country: 'CA', region: 'QC', isOptInRegion: true, tracVis: false }
    
  6. For Quebec/GDPR: Cookie consent banner should appear

  7. Accept cookies, then verify:

    window.tracVis  // Should now be true
    

View Tracking Debug Info

// In browser console:
window.trackingState
// Returns: { country, region, hasGPC, hasConsent, isOptOutRegion, isOptInRegion, tracVis }

window.Analytics._debug = true
// Enables verbose logging for all Analytics calls

ConsentManager.debug()
// Shows current consent state

Check Visit Records

# In rails console:
Visit.order(created_at: :desc).limit(5).pluck(:id, :country, :region, :city, :ip)

Consent Tracking via Visit Events

When users interact with the cookie consent banner, their choices are recorded as $consent visit events (following the $view, $click pattern).

Event Structure

// POST /globals/consent creates this event:
{
  name: '$consent',
  time: '2025-11-25T11:15:00Z',
  properties: {
    mode: 'opt_out',        // opt_in, opt_out, implied
    country: 'US',          // ISO country code
    region: 'CA',           // Region code (QC, ON, etc.)
    analytics: true,        // Accepted analytics cookies?
    marketing: true,        // Accepted marketing cookies?
    gpc: false,             // Global Privacy Control enabled?
    url: 'https://...'      // Page where consent was given
  }
}

Benefits of Event-Based Tracking

Approach Pros Cons
Visit fields Quick lookup Only stores latest state
Visit events Full history, audit trail Need to query latest

Using events allows us to:

  • Track every consent change over time
  • See if user initially declined then accepted later
  • Audit consent for compliance purposes
  • Show "Consent Changes: X updates" badge in CRM

Viewing in CRM

On the Visit details page (/visits/:id):

  1. Privacy & Consent panel: Shows latest consent state
  2. Events table: Shows all $consent events with badges:
    • [Analytics: Yes] / [Analytics: No]
    • [Marketing: Yes] / [Marketing: No]
    • [GPC] if Global Privacy Control was detected

Microsoft Clarity (Session Recording)

Microsoft Clarity provides session recordings and heatmaps for UX analysis.

Configuration

# config/initializers/microsoft_clarity.rb
module MicrosoftClarity
  def self.enabled?
    return false if Rails.env.development? || Rails.env.test?
    return false if ENV['CLARITY_ENABLED'] == 'false'
    true
  end

  def self.project_id
    ENV.fetch('CLARITY_PROJECT_ID', 'nym19w4utx')
  end
end

Behavior by Environment

Environment Default Can Override?
Production Enabled CLARITY_ENABLED=false to disable
Development Disabled CLARITY_ENABLED=true to enable
Test Disabled Cannot enable

Consent Category

Clarity is loaded under the analytics consent category - it only runs when:

  1. window.tracVis === true
  2. User has accepted analytics cookies (or is in opt-out region without declining)

Analytics Track Controller (Stimulus)

For tracking events from ERB templates without inline JavaScript, use the analytics-track Stimulus controller.

Track on Page Load

Used for thank-you pages, order confirmations, etc. that fire an event when the page loads:

<div data-controller="analytics-track"
     data-analytics-track-on-load
     data-analytics-track-event-value="Lead - Form Submitted"
     data-analytics-track-properties-value="<%= { leadType: 'Contact Form', source: 'lead_form_thank_you' }.to_json %>">
</div>

Track on Click

Used for buttons and links that should fire an event when clicked:

<%= link_to 'Buy Now', add_to_cart_path,
    data: {
      controller: 'analytics-track',
      action: 'click->analytics-track#track',
      analytics_track_event_value: 'Quote Added to Cart',
      analytics_track_properties_value: { quoteId: quote.reference_number, value: quote.total }.to_json
    } %>

How It Works

  1. On page load: If data-analytics-track-on-load is present, fires the event when the controller connects
  2. On click: Binds to the track action and fires when clicked
  3. Turbo-safe: Automatically waits for window.Analytics to be available, solving timing issues with Turbo Drive navigation

Controller Location

app/javascript/controllers/analytics_track_controller.js

Benefits Over Inline Scripts

Approach Turbo-Safe CSP-Safe Maintainable
Inline <script> ⚠️ Needs nonce
onclick attribute ⚠️
Stimulus controller

Key Files

File Purpose
app/services/tracking/tracker.rb Core tracking logic, Cloudflare header extraction
app/views/shared/_tracking_init.html.erb Early JS setup, consent resolution
app/javascript/services/analytics.js Analytics wrapper (GA4, FB, Bing)
app/javascript/services/consent_manager.js Cookie consent UI and logic
app/javascript/controllers/consent_controller.js Stimulus controller for consent UI
app/javascript/controllers/analytics_track_controller.js Stimulus controller for declarative event tracking
app/javascript/utils/debug.js Debug logging utility (auto-enabled in dev)
app/workers/visit_geocoder_worker.rb Fallback geocoding worker
app/models/visit.rb Visit record model
app/models/visit_event.rb Visit event model ($view, $consent, etc.)
config/initializers/microsoft_clarity.rb Clarity on/off configuration
lib/middleware/cloudflare_geo_simulator.rb Dev middleware for simulating geo

Privacy Law Summary

Region Law Model Consent Required?
US CCPA/CPRA Opt-out No (but must provide opt-out)
US (CA) CCPA/CPRA Opt-out No (but honor GPC signal)
Canada (QC) Law 25 Opt-in Yes, before tracking
Canada (rest) PIPEDA Implied No (notice sufficient)
EU/EEA GDPR Opt-in Yes, before tracking
UK UK GDPR Opt-in Yes, before tracking
Switzerland nFADP Opt-in Yes, before tracking

Troubleshooting

External tracking not firing?

  1. Check window.tracVis in browser console
  2. Check window.trackingState for consent details
  3. Verify cc_cookie exists and has analytics category
  4. Check if TRACK_VISITOR=y is set (required in development)

Visit record not created?

  1. Check if IP is in WarmlyYours range (bypassed with TRACK_VISITOR=y)
  2. Check if User-Agent is detected as bot
  3. Check Rails logs for [track_visitor] messages
  4. Ensure you're on www subdomain (crm subdomain doesn't track)

Geo data missing on Visit?

  1. Check if Cloudflare headers are present (view in Rails logs)
  2. If headers missing, check if VisitGeocoderWorker was queued
  3. Check Sidekiq tracker queue for pending jobs
  4. In dev, use TRACK_VISITOR_PROFILE=US-IL to simulate headers

Quebec detection wrong?

  1. Verify CF-Region-Code header is QC
  2. Check window.visitorRegion in browser console
  3. If header missing, falls back to Accept-Language detection
  4. Test with TRACK_VISITOR_PROFILE=CA-QC

Consent not being recorded?

  1. Check browser network tab for POST /globals/consent
  2. Verify session[:visit_id] exists (check Rails logs)
  3. Check visit_events table for $consent events:
    Visit.last.visit_events.where(name: '$consent')
    

Microsoft Clarity errors in dev?

  1. Clarity is disabled by default in development
  2. CORS errors are expected - Clarity doesn't work on localhost
  3. To test Clarity locally: CLARITY_ENABLED=true bin/dev

Cookie consent banner not showing?

  1. Clear cookies and try in incognito
  2. Check window.trackingState.isOptInRegion - banner only auto-shows for opt-in regions
  3. For US visitors, use footer "Cookie Preferences" link
  4. Test opt-in region: TRACK_VISITOR_PROFILE=CA-QC or DE

Analytics track controller not firing?

  1. Check that data-analytics-track-on-load is present (for page load events)
  2. Check that data-action="click->analytics-track#track" is present (for click events)
  3. Verify data-analytics-track-event-value is set
  4. Check browser console for [analytics-track] logs (auto-enabled in dev)
  5. Verify window.Analytics exists (bundle must be loaded)

Debug commands

// Browser console - Tracking state
window.tracVis                    // Current tracking state
window.trackingState              // Full tracking debug info
window.visitorCountry             // Detected country
window.visitorRegion              // Detected region (e.g., QC)
CookieConsent.getUserPreferences() // Current cookie preferences

// Debug logging (auto-enabled in development)
debug.isEnabled                   // Check if debug logging is on
debug.enable()                    // Force enable debug logging
debug.disable()                   // Force disable debug logging

// Analytics-specific debug (uses global debug utility)
Analytics._debug                  // Same as debug.isEnabled
Analytics._debug = true           // Same as debug.enable()

See DEBUG_LOGGING.md for full debug utility documentation.


WarmlyYours Analytics & Conversion Tracking

Last Updated: May 14, 2026
For: PPC/Advertising Team & Developers


Overview

All tracking events are centralized through a single Analytics wrapper (app/javascript/services/analytics.js) that sends data to multiple platforms. Events flow through two channels:

  1. Client-side — JavaScript events fired in the browser via the Analytics wrapper
  2. Server-side — API calls from Rails to Pinterest Conversions API and Google Ads offline conversions

Event Categories

Category Description Fires To
Key Conversions High-value actions tracked across all platforms GA4, Facebook, Bing, Pinterest
Funnel Events Drop-off analysis for cart and quote flows GA4 + Pinterest (subset)

Platforms Receiving Data

Platform Purpose Account / Tag ID Consent Category Channel
Google Analytics 4 (GA4) Analytics & Reporting Analytics Client
Facebook/Meta Pixel Conversion Tracking 261770050896557 Marketing Client
Microsoft Bing UET Conversion Tracking Marketing Client
Pinterest Tag Conversion Tracking 2614464771308 Marketing Client
Pinterest Conversions API Server-to-Server Conversions Ad Account: 549755854137 N/A (server) Server
Google Ads Offline Conversion Reporting AW-1061033593 N/A (server) Server
OpenAI Ads (ChatGPT) Pixel Conversion Tracking Pixel: PkMqGWwheG3kgF4gJgzzbp Marketing Client
OpenAI Ads Conversions API Server-to-Server Conversions Pixel: PkMqGWwheG3kgF4gJgzzbp N/A (server) Server

Note on Google Ads: Direct client-side Google Ads conversion tags (gtag('event', 'conversion', ...)) were removed on Feb 27, 2026. Google Ads now receives data through two paths: (1) GA4 key events auto-imported into Google Ads, and (2) the server-side offline conversion API via Invoicing::GoogleConversionReporter.


Event Schema (10 Client-Side Events)

Key Conversions (GA4, Facebook, Bing, Pinterest, OpenAI Ads)

Event Name GA4 Facebook Bing Pinterest OpenAI Ads When It Fires
Cart - Product Added add_to_cart AddToCart AddToCart items_added User adds item to cart
Cart - Order Completed purchase Purchase checkout order_created + CAPI Purchase confirmation
Lead - Form Submitted generate_lead Lead lead lead_created + CAPI Form submitted
Account - Registration Completed sign_up CompleteRegistration sign_up Signup registration_completed Devise sign-up persisted
Instant Quote - Completed FH instant_quote_complete_fh ✅ custom ✅ custom ✅ custom Floor heating (Indoor) quote completes
Instant Quote - Completed SM instant_quote_complete_sm ✅ custom ✅ custom ✅ custom Snow melting (Outdoor) quote completes

Funnel Events (GA4 + Pinterest Subset + OpenAI Ads Subset)

Event Name GA4 Pinterest OpenAI Ads When It Fires Properties
Cart - Viewed view_cart User views cart page cartId, products, value, currency
Cart - Customer Info Entered begin_checkout InitiateCheckout checkout_started Customer info form submitted currency, value
Cart - Shipping Info Added add_shipping_info Shipping method selected currency, value, shippingMethod
Cart - Payment Info Entered add_payment_info AddPaymentInfo Payment details entered currency, value, paymentMethod
My Account - Quote Viewed my_account_quote_viewed User views saved quote quoteId, value, currency, itemCount

Provider Mapping (Complete)

Analytics Event GA4 Event Facebook Pixel Bing UET Pinterest Tag OpenAI Ads
Cart - Product Added add_to_cart AddToCart add_to_cart AddToCart items_added
Cart - Order Completed purchase Purchase purchase checkout order_created (+ CAPI)
Lead - Form Submitted generate_lead Lead submit_lead_form lead lead_created (+ CAPI)
Account - Registration Completed sign_up CompleteRegistration sign_up Signup registration_completed
Instant Quote - Completed FH instant_quote_complete_fh InstantQuoteCompleteFH instant_quote_complete_fh custom custom
Instant Quote - Completed SM instant_quote_complete_sm InstantQuoteCompleteSM instant_quote_complete_sm custom custom
Cart - Viewed view_cart
Cart - Customer Info Entered begin_checkout InitiateCheckout checkout_started
Cart - Shipping Info Added add_shipping_info
Cart - Payment Info Entered add_payment_info AddPaymentInfo
My Account - Quote Viewed my_account_quote_viewed

First-Party Tracking (visit_events Table)

In addition to client-side and server-side external events, the quote builder records first-party analytics events directly to our database.

Quote Completed Events

Fired server-side by QuoteBuilderController#track_quote_completed on every successful quote calculation:

Event Name When System Type
$quote_completed_fh Indoor (floor heating) quote succeeds floor-heating
$quote_completed_sm Outdoor (snow melting) quote succeeds snow-melting

Properties stored:

Property Source
share_key MD5 hash of quote configuration
system_type floor-heating or snow-melting (derived from environment)
environment Indoor or Outdoor
room_type e.g. bathroom, driveway
floor_type e.g. tile-marble-or-stone, asphalt
heated_area Square footage
currency USD or CAD
value Lowest price from BOM output
url Referring or original URL

Implementation note: system_type is derived from environment (Outdoor → snow-melting, Indoor → floor-heating), not from params[:system_type]. The React client calls /quote-builder/get_quotes.json which has no system_type URL segment — using params[:system_type] would always be nil.

Historical data: A backfill migration (20260227200001) was run on Feb 27, 2026 to correct mislabeled events. Prior to the fix, all quotes — including Outdoor/SM — were recorded as $quote_completed_fh. The legacy $quote_completed event (pre-Feb 13, 2026) was also split into FH/SM retroactively.


Pinterest Integration

Pinterest tracking operates on two channels that work together with deduplication:

Channel 1: Client-Side (Pinterest Tag)

The Pinterest Tag (pintrk) is loaded by consent_manager.js inside the marketing consent block, alongside Facebook and Bing. It only fires when the user has granted marketing consent.

Setting Value
Tag ID 2614464771308
Enhanced Match Hashed email via window.current_user.email_hash
GPC Compliance Blocked when Global Privacy Control is enabled
Cookie Names _pin_unauth, _pinterest*, _epik, _derived_epik (auto-cleared on consent withdrawal)

Each event includes a unique event_id (UUID) from Analytics.generateEventId() for deduplication with the server-side Conversions API.

Channel 2: Server-Side (Conversions API v5)

Setting Value
API Endpoint POST https://api.pinterest.com/v5/ad_accounts/549755854137/events
Ad Account ID 549755854137 (WarmlyYours Radiant Heating)
Auth Bearer token in Rails credentials (pinterest.conversion_token)
Action Source web
Rate Limit 5,000 calls/min per ad account

Events sent:

Trigger Pinterest Event Source Data
Order invoiced checkout Invoice total, tracking email, Visit IP/UA
Opportunity qualified lead 20% of opportunity value, email, Visit IP/UA

User data sent: SHA256-hashed email (Gmail dot-stripped), client_ip_address, client_user_agent, event_source_url — all from Visit record.

Metadata storage: Results persisted to pinterest_conversion_meta JSONB column on Order and Opportunity models. Fields: event_id, event_name, reported_at, attempted_at, conversion_date_time, conversion_email, error, http_status, result.

Deduplication

When both channels report the same event, Pinterest deduplicates using event_id:

  1. Client fires pintrk('track', 'checkout', { event_id: 'abc-123' })
  2. Server sends POST /events with event_id: 'abc-123'
  3. Pinterest treats both as a single conversion

Requirements: same non-empty event_id and event_name; action_source not offline; duplicate arrives within 24 hours.


Google Ads Conversions

GA4 Key Events (Auto-Imported)

All client-side events flow into Google Ads via GA4 key events (4–6 hour delay):

GA4 Event Google Ads Import
add_to_cart product_added_to_cart
instant_quote_complete_fh GA4 key event
instant_quote_complete_sm GA4 key event
purchase GA4 key event
generate_lead GA4 key event
sign_up GA4 key event — verify it's marked as a key event + imported into Google Ads via Tools → Conversions

Server-Side (Offline Conversions)

Google Ads offline conversions are sent server-to-server for orders matched to an ad click (GCLID/GBRAID/WBRAID):

Service Worker Identifier
Invoicing::GoogleConversionReporter GoogleOfflineConversionWorker GCLID, GBRAID, or WBRAID from Visit
Same service GoogleOfflineConversionRetryWorker Retries failed conversions within 7-day window

Metadata stored in orders.google_conversion_meta JSONB column.


OpenAI Ads (ChatGPT) Integration

Two channels mirror Pinterest's architecture — browser pixel + server-side Conversions API — using a single shared tracking_event_id UUID stamped on orders and opportunities for dedup.

Channel 1: Client-Side (oaiq pixel)

JS SDK loaded from https://bzrcdn.openai.com/sdk/oaiq.min.js, initialized with pixel ID PkMqGWwheG3kgF4gJgzzbp. Every conversion-relevant Analytics.track* method fires an oaiq('measure', ...) call alongside the GA4/Facebook/Bing/Pinterest equivalents.

Channel 2: Server-Side (Conversions API)

OpenaiAds::ConversionReporter posts to https://bzr.openai.com/v1/events?pid=<pixel> with bearer-token auth (Conversions Key from Heatwave::Configuration[:openai_ads][:capi_token]). Two events get the CAPI mirror: order_created (after Order#after_create) and lead_created (after Opportunity state-machine "won"). The other browser events (items_added, checkout_started, registration_completed) are pixel-only — server-side mirror adds marginal value for browser-bound events.

Service Worker Trigger
OpenaiAds::ConversionReporter OpenaiAdsConversionWorker Order#after_create, Opportunity state-machine

Per-attempt state stored in orders.openai_ads_conversion_meta and opportunities.openai_ads_conversion_meta JSONB columns.

Deduplication

Shared tracking_event_id UUID (set in before_create on Order and Opportunity) flows through both channels:

  1. Server-side: reporter reads record.tracking_event_id
  2. Client-side: confirmation views render tracking_event_id_meta_tag(record); JS reads via Analytics.serverEventId() and passes as event_id option to oaiq('measure', ...)

OpenAI's dedup contract: match on pixel_id + event_id within 24h. Same architecture as Pinterest's, same shared UUID.

Conversion event registration in OpenAI Ads dashboard

OpenAI requires each event type be registered in the Conversions tab before it counts toward reporting/optimization. Currently registered:

Event Base Event (registered) Data Source
order_created order_created WarmlyYours.com
lead_created lead_created WarmlyYours.com
items_added items_added WarmlyYours.com
checkout_started checkout_started WarmlyYours.com
registration_completed registration_completed WarmlyYours.com

Adding a new event type requires both (a) registering it in the OpenAI Ads dashboard AND (b) wiring the oaiq('measure', ...) call in analytics.js. Skipping either step means the event never makes it to OpenAI's optimizer.

Canary deploy

The reporter respects a Heatwave::Configuration[:openai_ads][:validate_only] flag. When true, the Conversions API runs validation but doesn't record events — first 24h of any new deploy should start in canary, then flip to live.


Implementation Methods

There are three ways client-side events are fired:

Method Use Case Location
Server-side track_event() Controller actions before redirects Ruby controllers
Client-side Analytics.track() JavaScript/Stimulus interactions JS files
Stimulus analytics-track controller Declarative tracking from ERB templates HTML attributes

Server-Side Events (track_event)

Events queued via track_event() are delivered via globals.json and fired client-side. Stored in session['queued_analytics_events'] (drained by GlobalsController#show). Session storage is durable across intermediate requests between the redirect and the next globals.json fetch — earlier flash-based storage would silently drop events if the user submitted another request first, and crashed flash-toast partials when iterated.

Controller Event Properties
auth/authentications_controller.rb Lead - Form Submitted { leadType: 'Registration', source: 'oauth_<provider>' }
auth/customer_sessions_controller.rb Lead - Form Submitted { leadType: 'Registration', source: 'customer_registration' }
my_rooms_controller.rb My Account - Quote Viewed { quoteId, roomType }
my_rooms_controller.rb Cart - Product Added { quoteId, roomType, value }

Why globals.json? Layouts can be edge-cached (Cloudflare). Injecting JavaScript directly would cause stale events to replay. globals.json is never cached and runs per-request.

Client-Side Events (JavaScript)

File Events Fired
checkout_customer_controller.js Cart - Customer Info Entered, Cart - Shipping Info Added
analytics_track_controller.js Any event (declarative from ERB)
QuotingApp.js Instant Quote - Completed FH, Instant Quote - Completed SM, Cart - Product Added

Declarative Tracking (Stimulus analytics-track Controller)

Lead Form Thank-You Pages:

File Lead Type
www/leads/lead_form_thank_you.html.erb Contact Form
www/leads/lead_form_thank_you_es.html.erb Contact Form (Spanish)
www/leads/lead_form_modal_thank_you.turbo_stream.erb Contact Form (Modal)
www/leads/tradepro_lead_form_thank_you.html.erb Trade Pro Contact
contact_forms/enroll_thank_you.html.erb Dealer Program Enrollment
contact_forms/request_literature_thank_you.html.erb Request Literature
contact_forms/product_sample_thank_you.html.erb Product Sample
contact_forms/upload_photo_thank_you.html.erb Upload Photo
contact_forms/personal_connect_thank_you.html.erb Personal Connect
contact_forms/custom_product_training_thank_you.html.erb Custom Product Training
contact_forms/roof_gutter_deicing_quote_thank_you.html.erb Roof & Gutter Deicing Quote
party_notifier/signup_notification.html.erb My Projects Signup

Other Declarative Events:

File Event Properties
my_quotes/show.html.erb My Account - Quote Viewed quoteId, value, currency, itemCount
my_orders/_confirmation.html.erb Cart - Order Completed orderId, revenue, tax, shipping, currency, products
my_carts/add_item.turbo_stream.erb Cart - Product Added sku, name, category, price, quantity

Usage example:

<div data-controller="analytics-track"
     data-analytics-track-on-load
     data-analytics-track-event-value="Lead - Form Submitted"
     data-analytics-track-properties-value="<%= { leadType: 'Contact Form', source: 'lead_form_thank_you' }.to_json %>">
</div>

Meta-Tag Trigger (for post-redirect events)

For events that need to fire on the redirect target of a server-side action — registration success, password reset confirmation, etc. — the form-submit page navigates away before any JS can fire, so declarative data-analytics-track-on-load won't work. Use the meta-tag pattern instead:

# In the controller — set a flash on success:
class Auth::CustomerRegistrationsController < Devise::RegistrationsController
  def create
    super do |resource|
      flash[:registration_completed] = true if resource.persisted?
    end
  end
end
<%# In a layout fragment rendered on every storefront page (currently
    `app/views/shared/_tracking_init.html.erb`): %>
<%= analytics_event_meta_tag(:registration_completed) if flash[:registration_completed] %>

Analytics.boot() (in analytics.js) reads meta[name^="analytics:"] tags on every turbo:load and DOMContentLoaded, dispatches to the matching track* method via a registry, then removes the tag so re-renders / back-navigation don't double-fire. A turbo:before-cache handler additionally strips any stale tags from the cached snapshot as a defensive measure.

Currently registered events (see Analytics.boot() source):

Meta name Dispatches to
analytics:registration_completed Analytics.trackRegistrationCompleted

To add a new meta-tag-driven event, both register it in the boot() registry AND wire a track* method.

Turbo Drive / Morph compatibility

Analytics.boot() is safe under all three navigation modes:

Navigation turbo:load fires? Meta-tag handling
Initial page load ✅ (via DOMContentLoaded + turbo:load) Read once, then removed
Turbo Drive visit Read fresh on each navigation
Turbo Morph visit / refresh Morph adds/removes meta nodes based on server diff; boot() reads on turbo:load post-morph
Back-button (cache restore) Cached snapshot has the tag stripped by boot() and the defensive turbo:before-cache handler — no double-fire

Usage Reference

From JavaScript (ES Module)

import Analytics from '../services/analytics'

Analytics.track('Cart - Product Added', {
  sku: 'SKU-123',
  name: 'Product Name',
  category: 'Floor Heating',
  price: 299.00,
  quantity: 1,
  currency: 'USD'
})

Analytics.track('Lead - Form Submitted', {
  leadType: 'Contact Form',
  source: 'lead_form_thank_you'
})

Analytics.track('Cart - Order Completed', {
  orderId: 'ORD-12345',
  revenue: 599.00,
  tax: 45.00,
  shipping: 15.00,
  currency: 'USD',
  products: [
    { sku: 'SKU-1', name: 'Product 1', price: 299, quantity: 1 },
    { sku: 'SKU-2', name: 'Product 2', price: 300, quantity: 1 }
  ]
})

From Ruby Controllers

track_event('Lead - Form Submitted', { leadType: 'New Form Type', source: 'new_page' })
redirect_to some_path

Consent & Privacy

Consent Category Platforms Behavior
Analytics GA4, Microsoft Clarity Defaults to granted; revocable
Marketing Facebook, Bing, Pinterest Tag Requires explicit consent in GDPR/Quebec regions; default grant in US/non-QC Canada

Global Privacy Control (GPC): When the browser's GPC signal is detected, all marketing scripts (Facebook, Bing, Pinterest) are blocked regardless of consent state.

Cookie Management: Pinterest cookies (_pin_unauth, _pinterest*, _epik, _derived_epik) are auto-cleared when marketing consent is withdrawn.

See doc/features/TRACKING_SYSTEM.md for full consent flow documentation.


GA4 Funnel Analysis

Cart Funnel

Cart - Product Added → Cart - Viewed → Cart - Customer Info Entered →
Cart - Shipping Info Added → Cart - Payment Info Entered → Cart - Order Completed

Quote Funnel

Instant Quote - Completed FH/SM → My Account - Quote Viewed →
Cart - Product Added → ... → Cart - Order Completed

Currency Handling

  • US Site (en-US): All values in USD
  • Canada Site (en-CA): All values in CAD

Currency is automatically detected from the user's locale and sent with all monetary events.


CRM Visibility

The Marketing tab on the Order detail page (orders/_marketing_tab.html.erb) shows conversion status for both Google Ads and Pinterest:

Google Ads Panel

  • GCLID / GBRAID / WBRAID values and their source (order visit, quote visit, customer visit)
  • Reporting status badge (reported / skipped / failed)
  • Report delay (days between click and conversion report)
  • Email used, conversion date/time, conversion value
  • "Resend conversion to Google" button

Pinterest Panel

  • Reporting status badge (reported / failed / rate_limited / timeout)
  • Event name (checkout / lead)
  • Event ID (UUID for deduplication)
  • HTTP status, email used, reported at, conversion date
  • "Send conversion to Pinterest" button

Key Implementation Files

Client-Side

File Purpose
app/javascript/services/analytics.js Main Analytics wrapper — routes events to all providers
app/javascript/services/consent_manager.js Loads tracking scripts based on consent; fires PageView
app/javascript/controllers/analytics_track_controller.js Stimulus controller for declarative tracking
app/javascript/controllers/checkout_customer_controller.js Checkout funnel tracking
client/js/www/quotes/QuotingApp.js Quote builder tracking
client/js/www/primeval.js Fires server-queued events from globals.json

Server-Side (Event Bridge)

File Purpose
app/concerns/controllers/analytics_events.rb Server-side track_event() concern
app/controllers/www/quote_builder_controller.rb First-party $quote_completed_fh/sm visit_events

Server-Side (Google Ads Offline Conversions)

File Purpose
app/services/invoicing/google_conversion_reporter.rb Google Ads offline conversion API client
app/workers/google_offline_conversion_worker.rb Enqueues single order conversion
app/workers/google_offline_conversion_retry_worker.rb Retries failed conversions (7-day window)

Server-Side (Pinterest Conversions API)

File Purpose
app/services/pinterest/conversion_reporter.rb Pinterest Conversions API v5 client
app/workers/pinterest_conversion_worker.rb Enqueues order or opportunity conversion

Database

Column Table Purpose
google_conversion_meta orders JSONB store for Google Ads conversion metadata
pinterest_conversion_meta orders JSONB store for Pinterest conversion metadata
pinterest_conversion_meta opportunities JSONB store for Pinterest lead conversion metadata

Tests

File Tests Coverage
test/services/pinterest/conversion_reporter_test.rb 11 Service success/failure, idempotency, API errors, email hashing
test/workers/pinterest_conversion_worker_test.rb 5 Worker delegation, missing records, unknown types
test/services/pinterest/analytics_events_test.rb 12 Client-side JS event verification, deduplication, consent gating

Architecture Diagram

                        ┌─────────────────────────────────────────────┐
                        │              Browser (Client)               │
                        │                                             │
                        │  consent_manager.js                         │
                        │    ├─ Loads GA4 (gtag)                      │
                        │    ├─ Loads Facebook Pixel (fbq)            │
                        │    ├─ Loads Bing UET (uetq)                 │
                        │    └─ Loads Pinterest Tag (pintrk)          │
                        │                                             │
                        │  analytics.js                               │
                        │    └─ Analytics.track(event, props)         │
                        │         ├─ gtag('event', ...)     → GA4     │
                        │         ├─ fbq('track', ...)      → Facebook│
                        │         ├─ uetq.push(...)         → Bing    │
                        │         └─ pintrk('track', ...)   → Pinterest│
                        │                                             │
                        │  primeval.js                                │
                        │    └─ Fires queued analytics events         │
                        │       drained from session via globals.json │
                        └─────────────────────────────────────────────┘

                        ┌─────────────────────────────────────────────┐
                        │              Rails (Server)                 │
                        │                                             │
                        │  Controllers                                │
                        │    └─ track_event() → session → globals.json│
                        │                                             │
                        │  QuoteBuilderController                     │
                        │    └─ track_quote_completed()               │
                        │         └─ visit_events ($quote_completed_  │
                        │              fh / $quote_completed_sm)      │
                        │                                             │
                        │  GoogleOfflineConversionWorker              │
                        │    └─ Invoicing::GoogleConversionReporter   │
                        │         └─ POST → Google Ads API            │
                        │                                             │
                        │  PinterestConversionWorker                  │
                        │    └─ Pinterest::ConversionReporter         │
                        │         └─ POST → Pinterest API v5          │
                        │                                             │
                        │  DB: visit_events ($quote_completed_fh/sm)  │
                        │  DB: orders.google_conversion_meta (JSONB)  │
                        │  DB: orders.pinterest_conversion_meta (JSONB)│
                        │  DB: opportunities.pinterest_conversion_meta│
                        └─────────────────────────────────────────────┘

Testing & Verification

Enable Debug Mode (Client-Side)

Analytics.enableDebug()
Analytics.track('Cart - Product Added', { sku: 'TEST' })
Analytics.disableDebug()

Google Analytics 4

  1. GA4 → Reports → Realtime
  2. Trigger an event, watch for it in realtime view

Facebook Pixel

  1. Install Facebook Pixel Helper Chrome extension
  2. Trigger events and verify in the extension popup

Microsoft Bing

  1. Install UET Tag Helper Chrome extension

Pinterest Tag

  1. Use the Pinterest Tag Helper Chrome extension
  2. Verify in Pinterest Ads Manager → Events → Event History

Pinterest Conversions API

  1. In CRM, go to an invoiced order's Marketing tab
  2. Click "Send conversion to Pinterest"
  3. Review pinterest_conversion_meta JSON panel for stored result
  4. Verify in Pinterest Ads Manager → Events → Event History (source: "Conversions API")

Automated Tests

# Run all Pinterest-related tests
mise exec -- bin/rails test test/services/pinterest/ test/workers/pinterest_conversion_worker_test.rb

Adding New Events

Option 1: Server-Side (Before Redirect)

track_event('Lead - Form Submitted', { leadType: 'New Form Type', source: 'new_page' })
redirect_to some_path

Option 2: Client-Side (JavaScript)

if (window.Analytics) {
  window.Analytics.track('Cart - Product Added', { sku: 'ABC123', price: 29.99 })
}

Option 3: Declarative (ERB Template)

<div data-controller="analytics-track"
     data-analytics-track-on-load
     data-analytics-track-event-value="Lead - Form Submitted"
     data-analytics-track-properties-value="<%= { leadType: 'New Type' }.to_json %>">
</div>

Adding a New Pinterest Server-Side Event

  1. Add a method to Pinterest::ConversionReporter (see send_order_conversion / send_opportunity_conversion)
  2. Call it from a worker or directly from a service
  3. Store result in the record's pinterest_conversion_meta JSONB column

Related Documentation

Document Purpose
doc/features/TRACKING_SYSTEM.md Consent flow, visit table, internal tracking architecture