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, Pinterest, OpenAI Ads) 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, Pinterest, OpenAI Ads) 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_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id Query params Campaign, ad-group, and creative tracking
marketing_meta[gclid/gbraid/wbraid] Query params/session Google Ads clicks
marketing_meta[oppref] OpenAI landing param/session/__oppref cookie ChatGPT Ads click attribution
marketing_meta[obref] OpenAI __obref cookie Browser/CAPI matching; persisted only with affirmative marketing consent
marketing_meta[msclkid] Query params/session Microsoft 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 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         │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘
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

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
# 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).

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)

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

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, quoted_amount: quote.total }.to_json
    } %>

Pass a quoted total as quoted_amount, never as value — see
Conversion Values.

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
  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
  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: August 7, 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 Google, Microsoft, Pinterest, Facebook, and OpenAI conversion endpoints

Event Categories

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

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 Marketing (Party#consent_marketing) 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 Mapping Schema

Analytics.js accepts the event names below and maps them to provider-specific
events. A retained mapping does not mean an active controller or view currently
emits it; dormant mappings are called out explicitly.

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 Account 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 Not currently emitted; mapping retained cartId, products, value, currency
Cart - Customer Info Entered begin_checkout InitiateCheckout checkout_started First successful customer-info save per cart in the browser session currency, value
Cart - Shipping Info Added add_shipping_info Not currently emitted; mapping retained currency, value, shippingMethod
Cart - Payment Info Entered add_payment_info AddPaymentInfo Not currently emitted; mapping retained currency, value, paymentMethod
My Account - Quote Viewed my_account_quote_viewed User views saved quote quoteId, quoted_amount, currency, itemCount — no value, see Conversion Values

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

Conversion Values

value is what the ad platforms bid on. Getting it wrong is not a reporting
problem — Google Ads conversion actions with
always_use_default_value = false consume the amount the tag sends, and
MAXIMIZE_CONVERSION_VALUE / TARGET_ROAS campaigns bid on it directly.

The rule: value carries either money someone actually put in a basket, or
the measured expected value of a capture. Never an amount we computed for the
visitor.

Event value Source
purchase Real order revenue The transaction
add_to_cart, view_cart, begin_checkout, add_shipping_info, add_payment_info Real basket value What the visitor assembled
generate_lead Setting.ad_conversion_lead_value$200 Measured, see below
instant_quote_complete_fh / _sm Setting.ad_conversion_quote_value$100 Measured, see below
my_account_quote_viewed none Funnel exploration only

Quoted amounts still travel, as a quoted_amount parameter — available for
analysis, invisible to bidding.

Why quote-builder events don't send the quoted total

They did until Aug 2026, and it was wrong twice over.

Wrong in magnitude. GA4 was carrying ~$6.4M of quote value per 30 days
against ~$189K of real purchase revenue.

Wrong in direction, which is the part that actually cost money. Conversion
falls as the quote grows, so the quoted amount is inversely correlated with
expected value at the top end:

Quoted range Avg quoted Converts Actual EV
FH Q1 $119–339 $260 12.0% $145
FH Q5 $2,028–52,878 $5,541 3.5% $68
SM Q1 $278–1,746 $1,005 8.3% $142
SM Q5 $13,115–262,416 $30,916 0.4% $24

The top snow-melting quintile declared an average $30,916 and was worth $24. A
value-based bid strategy fed that number pays the most for the quotes least
likely to close.

Where these values live

All three are rows in Setting (scope ad_conversion_values), editable at
/crm/settings/ad-conversion-values — no deploy needed:

Setting Default Drives
ad_conversion_lead_value 200 generate_lead
ad_conversion_quote_value 100 instant_quote_complete_fh / _sm
ad_conversion_opportunity_rate 0.2 the five offline reporters' opportunity.value × multiplier

The two browser-side values reach JS through the #page-config JSON island
and are read by Analytics.LEAD_VALUE / QUOTE_VALUE, which fall back to the
same defaults if page-config is missing or stale. ad_conversion_opportunity_rate
is read directly by Invoicing::GoogleConversionReporter,
MicrosoftAds::, Pinterest::, Facebook:: and OpenaiAds::ConversionReporter
— it used to be duplicated five times (twice as a constant, three times as a
bare 0.2) and could drift.

$200 is the agency's number, adopted 2026-08-05 so the code and Google Ads
agree. It's the conservative end of the measured range below — see
Attribution window vs. lead value
for why the full expected value is also defensible.

How the constants were derived

Both are revenue ÷ events over a matured cohort, joined to CRM orders. Rerun
these when revisiting the numbers — the quote cohort in particular starts
2026-01-30 (when $quote_completed_* shipped) and contains no snow season.

ad_conversion_lead_value = $200 — first web lead-form activity per customer
(ActivityTypeConstants::LEAD_FORM 206, plus 393/416/555/580),
Aug 2021 – Aug 2025. Four independent routes land in the same place:

Basis n Conv. AOV EV
orders.total, from lead date, 365d 6,642 21.1% $2,272 $479
orders.total, from party creation 5,809 17.5% $2,337 $408
…prospects only (excl. checkout-created accounts) 5,669 15.7% $2,523 $395
invoices.revenue_consolidated by gl_date, 365d 6,642 $437

Per year: $351 / $370 / $444 / $385 / $411. No outlier skew (median converting
value $1,284, p99 $14,927, max $36,925). The event population is 1:1 with the
activity — 830 activities vs 840 GA4 events over six months — so the
registration and contact-form paths that also call trackLeadFormSubmitted
don't materially dilute it.

ad_conversion_quote_value = $100 — quote-builder completions ≥90 days old, 90-day
window: FH 10.4% × $1,402 = $99.6/event; SM 3.5% × $4,296 = $102.8/event. One
flat value because the two converge — FH converts ~3x more often, SM orders are
~3x bigger. Bucketing by quote size was considered and rejected: the top
quintiles rest on 1–8 conversions each, so it would fit noise.

Is the lead value real? (control group + causality)

Two checks, because "customer submitted a form and later bought" is on its own
just co-occurrence.

Control group. Every other Customer party created in the same four years,
measured identically. Both arms exclude accounts created at purchase (an
order within 1 day of party creation) — checkout creates the account, so those
are customers created by a sale, not prospects:

Prospects Ordered AOV EV
Submitted a lead form 5,669 15.66% $2,523 $395
Did not 31,423 10.27% $1,955 $201

1.52x the conversion rate, z = 11.9. Holds in all five years individually
($351/$370/$444/$385/$411 vs $251/$174/$212/$256/$133).

Skipping the checkout-created exclusion inverts this result — leads appear to
convert worse (17.5% vs 19.7%), because 53.6% of non-lead "buyers" ordered
within a day of account creation. Any rerun of this analysis must keep the
exclusion.

Causality. Revenue clusters hard around the lead rather than spreading
across the year, which co-occurrence would not do:

Lag from lead % of revenue Uniform would be
before lead (−7…0d) 0.9%
same day 3.3% 0.3%
1–7d 15.7% 1.6% (10x)
7–30d 26.6% 6.3% (4x)
30–90d 20.5% 16.4%
90–180d 17.6% 24.7%
180–365d 15.3% 50.7% (0.3x)

Attribution window vs. lead value — why the value is not cut to $288

Every conversion action that matters here (generate_lead, CRM Invoiced Revenue, both quote actions) runs a 90-day click-through window — Google's
maximum. Invoiced revenue per lead by window:

Window 30d 60d 90d 180d 365d
EV per lead $188 $248 $288 $366 $437

The window does not truncate the lead's value. It governs click→conversion
lag, not conversion→revenue lag: generate_lead fires at form submission and
books its value then, and the window only decides whether a click from ≤90 days
earlier gets credit for that lead. So booking full expected value is correct,
and the $200 we ship is already well inside the $437 invoice basis.

What the window does raise is double counting, since CRM Invoiced Revenue
is also primary and reports the in-window portion separately. Splitting leads
by whether they produced a CRM opportunity inside the window:

Leads % EV ≤90d EV ≤365d Never visible to Google
Opportunity created ≤90d 3,481 52.4% $506 $765 $259/lead
No opportunity ≤90d 3,161 47.6% $48 $77 $29/lead

Half of all leads open an opportunity inside the window, and 51% of the
revenue those leads generate arrives after day 90
— invisible to any
attribution mechanism. A lead that opens an opportunity in week 3 and invoices
in month 7 is a complete success that CRM Invoiced Revenue will never report.

The strictly non-overlapping floor is $437 − $288 = $149/lead. It isn't
worth downgrading to: only 273 of ~1,700 annual leads are ad-attributed, so
the lead value adds $55K/yr at $200 ($109K at $400) of signal against $504K of reported invoiced revenue, with
partial overlap. $150–$290 is defensible if both actions stay primary and you
want to be conservative.

What this evidence is NOT

There is no per-record link from a lead form to a sale in the CRM. This is
a population-level result, not per-customer attribution:

  • activities.opportunity_id — populated on 2.1% of lead activities
  • activities.visit_id5.6%
  • opportunity_reception_type — only CRM / IQ / Online, no lead-form value

So the defensible claim is "people who submit this form are worth ~2x the
average prospect"
, not "this lead became that order". Making the latter true
means stamping opportunity_id onto the lead activity when the form creates
one — a code change, and the single biggest improvement available to this
analysis.

A lead form is worth ~4x a quote-builder completion. Until Aug 2026 it was
the one event sending $0, while the quote events sent 15–47x their real
worth. Google's own reporting for the 12 months to Aug 2026 shows the scale:

Conversion action Conversions Reported value
instant_quote_complete_fh 6,068 $8,456,658
instant_quote_complete_sm 1,738 $4,683,107
CRM Opportunity 819 $1,193,909
CRM Invoiced Revenue 543 $504,228
generate_lead 273 $0

$13.1M of quote-builder value against $504K of actual invoiced revenue — 26:1 —
while the event worth ~$400 each reported nothing. Note generate_lead is only
273/yr here against ~1,700 total lead activities: Google counts only the
ad-attributed ones.

The offline reporters (Invoicing::GoogleConversionReporter,
MicrosoftAds::, Pinterest::, Facebook::, OpenaiAds::ConversionReporter)
value an opportunity as opportunity.value * Setting.ad_conversion_opportunity_rate
(default 0.2). The 21.1% measured above validates that rate for lead-form
opportunities
. It is not calibrated for quote-builder opportunities — the
quintile table shows a $30k driveway opportunity is not worth $6k, it closes at
0.4%.

All five read the one setting. Until Aug 2026 each carried its own copy — two
as an AVG_LEAD_CONVERSION_RATE constant, three as a bare 0.2 with a comment
claiming it matched the others — so a retune had to be applied five times or
they silently diverged.


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.


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 Bidding role
add_to_cart product_added_to_cart Secondary; value forced to $0 account-side
instant_quote_complete_fh GA4 key event Primary, REQUEST_QUOTE; uses the tag value (Setting.ad_conversion_quote_value)
instant_quote_complete_sm GA4 key event Primary, REQUEST_QUOTE; uses the tag value (Setting.ad_conversion_quote_value)
purchase GA4 key event Secondary (CRM Invoiced Revenue is the primary purchase action)
generate_lead GA4 key event Primary (since 2026-08-05), SUBMIT_LEAD_FORM; uses the tag value (Setting.ad_conversion_lead_value)
sign_up GA4 key event — verify it's marked as a key event + imported into Google Ads via Tools → Conversions

Does an event's value actually reach bidding?

Shipping a value in the tag is necessary but not sufficient. Every link
below has to hold, and a break in any one of them makes the value inert while
everything still looks correctly instrumented in GA4:

# Link Field Breaks it when
1 The action is optimized for conversion_action.primary_for_goal false — secondary actions are observation-only and excluded from bidding, unless #4 applies
2 The account bids on that category customer_conversion_goal.biddable false for the action's category + origin pair
3 The campaign bids on that category campaign_conversion_goal.biddable false — campaign-level overrides beat the account default
4 …or the action is in a custom goal custom_conversion_goal.conversion_actions An action can bid despite being secondary if a CustomConversionGoal names it and the campaign uses that goal
5 Our value is used, not a fixed one conversion_action.value_settings.always_use_default_value true — Google substitutes default_value and ignores whatever the tag sent
6 The strategy is value-based at all campaign.bidding_strategy_type MAXIMIZE_CONVERSIONS / TARGET_CPA optimize count, so only the conversion count matters, not its value

Read the live state (see the google-ads
skill for the token-minting preamble that defines ${H[@]}; the API version in
the URL drifts roughly annually — v25 is current as of Aug 2026 and v21
sunsets this month):

# 1 + 5 — conversion actions: primary flag, category/origin, value settings
curl -s "${H[@]}" -X POST \
  "https://googleads.googleapis.com/v25/customers/9897052886/googleAds:search" \
  -d '{"query":"SELECT conversion_action.name, conversion_action.primary_for_goal, conversion_action.category, conversion_action.origin, conversion_action.value_settings.default_value, conversion_action.value_settings.always_use_default_value FROM conversion_action WHERE conversion_action.status = '"'"'ENABLED'"'"'"}'

# 2 — account-level biddability per category/origin
curl -s "${H[@]}" -X POST \
  "https://googleads.googleapis.com/v25/customers/9897052886/googleAds:search" \
  -d '{"query":"SELECT customer_conversion_goal.category, customer_conversion_goal.origin, customer_conversion_goal.biddable FROM customer_conversion_goal"}'

# 3 + 6 — which campaigns bid on a category, and with what strategy
curl -s "${H[@]}" -X POST \
  "https://googleads.googleapis.com/v25/customers/9897052886/googleAds:search" \
  -d '{"query":"SELECT campaign.name, campaign.bidding_strategy_type FROM campaign_conversion_goal WHERE campaign.status = '"'"'ENABLED'"'"' AND campaign_conversion_goal.biddable = true AND campaign_conversion_goal.category = '"'"'REQUEST_QUOTE'"'"'"}'

# 4 — custom goals (none defined on this account as of Aug 2026)
curl -s "${H[@]}" -X POST \
  "https://googleads.googleapis.com/v25/customers/9897052886/googleAds:search" \
  -d '{"query":"SELECT custom_conversion_goal.name, custom_conversion_goal.status, custom_conversion_goal.conversion_actions FROM custom_conversion_goal"}'

Resolved 2026-08-05, override lifted 2026-08-08. generate_lead was
promoted to primary_for_goal = true, so the chain went live end to end. The
same change set turned on always_use_default_value = true, which made Google
ignore the tag and use its own account-side number — briefly leaving
generate_lead at $400 in the tag and $200 in the account.

Both were settled by moving the values into Setting (see
Where these values live) at the agency's $200, and
then clearing always_use_default_value on all three actions once the two
sides agreed — a deliberately neutral moment, since the tag was by then
sending exactly what the account default said. default_value (200 / 100) is
left in place as a fallback for an event that arrives without a value.

Setting is now the single source of truth for every platform. The quote
events were never in conflict — tag and account both said $100 throughout.

Also changed in the same pass: Shopping US/CAN moved from TARGET_ROAS to
MAXIMIZE_CONVERSION_VALUE, dropping the targets that had been set against
the inflated value pool. Still outstanding: the April 2026 audit's junk-action
demotions (Local actions ×4, YouTube ×2, Android installs are all still
primary). See
doc/tasks/202608041649_CONVERSION_VALUE_CORRECTION.md.

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.

OpenAI monetary values are integer minor units (cents). The standard-event
data.type is part of the event contract, not an arbitrary classification:

Event data.type Channel Notes
items_added contents Pixel Includes product contents and basket amount
checkout_started contents Pixel Checkout-entry amount; using customer_action causes a dropped event
order_created contents Pixel + CAPI Real order revenue; shared event ID deduplicates the channels
lead_created customer_action Pixel + CAPI Expected lead value; shared event ID deduplicates the channels
registration_completed customer_action Pixel Browser-only registration signal
custom quote events custom Pixel custom_event_name distinguishes FH and SM completions

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.

CAPI is marketing processing even though it runs on the server. The reporter
requires both an attributable Visit and customer.consent_marketing == true;
otherwise it records a terminal no_visit or
marketing_consent_not_granted result and sends nothing. A reported event
carries the click reference as top-level oppref and, when the consent-gated
Pixel created one, the browser reference as user.obref. Hashed email and
customer ID plus raw IP/user-agent/normalized geography remain best-effort
matching signals.

Click and campaign attribution

The Pixel's first-party __oppref and __obref cookies are in the marketing
auto-clear list. Internal Visit tracking still records landing evidence, but
__obref is copied into Visit#marketing_meta only with affirmative marketing
consent and neither identifier is sent back to OpenAI without that consent.

Use this Ads Manager landing-page parameter template for campaign-level
attribution without replacing an ad's existing referral-code parameters:

utm_source=chatgpt.com&utm_medium=cpc&utm_id={campaign_id}&oai_campaign_id={campaign_id}&utm_content={ad_group_id}:{ad_id}&oai_ad_account_id={ad_account_id}

Tracking::Tracker persists utm_id and utm_content on the Visit.
Source.find_from_params resolves oai_campaign_id against the nightly-synced
OpenAI campaign's campaign_external_id before falling back to the generic
ChatGPT Ads parent. The ad-account ID remains available in the recorded landing
URL; ad-group/ad detail is retained in utm_content.

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.

A validate_only_ok result proves only that OpenAI accepted the schema. It
stamps attempted_at, never reported_at, and cannot establish conversions or
CPA. While the canary remains enabled, repeat sends of the same validated
record are suppressed; flipping the setting to false releases that guard so
the next enqueue can persist a real event. Verify the production setting and a
saved event in Ads Manager before calling a rollout 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_carts_controller.rb Cart - Customer Info Entered { currency, value } on the first successful save per cart/session
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
analytics_track_controller.js Any event (declarative from ERB)
QuotingApp.js Instant Quote - Completed FH, Instant Quote - Completed SM, Cart - Product Added

The removed checkout_customer_controller.js and
checkout_shipping_controller.js files no longer emit the checkout funnel
events. Analytics.js retains mappings for Cart - Viewed,
Cart - Shipping Info Added, and Cart - Payment Info Entered, but no current
controller or view invokes them.

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, quoted_amount, currency, itemCount — no value, see Conversion Values
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>

Session Queue (for post-redirect events)

For events that need to fire after a redirect from 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 session-backed queue instead:

# In each registration controller — queue after persistence:
class Auth::CustomerRegistrationsController < Devise::RegistrationsController
  def create
    super { |resource| queue_registration_event(resource) }
  end
end

The queue helper checks persistence and normalizes an OmniAuth strategy name
(for example, google_oauth2 to google), falling back to email for a
password registration.

Controllers::AnalyticsEvents#track_event stores the payload in the session.
The next globals.json response drains it exactly once, after dispatching the
tracking configuration that initializes consent-gated SDK queues. Primeval then
routes Account - Registration Completed through
Analytics.trackRegistrationCompleted.

Turbo Drive / Morph compatibility

The queue is navigation-independent: it survives intermediate HTML/Turbo
requests in the session, is delivered by the next globals request, and is
deleted when that response is built. A refresh or cache restoration cannot
deliver the same event twice.


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 Category Platforms Behavior
Analytics GA4, Microsoft Clarity Defaults to granted; revocable
Marketing Facebook, Bing, Pinterest Tag, OpenAI Ads Pixel and CAPI 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, OpenAI Ads) are blocked regardless of consent state. OpenAI CAPI independently checks the persisted Party marketing-consent decision before sending.

Cookie Management: Pinterest cookies (_pin_unauth, _pinterest*, _epik, _derived_epik) and OpenAI attribution cookies (__oppref, __obref) 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

This is the target funnel. Current emitters cover Cart - Product Added,
Cart - Customer Info Entered, and Cart - Order Completed; the viewed,
shipping, and payment mappings are dormant as noted above.

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:

  • 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

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