Class: OpenaiAds::ConversionReporter

Inherits:
BaseService show all
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_id so 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 =
'order_created'
EVENT_LEAD_CREATED =
'lead_created'

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

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.openai_ads_conversion_meta_reported_at.present?
    Rails.logger.warn "OpenaiAds::ConversionReporter: Opportunity #{opportunity.reference_number} already reported"
    return { success: false, reason: :already_reported, meta: opportunity.openai_ads_conversion_meta }
  end

  if already_validate_only_ok?(opportunity.openai_ads_conversion_meta)
    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.openai_ads_conversion_meta }
  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 persist_skip_meta(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&.marketing_meta&.[]('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])
  persist_meta(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.openai_ads_conversion_meta_reported_at.present?
    Rails.logger.warn "OpenaiAds::ConversionReporter: Order #{order.reference_number} already reported"
    return { success: false, reason: :already_reported, meta: order.openai_ads_conversion_meta }
  end

  if already_validate_only_ok?(order.openai_ads_conversion_meta)
    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.openai_ads_conversion_meta }
  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 persist_skip_meta(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&.marketing_meta&.[]('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])
  persist_meta(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.

Parameters:

Returns:

  • (Opportunity, nil)

    sibling already reported, or nil.



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