Tracking & Analytics
Overview
Section titled “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?
Section titled “Why Two Layers?”-
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
-
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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Why This Works for Edge-Cached Pages”With the simplified flow, edge caching is no longer a concern:
_tracking_init.html.erbonly 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)
Section titled “Internal Tracking (Visit Table)”What Gets Tracked
Section titled “What Gets Tracked”The Tracking::Tracker service creates/updates Visit records for every request except:
- WarmlyYours employee IPs
- Bot/crawler requests
- Non-www subdomains
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) trueendVisit Record Fields
Section titled “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
Section titled “Geolocation Worker Fallback”If Cloudflare headers are missing or incomplete, the VisitGeocoderWorker is queued:
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)endExternal Tracking (Analytics)
Section titled “External Tracking (Analytics)”Consent Flow (Simplified)
Section titled “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
Section titled “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 = falseuntil consent given
Cross-Region Consent (Re-consent Handling)
Section titled “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 regionMODE_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
Section titled “Analytics Service”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
Section titled “Testing with cURL”Base URL
Section titled “Base URL”BASE_URL="https://www.warmlyyours.me:3000"Test 1: US Visitor (Opt-out Default)
Section titled “Test 1: US Visitor (Opt-out Default)”# US visitor from California - should track by defaultcurl -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)
Section titled “Test 2: Quebec Visitor (Law 25 - Opt-in)”# Quebec visitor - should NOT track until consent givencurl -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 showTest 3: Ontario Visitor (Implied Consent)
Section titled “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)
Section titled “Test 4: EU/GDPR Visitor (Strict Opt-in)”# German visitor - should NOT track until consent givencurl -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 showTest 5: No Cloudflare Headers (Fallback)
Section titled “Test 5: No Cloudflare Headers (Fallback)”# Request without Cloudflare headers - should trigger geocoder workercurl -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)
Section titled “Test 6: Bot Request (Should Not Track)”# Bot user agent - should NOT create visit recordcurl -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 normallyTest 7: WarmlyYours Employee IP (Should Not Track)
Section titled “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 falseTest 8: Global Privacy Control (GPC)
Section titled “Test 8: Global Privacy Control (GPC)”# GPC header set - marketing pixels should be blockedcurl -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
Section titled “Development Testing”Enable Tracking in Development
Section titled “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 systemTRACK_VISITOR=y bin/devAll Environment Flags
Section titled “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
Section titled “Available TRACK_VISITOR_PROFILE Values”Region Coding Format:
- US States:
US-XXformat (e.g.,US-IL,US-CA) - Canadian Provinces:
CA-XXformat (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_IL → US-IL).
United States (Opt-out consent model)
Section titled “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
Section titled “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)
Section titled “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
Section titled “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
Section titled “Special/Test Profiles”| Profile | Description |
|---|---|
UNKNOWN |
Country code XX - tests fallback behavior |
NONE |
No Cloudflare headers - tests VisitGeocoderWorker fallback |
Common Development Scenarios
Section titled “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 # CaliforniaTRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-FL bin/dev # FloridaTRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-TX bin/dev # TexasTRACK_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 # GermanyTRACK_VISITOR=y TRACK_VISITOR_PROFILE=FR bin/dev # FranceTRACK_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/devCloudflare Geo Simulator Middleware
Section titled “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
Section titled “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
Section titled “Custom Header Overrides”You can also set individual headers for fine-grained testing:
# Just set country and region codeCF_IPCOUNTRY=CA CF_REGION_CODE=QC TRACK_VISITOR=y bin/dev
# Full custom locationCF_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/devTesting Workflow
Section titled “Testing Workflow”-
Start server with desired profile:
Terminal window TRACK_VISITOR=y TRACK_VISITOR_PROFILE=CA-QC bin/dev -
Open incognito browser (to start fresh without cookies)
-
Navigate to:
https://www.warmlyyours.me:3000/en-US -
Check console for injected headers:
[CloudflareGeoSimulator] Injecting profile QUEBEC: CA/QC/Montreal -
Verify in browser console:
window.trackingState// Should show: { country: 'CA', region: 'QC', isOptInRegion: true, tracVis: false } -
For Quebec/GDPR: Cookie consent banner should appear
-
Accept cookies, then verify:
window.tracVis // Should now be true
View Tracking Debug Info
Section titled “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 stateCheck Visit Records
Section titled “Check Visit Records”# In rails console:Visit.order(created_at: :desc).limit(5).pluck(:id, :country, :region, :city, :ip)Consent Tracking via Visit Events
Section titled “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
Section titled “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
Section titled “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
Section titled “Viewing in CRM”On the Visit details page (/visits/:id):
- Privacy & Consent panel: Shows latest consent state
- Events table: Shows all
$consentevents with badges:[Analytics: Yes]/[Analytics: No][Marketing: Yes]/[Marketing: No][GPC]if Global Privacy Control was detected
Microsoft Clarity (Session Recording)
Section titled “Microsoft Clarity (Session Recording)”Microsoft Clarity provides session recordings and heatmaps for UX analysis.
Configuration
Section titled “Configuration”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') endendBehavior by Environment
Section titled “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
Section titled “Consent Category”Clarity is loaded under the analytics consent category - it only runs when:
window.tracVis === true- User has accepted analytics cookies (or is in opt-out region without declining)
Analytics Track Controller (Stimulus)
Section titled “Analytics Track Controller (Stimulus)”For tracking events from ERB templates without inline JavaScript, use the analytics-track Stimulus controller.
Track on Page Load
Section titled “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
Section titled “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 asvalue— see Conversion Values.
How It Works
Section titled “How It Works”- On page load: If
data-analytics-track-on-loadis present, fires the event when the controller connects - On click: Binds to the
trackaction and fires when clicked - Turbo-safe: Automatically waits for
window.Analyticsto be available, solving timing issues with Turbo Drive navigation
Controller Location
Section titled “Controller Location”app/javascript/controllers/analytics_track_controller.jsBenefits Over Inline Scripts
Section titled “Benefits Over Inline Scripts”| Approach | Turbo-Safe | CSP-Safe | Maintainable |
|---|---|---|---|
Inline <script> |
❌ | ⚠️ Needs nonce | ❌ |
onclick attribute |
⚠️ | ❌ | ❌ |
| Stimulus controller | ✅ | ✅ | ✅ |
Key Files
Section titled “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
Section titled “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
Section titled “Troubleshooting”External tracking not firing?
Section titled “External tracking not firing?”- Check
window.tracVisin browser console - Check
window.trackingStatefor consent details - Verify
cc_cookieexists and hasanalyticscategory - Check if
TRACK_VISITOR=yis set (required in development)
Visit record not created?
Section titled “Visit record not created?”- Check if IP is in WarmlyYours range (bypassed with
TRACK_VISITOR=y) - Check if User-Agent is detected as bot
- Check Rails logs for
[track_visitor]messages - Ensure you’re on www subdomain (crm subdomain doesn’t track)
Geo data missing on Visit?
Section titled “Geo data missing on Visit?”- Check if Cloudflare headers are present (view in Rails logs)
- If headers missing, check if
VisitGeocoderWorkerwas queued - Check Sidekiq tracker queue for pending jobs
- In dev, use
TRACK_VISITOR_PROFILE=US-ILto simulate headers
Quebec detection wrong?
Section titled “Quebec detection wrong?”- Verify
CF-Region-Codeheader isQC - Check
window.visitorRegionin browser console - If header missing, falls back to Accept-Language detection
- Test with
TRACK_VISITOR_PROFILE=CA-QC
Consent not being recorded?
Section titled “Consent not being recorded?”- Check browser network tab for
POST /globals/consent - Verify
session[:visit_id]exists (check Rails logs) - Check
visit_eventstable for$consentevents:Visit.last.visit_events.where(name: '$consent')
Microsoft Clarity errors in dev?
Section titled “Microsoft Clarity errors in dev?”- Clarity is disabled by default in development
- CORS errors are expected - Clarity doesn’t work on localhost
- To test Clarity locally:
CLARITY_ENABLED=true bin/dev
Cookie consent banner not showing?
Section titled “Cookie consent banner not showing?”- Clear cookies and try in incognito
- Check
window.trackingState.isOptInRegion- banner only auto-shows for opt-in regions - For US visitors, use footer “Cookie Preferences” link
- Test opt-in region:
TRACK_VISITOR_PROFILE=CA-QCorDE
Analytics track controller not firing?
Section titled “Analytics track controller not firing?”- Check that
data-analytics-track-on-loadis present (for page load events) - Check that
data-action="click->analytics-track#track"is present (for click events) - Verify
data-analytics-track-event-valueis set - Check browser console for
[analytics-track]logs (auto-enabled in dev) - Verify
window.Analyticsexists (bundle must be loaded)
Debug commands
Section titled “Debug commands”// Browser console - Tracking statewindow.tracVis // Current tracking statewindow.trackingState // Full tracking debug infowindow.visitorCountry // Detected countrywindow.visitorRegion // Detected region (e.g., QC)CookieConsent.getUserPreferences() // Current cookie preferences
// Debug logging (auto-enabled in development)debug.isEnabled // Check if debug logging is ondebug.enable() // Force enable debug loggingdebug.disable() // Force disable debug logging
// Analytics-specific debug (uses global debug utility)Analytics._debug // Same as debug.isEnabledAnalytics._debug = true // Same as debug.enable()See DEBUG_LOGGING.md for full debug utility documentation.
WarmlyYours Analytics & Conversion Tracking
Section titled “WarmlyYours Analytics & Conversion Tracking”Last Updated: August 7, 2026 For: PPC/Advertising Team & Developers
Overview
Section titled “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:
- Client-side — JavaScript events fired in the browser via the Analytics wrapper
- Server-side — API calls from Rails to Google, Microsoft, Pinterest, Facebook, and OpenAI conversion endpoints
Event Categories
Section titled “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
Section titled “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 viaInvoicing::GoogleConversionReporter.
Event Mapping Schema
Section titled “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)
Section titled “Key Conversions (GA4, Facebook, Bing, Pinterest, OpenAI Ads)”| Event Name | GA4 | Bing | 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)
Section titled “Funnel Events (GA4 + Pinterest Subset + OpenAI Ads Subset)”| Event Name | GA4 | 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)
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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)
Section titled “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
Section titled “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
Section titled “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 activitiesactivities.visit_id— 5.6%opportunity_reception_type— onlyCRM/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.
Related server-side model
Section titled “Related server-side model”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)
Section titled “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
Section titled “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_typeis derived fromenvironment(Outdoor → snow-melting, Indoor → floor-heating), not fromparams[:system_type]. The React client calls/quote-builder/get_quotes.jsonwhich has nosystem_typeURL segment — usingparams[: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
Section titled “Pinterest Integration”Pinterest tracking operates on two channels that work together with deduplication:
Channel 1: Client-Side (Pinterest Tag)
Section titled “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)
Section titled “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
Section titled “Deduplication”When both channels report the same event, Pinterest deduplicates using event_id:
- Client fires
pintrk('track', 'checkout', { event_id: 'abc-123' }) - Server sends
POST /eventswithevent_id: 'abc-123' - 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
Section titled “Google Ads Conversions”GA4 Key Events (Auto-Imported)
Section titled “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?
Section titled “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 settingscurl -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/origincurl -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 strategycurl -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_leadwas promoted toprimary_for_goal = true, so the chain went live end to end. The same change set turned onalways_use_default_value = true, which made Google ignore the tag and use its own account-side number — briefly leavinggenerate_leadat $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 clearingalways_use_default_valueon 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.
Settingis 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_ROAStoMAXIMIZE_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 stillprimary). Seedoc/tasks/202608041649_CONVERSION_VALUE_CORRECTION.md.
Server-Side (Offline Conversions)
Section titled “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
Section titled “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)
Section titled “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)
Section titled “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
Section titled “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
Section titled “Deduplication”Shared tracking_event_id UUID (set in before_create on Order and Opportunity) flows through both channels:
- Server-side: reporter reads
record.tracking_event_id - Client-side: confirmation views render
tracking_event_id_meta_tag(record); JS reads viaAnalytics.serverEventId()and passes asevent_idoption tooaiq('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
Section titled “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
Section titled “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
Section titled “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)
Section titled “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)
Section titled “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)
Section titled “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)
Section titled “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) } endendThe 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
Section titled “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
Section titled “Usage Reference”From JavaScript (ES Module)
Section titled “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
Section titled “From Ruby Controllers”track_event('Lead - Form Submitted', { leadType: 'New Form Type', source: 'new_page' })redirect_to some_pathConsent & Privacy
Section titled “Consent & Privacy”| 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
Section titled “GA4 Funnel Analysis”Cart Funnel
Section titled “Cart Funnel”Cart - Product Added → Cart - Viewed → Cart - Customer Info Entered →Cart - Shipping Info Added → Cart - Payment Info Entered → Cart - Order CompletedThis 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
Section titled “Quote Funnel”Instant Quote - Completed FH/SM → My Account - Quote Viewed →Cart - Product Added → ... → Cart - Order CompletedCurrency Handling
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Key Implementation Files”Client-Side
Section titled “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)
Section titled “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)
Section titled “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)
Section titled “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
Section titled “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 |
| 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
Section titled “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
Section titled “Testing & Verification”Enable Debug Mode (Client-Side)
Section titled “Enable Debug Mode (Client-Side)”Analytics.enableDebug()Analytics.track('Cart - Product Added', { sku: 'TEST' })Analytics.disableDebug()Google Analytics 4
Section titled “Google Analytics 4”- GA4 → Reports → Realtime
- Trigger an event, watch for it in realtime view
Facebook Pixel
Section titled “Facebook Pixel”- Install Facebook Pixel Helper Chrome extension
- Trigger events and verify in the extension popup
Microsoft Bing
Section titled “Microsoft Bing”- Install UET Tag Helper Chrome extension
Pinterest Tag
Section titled “Pinterest Tag”- Use the Pinterest Tag Helper Chrome extension
- Verify in Pinterest Ads Manager → Events → Event History
Pinterest Conversions API
Section titled “Pinterest Conversions API”- In CRM, go to an invoiced order’s Marketing tab
- Click “Send conversion to Pinterest”
- Review
pinterest_conversion_metaJSON panel for stored result - Verify in Pinterest Ads Manager → Events → Event History (source: “Conversions API”)
Automated Tests
Section titled “Automated Tests”# Run all Pinterest-related testsmise exec -- bin/rails test test/services/pinterest/ test/workers/pinterest_conversion_worker_test.rbAdding New Events
Section titled “Adding New Events”Option 1: Server-Side (Before Redirect)
Section titled “Option 1: Server-Side (Before Redirect)”track_event('Lead - Form Submitted', { leadType: 'New Form Type', source: 'new_page' })redirect_to some_pathOption 2: Client-Side (JavaScript)
Section titled “Option 2: Client-Side (JavaScript)”if (window.Analytics) { window.Analytics.track('Cart - Product Added', { sku: 'ABC123', price: 29.99 })}Option 3: Declarative (ERB Template)
Section titled “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
Section titled “Adding a New Pinterest Server-Side Event”- Add a method to
Pinterest::ConversionReporter(seesend_order_conversion/send_opportunity_conversion) - Call it from a worker or directly from a service
- Store result in the record’s
pinterest_conversion_metaJSONB column
Related Documentation
Section titled “Related Documentation”| Document | Purpose |
|---|---|
doc/features/TRACKING_SYSTEM.md |
Consent flow, visit table, internal tracking architecture |