Class: Facebook::ConversionReporter
- Inherits:
-
BaseService
- Object
- BaseService
- Facebook::ConversionReporter
- Defined in:
- app/services/facebook/conversion_reporter.rb
Overview
Facebook/Meta Conversions API (CAPI) — server-to-server event reporting.
Mirrors OpenaiAds::ConversionReporter, Pinterest::ConversionReporter,
and Invoicing::GoogleConversionReporter:
- called from FacebookConversionWorker
- persists result metadata to the record's
facebook_conversion_meta
JSONB column (used as the idempotency key and for sibling-opp dedup) - reuses the shared
tracking_event_idso the browser pixel (fbq)
and the CAPI call dedupe correctly via Meta'sevent_idmatching - routes PII through Tracking::Hashing (SHA-256, lowercased + trimmed,
gmail dot-stripping for higher match rates)
Canary rollout via test_event_code
First deploy ships with Heatwave::Configuration.fetch(:facebook, :test_event_code) set to a Meta-issued test code (visible under Events
Manager → Test Events). Meta routes events with this code to the test
tab and doesn't count them toward attribution — we use this to confirm
our user_data shape and event fields are accepted before flipping to
real sends. Set the code to nil/blank in production once verified.
Constant Summary collapse
- EVENT_PURCHASE =
Meta standard event names — see
https://developers.facebook.com/docs/meta-pixel/reference#standard-events 'Purchase'- EVENT_LEAD =
Event lead.
'Lead'
Instance Attribute Summary
Attributes inherited from BaseService
Instance Method Summary collapse
-
#send_events(events) ⇒ Object
── API Communication ────────────────────────────────────────────────.
-
#send_opportunity_conversion(opportunity) ⇒ Object
── Opportunity Conversions ──────────────────────────────────────────.
-
#send_order_conversion(order) ⇒ Object
── Order Conversions ────────────────────────────────────────────────.
-
#sibling_opportunity_already_reported(opportunity) ⇒ Opportunity?
When a rep creates a CRM opp for a customer who already had an open quote-builder / online opp, both opps can transition to qualified and both would fire send_opportunity_conversion against the same Facebook session — phantom lead volume that biases Meta's smart bidding.
Methods inherited from BaseService
#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger
Constructor Details
This class inherits a constructor from BaseService
Instance Method Details
#send_events(events) ⇒ Object
── API Communication ────────────────────────────────────────────────
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'app/services/facebook/conversion_reporter.rb', line 156 def send_events(events) result = Facebook::ApiClient.new.send_events( pixel_id: pixel_id, token: capi_token, events: events, test_event_code: test_event_code ) if result[:status] == :failed if result[:transient] # Transient Meta-side blip (HTTP 5xx, or Graph error code 1/2 — # "An unknown error has occurred."). The record keeps result: 'failed' # with reported_at: nil, so ConversionRetrySweepWorker re-enqueues it # within its 7-day window. Don't page AppSignal for each attempt — # these are not our defect (AppSignal #5248; mirrors the OpenAI Ads # transient-timeout contract in #5214). Rails.logger.warn( "Facebook::ConversionReporter: transient Meta error -- #{result[:error]} " \ "(code=#{result[:error_code]} http=#{result[:http_status]}); will retry via sweep" ) else ErrorReporting.error("Facebook::ConversionReporter: #{result[:error]}", { http_status: result[:http_status], error_code: result[:error_code], error_subcode: result[:error_subcode], fbtrace_id: result[:fbtrace_id], event_count: events.size }) end end result end |
#send_opportunity_conversion(opportunity) ⇒ Object
── Opportunity Conversions ──────────────────────────────────────────
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'app/services/facebook/conversion_reporter.rb', line 84 def send_opportunity_conversion(opportunity) return { success: false, reason: :not_a_sales_opportunity } unless opportunity.sales_opportunity? return { success: false, reason: :credentials_missing } unless credentials_present? if opportunity..present? Rails.logger.warn "Facebook::ConversionReporter: Opportunity #{opportunity.reference_number} already reported" return { success: false, reason: :already_reported, meta: opportunity. } end if (sibling = sibling_opportunity_already_reported(opportunity)) Rails.logger.info "Facebook::ConversionReporter: Sibling opp #{sibling.reference_number} already reported; skipping #{opportunity.reference_number}" return { success: false, reason: :sibling_already_reported, sibling_opportunity_id: sibling.id } end conversion_date_time = opportunity.updated_at || opportunity.created_at fractional_value = (opportunity.value || 0) * Setting.ad_conversion_opportunity_rate # PPC dial — /crm/settings/ad-conversion-values email = opportunity.emails.pick(:detail) || opportunity.customer&.email visit = find_visit(opportunity) event_id = opportunity.tracking_event_id.presence || SecureRandom.uuid event = build_event( event_name: EVENT_LEAD, event_time: conversion_date_time, event_id: event_id, source_url: visit&.landing_page, external_id: opportunity.customer&.id&.to_s, email: email, ip: visit&.ip, user_agent: visit&.user_agent, custom_data: { currency: 'USD', value: fractional_value.to_f.round(2), content_name: opportunity.reference_number }.compact ) result = send_events([event]) (opportunity, result, event_id, EVENT_LEAD, conversion_date_time, email) end |
#send_order_conversion(order) ⇒ Object
── Order Conversions ────────────────────────────────────────────────
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'app/services/facebook/conversion_reporter.rb', line 36 def send_order_conversion(order) return { success: false, reason: :not_a_sales_order } unless order.is_sales_order? return { success: false, reason: :credentials_missing } unless credentials_present? if order..present? Rails.logger.warn "Facebook::ConversionReporter: Order #{order.reference_number} already reported" return { success: false, reason: :already_reported, meta: order. } end conversion_date_time = order.invoices.first&.created_at unless conversion_date_time Rails.logger.warn "Facebook::ConversionReporter: No invoice for order #{order.reference_number}" return { success: false, reason: :no_invoice } end total = order.invoices.sum(&:revenue_consolidated) email = order.tracking_email&.first || order.order_emails.first visit = find_visit(order) # Shared with the browser fbq pixel — see PR description for analytics.js # Purchase eventID wiring. Falls back to a fresh UUID for legacy orders # that predate the column (those events were already pixel-only with # their own ids, no dedup loss). event_id = order.tracking_event_id.presence || SecureRandom.uuid event = build_event( event_name: EVENT_PURCHASE, event_time: conversion_date_time, event_id: event_id, source_url: visit&.landing_page, external_id: order.customer&.id&.to_s, email: email, ip: visit&.ip, user_agent: visit&.user_agent, custom_data: { currency: 'USD', value: total.to_f.round(2), order_id: order.reference_number, content_type: 'product', content_ids: build_content_ids(order.line_items), num_items: order.line_items.size }.compact ) result = send_events([event]) (order, result, event_id, EVENT_PURCHASE, conversion_date_time, email) end |
#sibling_opportunity_already_reported(opportunity) ⇒ Opportunity?
When a rep creates a CRM opp for a customer who already had an open
quote-builder / online opp, both opps can transition to qualified and
both would fire send_opportunity_conversion against the same Facebook
session — phantom lead volume that biases Meta's smart bidding.
Mirrors Invoicing::GoogleConversionReporter#sibling_opportunity_already_reported,
Pinterest::ConversionReporter#sibling_opportunity_already_reported,
and OpenaiAds::ConversionReporter#sibling_opportunity_already_reported.
Public so tests (and external auditing) can probe it without .send.
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'app/services/facebook/conversion_reporter.rb', line 138 def sibling_opportunity_already_reported(opportunity) customer_id = opportunity.customer_id return nil if customer_id.nil? cluster_ids = [opportunity.parent_id, opportunity.merged_into_id, opportunity.id].compact Opportunity .where(customer_id: customer_id) .where.not(id: opportunity.id) .where("facebook_conversion_meta->>'reported_at' IS NOT NULL") .where( 'opportunities.id IN (?) OR opportunities.parent_id IN (?) OR opportunities.merged_into_id IN (?)', cluster_ids, cluster_ids, cluster_ids ) .first end |