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) | 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) 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_* | Query params | Campaign tracking |
gclid, gbraid, wbraid | Query params | Google 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, value: quote.total }.to_json } %>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: May 14, 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 Pinterest Conversions API and Google Ads offline conversions
Event Categories
Section titled “Event Categories”| Category | Description | Fires To |
|---|---|---|
| Key Conversions | High-value actions tracked across all platforms | GA4, Facebook, Bing, Pinterest |
| Funnel Events | Drop-off analysis for cart and quote flows | GA4 + Pinterest (subset) |
Platforms Receiving Data
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 | N/A (server) | Server |
Note on Google Ads: Direct client-side Google Ads conversion tags (
gtag('event', 'conversion', ...)) were removed on Feb 27, 2026. Google Ads now receives data through two paths: (1) GA4 key events auto-imported into Google Ads, and (2) the server-side offline conversion API viaInvoicing::GoogleConversionReporter.
Event Schema (10 Client-Side Events)
Section titled “Event Schema (10 Client-Side Events)”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 | Devise sign-up persisted |
| Instant Quote - Completed FH | ✅ instant_quote_complete_fh | ✅ custom | ✅ | ✅ custom | ✅ custom | Floor heating (Indoor) quote completes |
| Instant Quote - Completed SM | ✅ instant_quote_complete_sm | ✅ custom | ✅ | ✅ custom | ✅ custom | Snow melting (Outdoor) quote completes |
Funnel Events (GA4 + Pinterest Subset + OpenAI Ads Subset)
Section titled “Funnel Events (GA4 + Pinterest Subset + OpenAI Ads Subset)”| Event Name | GA4 | OpenAI Ads | When It Fires | Properties | |
|---|---|---|---|---|---|
| Cart - Viewed | ✅ view_cart | — | — | User views cart page | cartId, products, value, currency |
| Cart - Customer Info Entered | ✅ begin_checkout | ✅ InitiateCheckout | ✅ checkout_started | Customer info form submitted | currency, value |
| Cart - Shipping Info Added | ✅ add_shipping_info | — | — | Shipping method selected | currency, value, shippingMethod |
| Cart - Payment Info Entered | ✅ add_payment_info | ✅ AddPaymentInfo | — | Payment details entered | currency, value, paymentMethod |
| My Account - Quote Viewed | ✅ my_account_quote_viewed | — | — | User views saved quote | quoteId, value, currency, itemCount |
Provider Mapping (Complete)
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 | — | — | — | — |
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 |
|---|---|
add_to_cart | product_added_to_cart |
instant_quote_complete_fh | GA4 key event |
instant_quote_complete_sm | GA4 key event |
purchase | GA4 key event |
generate_lead | GA4 key event |
sign_up | GA4 key event — verify it’s marked as a key event + imported into Google Ads via Tools → Conversions |
Server-Side (Offline Conversions)
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.
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.
| 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.
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_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 |
|---|---|
checkout_customer_controller.js | Cart - Customer Info Entered, Cart - Shipping Info Added |
analytics_track_controller.js | Any event (declarative from ERB) |
QuotingApp.js | Instant Quote - Completed FH, Instant Quote - Completed SM, Cart - Product Added |
Declarative Tracking (Stimulus analytics-track Controller)
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, value, currency, itemCount |
my_orders/_confirmation.html.erb | Cart - Order Completed | orderId, revenue, tax, shipping, currency, products |
my_carts/add_item.turbo_stream.erb | Cart - Product Added | sku, name, category, price, quantity |
Usage example:
<div data-controller="analytics-track" data-analytics-track-on-load data-analytics-track-event-value="Lead - Form Submitted" data-analytics-track-properties-value="<%= { leadType: 'Contact Form', source: 'lead_form_thank_you' }.to_json %>"></div>Meta-Tag Trigger (for post-redirect events)
Section titled “Meta-Tag Trigger (for post-redirect events)”For events that need to fire on the redirect target of a server-side action — registration success, password reset confirmation, etc. — the form-submit page navigates away before any JS can fire, so declarative data-analytics-track-on-load won’t work. Use the meta-tag pattern instead:
# In the controller — set a flash on success:class Auth::CustomerRegistrationsController < Devise::RegistrationsController def create super do |resource| flash[:registration_completed] = true if resource.persisted? end endend<%# In a layout fragment rendered on every storefront page (currently `app/views/shared/_tracking_init.html.erb`): %><%= analytics_event_meta_tag(:registration_completed) if flash[:registration_completed] %>Analytics.boot() (in analytics.js) reads meta[name^="analytics:"] tags on every turbo:load and DOMContentLoaded, dispatches to the matching track* method via a registry, then removes the tag so re-renders / back-navigation don’t double-fire. A turbo:before-cache handler additionally strips any stale tags from the cached snapshot as a defensive measure.
Currently registered events (see Analytics.boot() source):
| Meta name | Dispatches to |
|---|---|
analytics:registration_completed | Analytics.trackRegistrationCompleted |
To add a new meta-tag-driven event, both register it in the boot() registry AND wire a track* method.
Turbo Drive / Morph compatibility
Section titled “Turbo Drive / Morph compatibility”Analytics.boot() is safe under all three navigation modes:
| Navigation | turbo:load fires? | Meta-tag handling |
|---|---|---|
| Initial page load | ✅ (via DOMContentLoaded + turbo:load) | Read once, then removed |
| Turbo Drive visit | ✅ | Read fresh on each navigation |
| Turbo Morph visit / refresh | ✅ | Morph adds/removes meta nodes based on server diff; boot() reads on turbo:load post-morph |
| Back-button (cache restore) | ✅ | Cached snapshot has the tag stripped by boot() and the defensive turbo:before-cache handler — no double-fire |
Usage Reference
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 | Requires explicit consent in GDPR/Quebec regions; default grant in US/non-QC Canada |
Global Privacy Control (GPC): When the browser’s GPC signal is detected, all marketing scripts (Facebook, Bing, Pinterest) are blocked regardless of consent state.
Cookie Management: Pinterest cookies (_pin_unauth, _pinterest*, _epik, _derived_epik) are auto-cleared when marketing consent is withdrawn.
See doc/features/TRACKING_SYSTEM.md for full consent flow documentation.
GA4 Funnel Analysis
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 CompletedQuote 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 |