Skip to content

Tracking & Analytics

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

LayerPurposeRespects Consent?Can Be Disabled?
Internal (Visit table)Order validation, troubleshooting, analyticsNoOnly for employees/bots
External (GA4, FB, Bing)Marketing, advertising, retargetingYesYes (cookie consent)
  1. Internal tracking (Visit table) is essential for:

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

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

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 │ │
│ └──────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘

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

HeaderRails Env KeyExampleUsed For
cf-ipcountryHTTP_CF_IPCOUNTRYUS, CA, GBCountry code
cf-regionHTTP_CF_REGIONCalifornia, QuebecFull region name
cf-region-codeHTTP_CF_REGION_CODECA, QC, TXRegion code (Quebec detection!)
cf-ipcityHTTP_CF_IPCITYSan FranciscoCity name
cf-postal-codeHTTP_CF_POSTAL_CODE94102, H2X 1Y4Postal/ZIP code
cf-iplatitudeHTTP_CF_IPLATITUDE37.7749Latitude
cf-iplongitudeHTTP_CF_IPLONGITUDE-122.4194Longitude
cf-timezoneHTTP_CF_TIMEZONEAmerica/Los_AngelesTimezone
BeforeAfter
IP recorded, geocoding queued to workerGeo data available immediately
Worker calls external Geocoder APINo external API call needed
Quebec detection: guessing from Accept-LanguageQuebec detection: exact cf-region-code == 'QC'
Async, eventual consistencySync, immediate consistency

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

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

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

  • WarmlyYours employee IPs
  • Bot/crawler requests
  • Non-www subdomains
app/services/tracking/tracker.rb
def track_visitor?(request: nil)
# Skip employees
return false if warmlyyours_ip?(request)
# Skip bots
return false if bot_request?(request)
# Skip non-www subdomains
return false unless request.subdomain&.match?(/^www/)
# Track everyone else (regardless of consent cookie)
true
end
FieldSourcePurpose
ipRequestVisitor IP
country, region, cityCloudflare headersLocation
latitude, longitudeCloudflare headersCoordinates
postal_codeCloudflare headersZIP/Postal
landing_pageURLEntry point
referrer, referring_domainHeadersTraffic source
utm_*Query paramsCampaign tracking
gclid, gbraid, wbraidQuery paramsGoogle Ads clicks
browser, os, device_typeUser-AgentDevice info

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

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

┌─────────────────────────────────────────────────────────────────┐
│ 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 │
│ │
└─────────────────────────────────────────────────────────────────┘
RegionModeBanner Shown?Default Tracking
United StatesOpt-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/CHOpt-in✅ Yes - must consent❌ Disabled until consent
Rest of WorldOpt-in✅ Yes - must consent❌ Disabled until consent

How it works:

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

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

For opt-in (Quebec, GDPR) regions:

  • Banner must be shown before any tracking
  • Users must explicitly accept/reject
  • window.tracVis = false until consent given
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 region
  • MODE_STRICTNESS: opt_out (1) < implied (2) < opt_in (3)
  • If current location is stricter AND stored consent was from permissive region → re-consent required

Why this matters:

  • Implied consent from US doesn’t satisfy GDPR’s explicit consent requirement
  • User must actively opt-in when visiting from GDPR/Quebec regions
  • Once they consent in a strict region, that consent is valid everywhere
app/javascript/services/analytics.js
class Analytics {
static get isEnabled() {
return window.tracVis === true
}
static get isMarketingEnabled() {
// GPC blocks marketing even if analytics is allowed
if (navigator.globalPrivacyControl === true) return false
return this.isEnabled
}
static track(eventName, properties = {}) {
if (!this.isEnabled) return // Respects consent
// Routes to GA4, Facebook, Bing, Google Ads
this._fireEvent(eventName, properties)
}
}

Terminal window
BASE_URL="https://www.warmlyyours.me:3000"
Terminal window
# US visitor from California - should track by default
curl -k -v "$BASE_URL/en-US" \
-H "CF-IPCountry: US" \
-H "CF-Region: California" \
-H "CF-Region-Code: CA" \
-H "CF-IPCity: San Francisco" \
-H "CF-Postal-Code: 94102" \
-H "CF-IPLatitude: 37.7749" \
-H "CF-IPLongitude: -122.4194" \
-H "CF-Timezone: America/Los_Angeles" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
# Expected:
# - Visit record created with geo data
# - window.tracVis = true
# - window.visitorCountry = 'US'
# - window.visitorRegion = 'CA'
Terminal window
# Quebec visitor - should NOT track until consent given
curl -k -v "$BASE_URL/en-CA" \
-H "CF-IPCountry: CA" \
-H "CF-Region: Quebec" \
-H "CF-Region-Code: QC" \
-H "CF-IPCity: Montreal" \
-H "CF-Postal-Code: H2X 1Y4" \
-H "CF-IPLatitude: 45.5017" \
-H "CF-IPLongitude: -73.5673" \
-H "CF-Timezone: America/Toronto" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
# Expected:
# - Visit record created (internal tracking always happens)
# - window.tracVis = false (external tracking blocked until consent)
# - window.visitorCountry = 'CA'
# - window.visitorRegion = 'QC'
# - Cookie consent banner should show
Terminal window
# 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'
Terminal window
# German visitor - should NOT track until consent given
curl -k -v "$BASE_URL/en-US" \
-H "CF-IPCountry: DE" \
-H "CF-Region: Bavaria" \
-H "CF-Region-Code: BY" \
-H "CF-IPCity: Munich" \
-H "CF-Postal-Code: 80331" \
-H "CF-IPLatitude: 48.1351" \
-H "CF-IPLongitude: 11.5820" \
-H "CF-Timezone: Europe/Berlin" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
# Expected:
# - Visit record created (internal tracking)
# - window.tracVis = false (external tracking blocked)
# - window.isGdprCountry = true
# - Cookie consent banner should show
Terminal window
# Request without Cloudflare headers - should trigger geocoder worker
curl -k -v "$BASE_URL/en-US" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
# Expected:
# - Visit record created with IP only
# - VisitGeocoderWorker enqueued (fallback)
# - window.visitorCountry = 'US' (default fallback)
Terminal window
# Bot user agent - should NOT create visit record
curl -k -v "$BASE_URL/en-US" \
-H "CF-IPCountry: US" \
-H "User-Agent: Googlebot/2.1 (+http://www.google.com/bot.html)"
# Expected:
# - No visit record created
# - Page still renders normally

Test 7: WarmlyYours Employee IP (Should Not Track)

Section titled “Test 7: WarmlyYours Employee IP (Should Not Track)”
Terminal window
# From WarmlyYours office IP - should NOT create visit record
# (Replace with actual WarmlyYours IP range)
curl -k -v "$BASE_URL/en-US" \
-H "X-Forwarded-For: 216.251.32.1" \
-H "CF-IPCountry: US" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
# Expected:
# - No visit record created
# - track_visitor? returns false
Terminal window
# GPC header set - marketing pixels should be blocked
curl -k -v "$BASE_URL/en-US" \
-H "CF-IPCountry: US" \
-H "Sec-GPC: 1" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
# Expected:
# - Visit record created (internal tracking)
# - window.tracVis = true (analytics OK)
# - Analytics.isMarketingEnabled = false (ads blocked)

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:

Terminal window
# Basic: Enable tracking system
TRACK_VISITOR=y bin/dev
FlagValuesDefaultDescription
TRACK_VISITORy, nnMaster switch - enables the entire tracking system
TRACK_VISITOR_PROFILESee table belowUS-ILSimulates Cloudflare geo headers for a region
CLARITY_ENABLEDtrue, falsefalse (dev)Enable Microsoft Clarity session recording
CF_IPCOUNTRYISO country codeOverride: Country code (US, CA, DE, etc.)
CF_REGION_CODERegion codeOverride: Region code (QC, ON, TX, etc.)
CF_REGIONRegion nameOverride: Full region name
CF_IPCITYCity nameOverride: City name
CF_POSTAL_CODEPostal/ZIPOverride: Postal code
CF_IPLATITUDEDecimalOverride: Latitude
CF_IPLONGITUDEDecimalOverride: Longitude
CF_TIMEZONEIANA timezoneOverride: Timezone

Region Coding Format:

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

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

ProfileStateCityUsage
US-ILIllinoisLake ZurichDefault - WarmlyYours HQ location
US-CACaliforniaSan Francisco
US-TXTexasAustin
US-NYNew YorkNew York
US-FLFloridaMiami
ProfileProvinceCityConsent Model
CA-QCQuebecMontrealOpt-in (Law 25)
CA-ONOntarioTorontoImplied consent
CA-ABAlbertaCalgaryImplied consent
CA-BCBritish ColumbiaVancouverImplied consent
ProfileCountryCity
DEGermanyMunich
FRFranceParis
GBUnited KingdomLondon
ESSpainMadrid
ITItalyRome
NLNetherlandsAmsterdam
CHSwitzerlandZurich
ProfileCountryCityPrivacy Law
BRBrazilSão PauloLGPD
AUAustraliaSydneyPrivacy Act
JPJapanTokyoAPPI
MXMexicoMexico CityLFPDPPP
ProfileDescription
UNKNOWNCountry code XX - tests fallback behavior
NONENo Cloudflare headers - tests VisitGeocoderWorker fallback
Terminal window
# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 1: Basic tracking (defaults to Illinois - WarmlyYours HQ)
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y bin/dev
# Same as: TRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-IL bin/dev
# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 2: Test different US states
# All US states use opt-out consent model (tracks by default)
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-CA bin/dev # California
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-FL bin/dev # Florida
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-TX bin/dev # Texas
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=US-NY bin/dev # New York
# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 3: Test Quebec Law 25 (strict opt-in required)
# Cookie consent banner MUST appear before any tracking
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=CA-QC bin/dev
# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 4: Test GDPR (EU visitors)
# Cookie consent banner MUST appear before any tracking
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=DE bin/dev # Germany
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=FR bin/dev # France
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=GB bin/dev # United Kingdom
# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 5: Test Canadian implied consent (non-Quebec)
# Tracks by default, but consent banner available via footer
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=CA-ON bin/dev
# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 6: Test with Microsoft Clarity enabled (normally disabled in dev)
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y CLARITY_ENABLED=true bin/dev
# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 7: Test geocoder worker fallback (no Cloudflare headers)
# ─────────────────────────────────────────────────────────────────────────────
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=NONE bin/dev
# ─────────────────────────────────────────────────────────────────────────────
# SCENARIO 8: Custom location with CF_ overrides (takes precedence over profile)
# ─────────────────────────────────────────────────────────────────────────────
# Just country and region:
CF_IPCOUNTRY=CA CF_REGION_CODE=QC TRACK_VISITOR=y bin/dev
# Full custom location:
TRACK_VISITOR=y \
CF_IPCOUNTRY=US \
CF_REGION=Florida \
CF_REGION_CODE=FL \
CF_IPCITY=Orlando \
CF_POSTAL_CODE=32801 \
CF_IPLATITUDE=28.5383 \
CF_IPLONGITUDE=-81.3792 \
CF_TIMEZONE=America/New_York \
bin/dev

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.

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

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

Terminal window
# Just set country and region code
CF_IPCOUNTRY=CA CF_REGION_CODE=QC TRACK_VISITOR=y bin/dev
# Full custom location
CF_IPCOUNTRY=US \
CF_REGION=Florida \
CF_REGION_CODE=FL \
CF_IPCITY=Miami \
CF_POSTAL_CODE=33101 \
CF_IPLATITUDE=25.7617 \
CF_IPLONGITUDE=-80.1918 \
CF_TIMEZONE=America/New_York \
TRACK_VISITOR=y bin/dev
  1. Start server with desired profile:

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

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

  4. Check console for injected headers:

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

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

  7. Accept cookies, then verify:

    window.tracVis // Should now be true
// In browser console:
window.trackingState
// Returns: { country, region, hasGPC, hasConsent, isOptOutRegion, isOptInRegion, tracVis }
window.Analytics._debug = true
// Enables verbose logging for all Analytics calls
ConsentManager.debug()
// Shows current consent state
# In rails console:
Visit.order(created_at: :desc).limit(5).pluck(:id, :country, :region, :city, :ip)

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

// 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
}
}
ApproachProsCons
Visit fieldsQuick lookupOnly stores latest state
Visit eventsFull history, audit trailNeed 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

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

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

Microsoft Clarity provides session recordings and heatmaps for UX analysis.

config/initializers/microsoft_clarity.rb
module MicrosoftClarity
def self.enabled?
return false if Rails.env.development? || Rails.env.test?
return false if ENV['CLARITY_ENABLED'] == 'false'
true
end
def self.project_id
ENV.fetch('CLARITY_PROJECT_ID', 'nym19w4utx')
end
end
EnvironmentDefaultCan Override?
ProductionEnabledCLARITY_ENABLED=false to disable
DevelopmentDisabledCLARITY_ENABLED=true to enable
TestDisabledCannot enable

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

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

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

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>

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
} %>
  1. On page load: If data-analytics-track-on-load is present, fires the event when the controller connects
  2. On click: Binds to the track action and fires when clicked
  3. Turbo-safe: Automatically waits for window.Analytics to be available, solving timing issues with Turbo Drive navigation
app/javascript/controllers/analytics_track_controller.js
ApproachTurbo-SafeCSP-SafeMaintainable
Inline <script>⚠️ Needs nonce
onclick attribute⚠️
Stimulus controller

FilePurpose
app/services/tracking/tracker.rbCore tracking logic, Cloudflare header extraction
app/views/shared/_tracking_init.html.erbEarly JS setup, consent resolution
app/javascript/services/analytics.jsAnalytics wrapper (GA4, FB, Bing)
app/javascript/services/consent_manager.jsCookie consent UI and logic
app/javascript/controllers/consent_controller.jsStimulus controller for consent UI
app/javascript/controllers/analytics_track_controller.jsStimulus controller for declarative event tracking
app/javascript/utils/debug.jsDebug logging utility (auto-enabled in dev)
app/workers/visit_geocoder_worker.rbFallback geocoding worker
app/models/visit.rbVisit record model
app/models/visit_event.rbVisit event model ($view, $consent, etc.)
config/initializers/microsoft_clarity.rbClarity on/off configuration
lib/middleware/cloudflare_geo_simulator.rbDev middleware for simulating geo

RegionLawModelConsent Required?
USCCPA/CPRAOpt-outNo (but must provide opt-out)
US (CA)CCPA/CPRAOpt-outNo (but honor GPC signal)
Canada (QC)Law 25Opt-inYes, before tracking
Canada (rest)PIPEDAImpliedNo (notice sufficient)
EU/EEAGDPROpt-inYes, before tracking
UKUK GDPROpt-inYes, before tracking
SwitzerlandnFADPOpt-inYes, before tracking

  1. Check window.tracVis in browser console
  2. Check window.trackingState for consent details
  3. Verify cc_cookie exists and has analytics category
  4. Check if TRACK_VISITOR=y is set (required in development)
  1. Check if IP is in WarmlyYours range (bypassed with TRACK_VISITOR=y)
  2. Check if User-Agent is detected as bot
  3. Check Rails logs for [track_visitor] messages
  4. Ensure you’re on www subdomain (crm subdomain doesn’t track)
  1. Check if Cloudflare headers are present (view in Rails logs)
  2. If headers missing, check if VisitGeocoderWorker was queued
  3. Check Sidekiq tracker queue for pending jobs
  4. In dev, use TRACK_VISITOR_PROFILE=US-IL to simulate headers
  1. Verify CF-Region-Code header is QC
  2. Check window.visitorRegion in browser console
  3. If header missing, falls back to Accept-Language detection
  4. Test with TRACK_VISITOR_PROFILE=CA-QC
  1. Check browser network tab for POST /globals/consent
  2. Verify session[:visit_id] exists (check Rails logs)
  3. Check visit_events table for $consent events:
    Visit.last.visit_events.where(name: '$consent')
  1. Clarity is disabled by default in development
  2. CORS errors are expected - Clarity doesn’t work on localhost
  3. To test Clarity locally: CLARITY_ENABLED=true bin/dev
  1. Clear cookies and try in incognito
  2. Check window.trackingState.isOptInRegion - banner only auto-shows for opt-in regions
  3. For US visitors, use footer “Cookie Preferences” link
  4. Test opt-in region: TRACK_VISITOR_PROFILE=CA-QC or DE
  1. Check that data-analytics-track-on-load is present (for page load events)
  2. Check that data-action="click->analytics-track#track" is present (for click events)
  3. Verify data-analytics-track-event-value is set
  4. Check browser console for [analytics-track] logs (auto-enabled in dev)
  5. Verify window.Analytics exists (bundle must be loaded)
// Browser console - Tracking state
window.tracVis // Current tracking state
window.trackingState // Full tracking debug info
window.visitorCountry // Detected country
window.visitorRegion // Detected region (e.g., QC)
CookieConsent.getUserPreferences() // Current cookie preferences
// Debug logging (auto-enabled in development)
debug.isEnabled // Check if debug logging is on
debug.enable() // Force enable debug logging
debug.disable() // Force disable debug logging
// Analytics-specific debug (uses global debug utility)
Analytics._debug // Same as debug.isEnabled
Analytics._debug = true // Same as debug.enable()

See DEBUG_LOGGING.md for full debug utility documentation.


WarmlyYours Analytics & Conversion Tracking

Section titled “WarmlyYours Analytics & Conversion Tracking”

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


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

  1. Client-side — JavaScript events fired in the browser via the Analytics wrapper
  2. Server-side — API calls from Rails to Pinterest Conversions API and Google Ads offline conversions
CategoryDescriptionFires To
Key ConversionsHigh-value actions tracked across all platformsGA4, Facebook, Bing, Pinterest
Funnel EventsDrop-off analysis for cart and quote flowsGA4 + Pinterest (subset)
PlatformPurposeAccount / Tag IDConsent CategoryChannel
Google Analytics 4 (GA4)Analytics & ReportingAnalyticsClient
Facebook/Meta PixelConversion Tracking261770050896557MarketingClient
Microsoft Bing UETConversion TrackingMarketingClient
Pinterest TagConversion Tracking2614464771308MarketingClient
Pinterest Conversions APIServer-to-Server ConversionsAd Account: 549755854137N/A (server)Server
Google AdsOffline Conversion ReportingAW-1061033593N/A (server)Server
OpenAI Ads (ChatGPT) PixelConversion TrackingPixel: PkMqGWwheG3kgF4gJgzzbpMarketingClient
OpenAI Ads Conversions APIServer-to-Server ConversionsPixel: PkMqGWwheG3kgF4gJgzzbpN/A (server)Server

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


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

Section titled “Key Conversions (GA4, Facebook, Bing, Pinterest, OpenAI Ads)”
Event NameGA4FacebookBingPinterestOpenAI AdsWhen It Fires
Cart - Product Addedadd_to_cartAddToCartAddToCartitems_addedUser adds item to cart
Cart - Order CompletedpurchasePurchasecheckoutorder_created + CAPIPurchase confirmation
Lead - Form Submittedgenerate_leadLeadleadlead_created + CAPIForm submitted
Account - Registration Completedsign_upCompleteRegistrationsign_upSignupregistration_completedDevise sign-up persisted
Instant Quote - Completed FHinstant_quote_complete_fh✅ custom✅ custom✅ customFloor heating (Indoor) quote completes
Instant Quote - Completed SMinstant_quote_complete_sm✅ custom✅ custom✅ customSnow melting (Outdoor) quote completes

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

Section titled “Funnel Events (GA4 + Pinterest Subset + OpenAI Ads Subset)”
Event NameGA4PinterestOpenAI AdsWhen It FiresProperties
Cart - Viewedview_cartUser views cart pagecartId, products, value, currency
Cart - Customer Info Enteredbegin_checkoutInitiateCheckoutcheckout_startedCustomer info form submittedcurrency, value
Cart - Shipping Info Addedadd_shipping_infoShipping method selectedcurrency, value, shippingMethod
Cart - Payment Info Enteredadd_payment_infoAddPaymentInfoPayment details enteredcurrency, value, paymentMethod
My Account - Quote Viewedmy_account_quote_viewedUser views saved quotequoteId, value, currency, itemCount

Analytics EventGA4 EventFacebook PixelBing UETPinterest TagOpenAI Ads
Cart - Product Addedadd_to_cartAddToCartadd_to_cartAddToCartitems_added
Cart - Order CompletedpurchasePurchasepurchasecheckoutorder_created (+ CAPI)
Lead - Form Submittedgenerate_leadLeadsubmit_lead_formleadlead_created (+ CAPI)
Account - Registration Completedsign_upCompleteRegistrationsign_upSignupregistration_completed
Instant Quote - Completed FHinstant_quote_complete_fhInstantQuoteCompleteFHinstant_quote_complete_fhcustomcustom
Instant Quote - Completed SMinstant_quote_complete_smInstantQuoteCompleteSMinstant_quote_complete_smcustomcustom
Cart - Viewedview_cart
Cart - Customer Info Enteredbegin_checkoutInitiateCheckoutcheckout_started
Cart - Shipping Info Addedadd_shipping_info
Cart - Payment Info Enteredadd_payment_infoAddPaymentInfo
My Account - Quote Viewedmy_account_quote_viewed

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

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

Event NameWhenSystem Type
$quote_completed_fhIndoor (floor heating) quote succeedsfloor-heating
$quote_completed_smOutdoor (snow melting) quote succeedssnow-melting

Properties stored:

PropertySource
share_keyMD5 hash of quote configuration
system_typefloor-heating or snow-melting (derived from environment)
environmentIndoor or Outdoor
room_typee.g. bathroom, driveway
floor_typee.g. tile-marble-or-stone, asphalt
heated_areaSquare footage
currencyUSD or CAD
valueLowest price from BOM output
urlReferring or original URL

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

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


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

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.

SettingValue
Tag ID2614464771308
Enhanced MatchHashed email via window.current_user.email_hash
GPC ComplianceBlocked 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)”
SettingValue
API EndpointPOST https://api.pinterest.com/v5/ad_accounts/549755854137/events
Ad Account ID549755854137 (WarmlyYours Radiant Heating)
AuthBearer token in Rails credentials (pinterest.conversion_token)
Action Sourceweb
Rate Limit5,000 calls/min per ad account

Events sent:

TriggerPinterest EventSource Data
Order invoicedcheckoutInvoice total, tracking email, Visit IP/UA
Opportunity qualifiedlead20% 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.

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

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

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


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

GA4 EventGoogle Ads Import
add_to_cartproduct_added_to_cart
instant_quote_complete_fhGA4 key event
instant_quote_complete_smGA4 key event
purchaseGA4 key event
generate_leadGA4 key event
sign_upGA4 key event — verify it’s marked as a key event + imported into Google Ads via Tools → Conversions

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

ServiceWorkerIdentifier
Invoicing::GoogleConversionReporterGoogleOfflineConversionWorkerGCLID, GBRAID, or WBRAID from Visit
Same serviceGoogleOfflineConversionRetryWorkerRetries failed conversions within 7-day window

Metadata stored in orders.google_conversion_meta JSONB column.


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.

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.

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.

ServiceWorkerTrigger
OpenaiAds::ConversionReporterOpenaiAdsConversionWorkerOrder#after_create, Opportunity state-machine

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

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

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

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

Conversion event registration in OpenAI Ads dashboard

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:

EventBase Event (registered)Data Source
order_createdorder_createdWarmlyYours.com
lead_createdlead_createdWarmlyYours.com
items_addeditems_addedWarmlyYours.com
checkout_startedcheckout_startedWarmlyYours.com
registration_completedregistration_completedWarmlyYours.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.

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.


There are three ways client-side events are fired:

MethodUse CaseLocation
Server-side track_event()Controller actions before redirectsRuby controllers
Client-side Analytics.track()JavaScript/Stimulus interactionsJS files
Stimulus analytics-track controllerDeclarative tracking from ERB templatesHTML attributes

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.

ControllerEventProperties
auth/authentications_controller.rbLead - Form Submitted{ leadType: 'Registration', source: 'oauth_<provider>' }
auth/customer_sessions_controller.rbLead - Form Submitted{ leadType: 'Registration', source: 'customer_registration' }
my_rooms_controller.rbMy Account - Quote Viewed{ quoteId, roomType }
my_rooms_controller.rbCart - 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.

FileEvents Fired
checkout_customer_controller.jsCart - Customer Info Entered, Cart - Shipping Info Added
analytics_track_controller.jsAny event (declarative from ERB)
QuotingApp.jsInstant 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:

FileLead Type
www/leads/lead_form_thank_you.html.erbContact Form
www/leads/lead_form_thank_you_es.html.erbContact Form (Spanish)
www/leads/lead_form_modal_thank_you.turbo_stream.erbContact Form (Modal)
www/leads/tradepro_lead_form_thank_you.html.erbTrade Pro Contact
contact_forms/enroll_thank_you.html.erbDealer Program Enrollment
contact_forms/request_literature_thank_you.html.erbRequest Literature
contact_forms/product_sample_thank_you.html.erbProduct Sample
contact_forms/upload_photo_thank_you.html.erbUpload Photo
contact_forms/personal_connect_thank_you.html.erbPersonal Connect
contact_forms/custom_product_training_thank_you.html.erbCustom Product Training
contact_forms/roof_gutter_deicing_quote_thank_you.html.erbRoof & Gutter Deicing Quote
party_notifier/signup_notification.html.erbMy Projects Signup

Other Declarative Events:

FileEventProperties
my_quotes/show.html.erbMy Account - Quote ViewedquoteId, value, currency, itemCount
my_orders/_confirmation.html.erbCart - Order CompletedorderId, revenue, tax, shipping, currency, products
my_carts/add_item.turbo_stream.erbCart - Product Addedsku, 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
end
end
<%# In a layout fragment rendered on every storefront page (currently
`app/views/shared/_tracking_init.html.erb`): %>
<%= analytics_event_meta_tag(:registration_completed) if flash[:registration_completed] %>

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

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

Meta nameDispatches to
analytics:registration_completedAnalytics.trackRegistrationCompleted

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

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

Navigationturbo:load fires?Meta-tag handling
Initial page load✅ (via DOMContentLoaded + turbo:load)Read once, then removed
Turbo Drive visitRead fresh on each navigation
Turbo Morph visit / refreshMorph 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

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 }
]
})
track_event('Lead - Form Submitted', { leadType: 'New Form Type', source: 'new_page' })
redirect_to some_path

Consent CategoryPlatformsBehavior
AnalyticsGA4, Microsoft ClarityDefaults to granted; revocable
MarketingFacebook, Bing, Pinterest TagRequires 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.


Cart - Product Added → Cart - Viewed → Cart - Customer Info Entered →
Cart - Shipping Info Added → Cart - Payment Info Entered → Cart - Order Completed
Instant Quote - Completed FH/SM → My Account - Quote Viewed →
Cart - Product Added → ... → Cart - Order Completed

  • 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.


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

  • GCLID / GBRAID / WBRAID values and their source (order visit, quote visit, customer visit)
  • Reporting status badge (reported / skipped / failed)
  • Report delay (days between click and conversion report)
  • Email used, conversion date/time, conversion value
  • “Resend conversion to Google” button
  • 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

FilePurpose
app/javascript/services/analytics.jsMain Analytics wrapper — routes events to all providers
app/javascript/services/consent_manager.jsLoads tracking scripts based on consent; fires PageView
app/javascript/controllers/analytics_track_controller.jsStimulus controller for declarative tracking
app/javascript/controllers/checkout_customer_controller.jsCheckout funnel tracking
client/js/www/quotes/QuotingApp.jsQuote builder tracking
client/js/www/primeval.jsFires server-queued events from globals.json
FilePurpose
app/concerns/controllers/analytics_events.rbServer-side track_event() concern
app/controllers/www/quote_builder_controller.rbFirst-party $quote_completed_fh/sm visit_events

Server-Side (Google Ads Offline Conversions)

Section titled “Server-Side (Google Ads Offline Conversions)”
FilePurpose
app/services/invoicing/google_conversion_reporter.rbGoogle Ads offline conversion API client
app/workers/google_offline_conversion_worker.rbEnqueues single order conversion
app/workers/google_offline_conversion_retry_worker.rbRetries failed conversions (7-day window)
FilePurpose
app/services/pinterest/conversion_reporter.rbPinterest Conversions API v5 client
app/workers/pinterest_conversion_worker.rbEnqueues order or opportunity conversion
ColumnTablePurpose
google_conversion_metaordersJSONB store for Google Ads conversion metadata
pinterest_conversion_metaordersJSONB store for Pinterest conversion metadata
pinterest_conversion_metaopportunitiesJSONB store for Pinterest lead conversion metadata
FileTestsCoverage
test/services/pinterest/conversion_reporter_test.rb11Service success/failure, idempotency, API errors, email hashing
test/workers/pinterest_conversion_worker_test.rb5Worker delegation, missing records, unknown types
test/services/pinterest/analytics_events_test.rb12Client-side JS event verification, deduplication, consent gating

┌─────────────────────────────────────────────┐
│ 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│
└─────────────────────────────────────────────┘

Analytics.enableDebug()
Analytics.track('Cart - Product Added', { sku: 'TEST' })
Analytics.disableDebug()
  1. GA4 → Reports → Realtime
  2. Trigger an event, watch for it in realtime view
  1. Install Facebook Pixel Helper Chrome extension
  2. Trigger events and verify in the extension popup
  1. Install UET Tag Helper Chrome extension
  1. Use the Pinterest Tag Helper Chrome extension
  2. Verify in Pinterest Ads Manager → Events → Event History
  1. In CRM, go to an invoiced order’s Marketing tab
  2. Click “Send conversion to Pinterest”
  3. Review pinterest_conversion_meta JSON panel for stored result
  4. Verify in Pinterest Ads Manager → Events → Event History (source: “Conversions API”)
Terminal window
# Run all Pinterest-related tests
mise exec -- bin/rails test test/services/pinterest/ test/workers/pinterest_conversion_worker_test.rb

track_event('Lead - Form Submitted', { leadType: 'New Form Type', source: 'new_page' })
redirect_to some_path
if (window.Analytics) {
window.Analytics.track('Cart - Product Added', { sku: 'ABC123', price: 29.99 })
}
<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>
  1. Add a method to Pinterest::ConversionReporter (see send_order_conversion / send_opportunity_conversion)
  2. Call it from a worker or directly from a service
  3. Store result in the record’s pinterest_conversion_meta JSONB column

DocumentPurpose
doc/features/TRACKING_SYSTEM.mdConsent flow, visit table, internal tracking architecture