Class: OpenaiAds::ConversionReporter
- Inherits:
-
BaseService
- Object
- BaseService
- OpenaiAds::ConversionReporter
- Defined in:
- app/services/openai_ads/conversion_reporter.rb
Overview
OpenAI Ads (ChatGPT) Conversions API — server-to-server event reporting.
Mirrors Pinterest::ConversionReporter and Invoicing::GoogleConversionReporter:
- called from OpenaiAdsConversionWorker
- persists result metadata to the record's
openai_ads_conversion_meta
JSONB column (used as the idempotency key and for sibling-opp dedup) - reuses the shared
tracking_event_idso the browser pixel and the
CAPI call dedupe correctly on OpenAI's side - routes PII through Tracking::Hashing (SHA-256, lowercased + trimmed,
gmail dot-stripping for higher match rates)
Canary rollout via validate_only
First deploy ships with Heatwave::Configuration.fetch(:openai_ads, :validate_only)
set to true. OpenAI validates the payload schema but doesn't count events
toward attribution — we use this to confirm our user object shape and
event fields are accepted before flipping to real sends.
Constant Summary collapse
- EVENT_ORDER_CREATED =
Standard order conversion event name.
'order_created'- EVENT_LEAD_CREATED =
Standard lead conversion event name.
'lead_created'
Instance Attribute Summary
Attributes inherited from BaseService
Instance Method Summary collapse
-
#send_events(events) ⇒ Hash
Sends one or more conversion events to OpenAI Ads.
-
#send_opportunity_conversion(opportunity) ⇒ Hash
Reports an attributed sales opportunity to the OpenAI Ads Conversions API.
-
#send_order_conversion(order) ⇒ Hash
Reports an attributed sales order to the OpenAI Ads Conversions API.
-
#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 OpenAI session.
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) ⇒ Hash
Sends one or more conversion events to OpenAI Ads.
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'app/services/openai_ads/conversion_reporter.rb', line 214 def send_events(events) result = OpenaiAds::ApiClient.new.send_events( pixel_id: pixel_id, token: capi_token, events: events, validate_only: validate_only? ) if result[:status] == :failed if result[:timeout] # Transient timeout to an intermittently-slow endpoint. The record # keeps result: 'failed', so ConversionRetrySweepWorker re-enqueues it # within its 7-day window — don't page AppSignal for each attempt # (#5214; the sweep's own contract is to keep this noise out of AppSignal). Rails.logger.warn("OpenaiAds::ConversionReporter: #{result[:error]} (will retry via sweep)") else ErrorReporting.error("OpenaiAds::ConversionReporter: #{result[:error]}", { http_status: result[:http_status], event_count: events.size }) end end result end |
#send_opportunity_conversion(opportunity) ⇒ Hash
Reports an attributed sales opportunity to the OpenAI Ads Conversions API.
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'app/services/openai_ads/conversion_reporter.rb', line 103 def send_opportunity_conversion(opportunity) return { success: false, reason: :not_a_sales_opportunity } unless opportunity.sales_opportunity? if opportunity..present? Rails.logger.warn "OpenaiAds::ConversionReporter: Opportunity #{opportunity.reference_number} already reported" return { success: false, reason: :already_reported, meta: opportunity. } end if already_validate_only_ok?(opportunity.) Rails.logger.info "OpenaiAds::ConversionReporter: Opportunity #{opportunity.reference_number} already validated (canary); skipping re-send" return { success: false, reason: :already_validate_only_ok, meta: opportunity. } end if (sibling = sibling_opportunity_already_reported(opportunity)) Rails.logger.info "OpenaiAds::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 customer = opportunity.customer email = opportunity.emails.pick(:detail) || customer&.email visit = find_visit(opportunity) # CAPI exists to enrich pixel data — `action_source: 'web'` events without # a `source_url` are rejected by OpenAI (`source_url_required_for_web`), # and a CRM-rep-created opp with no visit never came from a web ad anyway. # Skip cleanly rather than ship a noisy event with a synthetic URL. skip_result = skip_unreportable(opportunity, visit, customer) return skip_result if skip_result event_id = opportunity.tracking_event_id.presence || SecureRandom.uuid event = build_event( event_name: EVENT_LEAD_CREATED, event_time: conversion_date_time, event_id: event_id, action_source: 'web', source_url: visit&.landing_page, oppref: visit&.&.[]('oppref').presence, obref: visit&.&.[]('obref').presence, external_id: customer.id.to_s, email: email, ip: visit&.ip, user_agent: visit&.user_agent, **opportunity_geo(opportunity, visit), data: { type: 'customer_action', amount: (fractional_value.to_f * 100).round, currency: 'USD' }.compact ) result = send_events([event]) (opportunity, result, event_id, EVENT_LEAD_CREATED, conversion_date_time, email) end |
#send_order_conversion(order) ⇒ Hash
Reports an attributed sales order to the OpenAI Ads Conversions API.
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 82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
# File 'app/services/openai_ads/conversion_reporter.rb', line 36 def send_order_conversion(order) return { success: false, reason: :not_a_sales_order } unless order.is_sales_order? if order..present? Rails.logger.warn "OpenaiAds::ConversionReporter: Order #{order.reference_number} already reported" return { success: false, reason: :already_reported, meta: order. } end if already_validate_only_ok?(order.) Rails.logger.info "OpenaiAds::ConversionReporter: Order #{order.reference_number} already validated (canary); skipping re-send" return { success: false, reason: :already_validate_only_ok, meta: order. } end conversion_date_time = order.invoices.first&.created_at unless conversion_date_time Rails.logger.warn "OpenaiAds::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) customer = order.customer # CAPI exists to enrich pixel data — `action_source: 'web'` events without # a `source_url` are rejected by OpenAI (`source_url_required_for_web`), # and an order with no visit on itself, its quote, or its opportunity # never came from a web ad anyway. Skip cleanly rather than ship a noisy # event with a synthetic URL. skip_result = skip_unreportable(order, visit, customer) return skip_result if skip_result # Shared with Pinterest + browser pixel — see PR #792 description. event_id = order.tracking_event_id.presence || SecureRandom.uuid event = build_event( event_name: EVENT_ORDER_CREATED, event_time: conversion_date_time, event_id: event_id, action_source: 'web', source_url: visit&.landing_page, oppref: visit&.&.[]('oppref').presence, obref: visit&.&.[]('obref').presence, external_id: customer.id.to_s, email: email, ip: visit&.ip, user_agent: visit&.user_agent, **order_geo(order, visit), data: { type: 'contents', # OpenAI expects monetary values in integer minor units (cents). amount: (total.to_f * 100).round, currency: 'USD', contents: build_contents(order.line_items) }.compact ) result = send_events([event]) (order, result, event_id, EVENT_ORDER_CREATED, 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 OpenAI
session. Mirrors Invoicing::GoogleConversionReporter#sibling_opportunity_already_reported
and Pinterest::ConversionReporter#sibling_opportunity_already_reported.
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'app/services/openai_ads/conversion_reporter.rb', line 170 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("openai_ads_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 |