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 event names from https://developers.openai.com/ads/supported-events
'order_created'- EVENT_LEAD_CREATED =
'lead_created'
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 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) ⇒ Object
── API Communication ────────────────────────────────────────────────
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'app/services/openai_ads/conversion_reporter.rb', line 200 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) ⇒ Object
── Opportunity Conversions ──────────────────────────────────────────
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 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 |
# File 'app/services/openai_ads/conversion_reporter.rb', line 93 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) * 0.2 # Same AVG_LEAD_CONVERSION_RATE as Google/Pinterest email = opportunity.emails.pick(:detail) || opportunity.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. unless visit Rails.logger.info "OpenaiAds::ConversionReporter: Opportunity #{opportunity.reference_number} has no visit (no web attribution); skipping" return (opportunity, :no_visit) end 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, external_id: opportunity.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) ⇒ Object
── Order Conversions ────────────────────────────────────────────────
30 31 32 33 34 35 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 |
# File 'app/services/openai_ads/conversion_reporter.rb', line 30 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) # 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. unless visit Rails.logger.info "OpenaiAds::ConversionReporter: Order #{order.reference_number} has no visit (no web attribution); skipping" return (order, :no_visit) end # 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, external_id: order.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.
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'app/services/openai_ads/conversion_reporter.rb', line 160 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 |