Class: Marketing::AdSpend::AmazonAdapter

Inherits:
Object
  • Object
show all
Defined in:
app/services/marketing/ad_spend/amazon_adapter.rb

Overview

Amazon Ads report request and single-sweep polling primitives. Reporting API
v3 is asynchronous, so this adapter deliberately does not own a loop or a
sleep. Workers persist the returned report state in Sidekiq, poll once, and
reschedule while Amazon generates the report.

Defined Under Namespace

Classes: ReportsThrottledError

Constant Summary collapse

PROVIDER =

Adapter key used by AdSpendSyncWorker::ADAPTERS and the recorder's provider column.

'amazon_ads'
PROFILE_TYPES =

Advertising account types we pull spend for; vendor profiles bill elsewhere.

%w[seller].freeze
PROFILE_COUNTRIES =

Marketplaces we advertise in. Anything else is filtered out before reporting.

%w[US CA].freeze
REPORT_RETENTION =

Amazon Ads v3 keeps report data for 60 days. Asking for anything older is a
permanent HTTP 400 ("startDate (…) must be equal to or after report type data
retention start date (…)"), so a backfill reaching past the window burned
three worker retries, failed the day's batch, left the day unmarked, and got
handed back to the self-heal run forever (AppSignal #6481, #6479).

60.days

Class Method Summary collapse

Class Method Details

.poll_report(report, default_date:) ⇒ Hash

Perform one status request for one report. A completed result includes
normalized campaign rows grouped by their report date; pending and failed
results carry no rows and let the worker choose reschedule/failure policy.

Parameters:

  • report (Hash)

    state from request_reports

  • default_date (Date)

    fallback for old/synthetic report rows without a date

Returns:

  • (Hash)

    state and rows_by_date



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
# File 'app/services/marketing/ad_spend/amazon_adapter.rb', line 108

def self.poll_report(report, default_date:)
  report = report.with_indifferent_access
  client = AmazonAds::ApiClient.new
  status = client.report_status(profile_id: report.fetch(:profile_id), report_id: report.fetch(:report_id))

  case status['status']
  when 'COMPLETED'
    ad_product = report.fetch(:ad_product)
    sales_key = AmazonAds::ApiClient::CONVERSION_COLUMNS.fetch(ad_product).fetch(:sales)
    rate = report.fetch(:rate).to_f
    rows = client.download_report(status.fetch('url')).map do |row|
      # Sales is money in the profile's own currency, exactly like cost, so it
      # takes the same multiplier. Without this a CA profile's sales would be
      # summed as USD and every Canadian ROAS would read ~1.4x too high.
      row.merge('cost' => row['cost'].to_f * rate, sales_key => row[sales_key].to_f * rate)
    end
    {
      state: :completed,
      rows_by_date: rows.group_by { |row| row['date'].present? ? Date.parse(row['date']) : default_date.to_date }
                        .transform_values { normalize(it, ad_product:) }
    }
  when 'FAILED'
    { state: :failed, rows_by_date: {} }
  else
    { state: :pending, rows_by_date: {} }
  end
end

.request_reports(start_date, end_date = start_date, reports: []) ⇒ Array<Hash>

Request one report per seller profile and ad product. The returned hashes
are JSON-safe job state: report identity plus the FX multiplier needed
when that report eventually downloads.

Parameters:

  • start_date (Date)
  • end_date (Date) (defaults to: start_date)

    inclusive

  • reports (Array<Hash>) (defaults to: [])

    report state from an earlier throttled pass, so a
    resumed request only creates the profile/product pairs still missing

Returns:

  • (Array<Hash>)

    serializable report state



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
96
97
98
99
# File 'app/services/marketing/ad_spend/amazon_adapter.rb', line 54

def self.request_reports(start_date, end_date = start_date, reports: [])
  reports = reports.map { it.to_h.stringify_keys }
  # Hoisted: retention_start_date reads Date.current, and a job crossing midnight
  # would otherwise compare against one floor and report another.
  retention_start = retention_start_date
  if start_date < retention_start
    Rails.logger.warn(
      "[AmazonAdapter] #{start_date} predates Amazon Ads report retention " \
      "(oldest available: #{retention_start}); skipping — the data is gone, not late"
    )
    # Hand back whatever a throttled earlier pass already created rather than [].
    # Those reports cost quota and are still downloadable; dropping them here
    # would orphan them when the retention floor advances mid-retry.
    return reports
  end
  existing = reports.index_by { [it.fetch('profile_id').to_s, it.fetch('ad_product')] }
  client = AmazonAds::ApiClient.new
  profiles = client.profiles.select do |profile|
    PROFILE_TYPES.include?(profile.dig('accountInfo', 'type')) &&
      PROFILE_COUNTRIES.include?(profile['countryCode'])
  end

  profiles.each do |profile|
    missing_products = AmazonAds::ApiClient::REPORT_PRODUCTS.keys.reject do |ad_product|
      existing.key?([profile['profileId'].to_s, ad_product])
    end
    next if missing_products.empty?

    rate = consolidation_rate(profile['currencyCode'], start_date)
    missing_products.each do |ad_product|
      report = {
        'profile_id' => profile['profileId'],
        'ad_product' => ad_product,
        'rate' => rate,
        'report_id' => client.create_report(
          profile_id: profile['profileId'], ad_product:, date: start_date, end_date:
        )
      }
      reports << report
      existing[[profile['profileId'].to_s, ad_product]] = report
    end
  end
  reports
rescue AmazonAds::ApiClient::ThrottledError => e
  raise ReportsThrottledError.new(e, reports:), cause: e
end

.retention_start_dateDate

ponytail: a request that straddles the boundary can still 400 and re-enter
that loop for one day. Parse the floor out of the 400 body if it ever matters.

Returns:

  • (Date)

    oldest day Amazon will still report on



26
27
28
# File 'app/services/marketing/ad_spend/amazon_adapter.rb', line 26

def self.retention_start_date
  Date.current - REPORT_RETENTION
end