Class: MicrosoftAds::ReportingClient

Inherits:
Object
  • Object
show all
Defined in:
app/services/microsoft_ads/reporting_client.rb

Overview

REST client for the Microsoft Advertising (Bing Ads) Reporting API v13.
Backs the read half of Assistant::MicrosoftAdsToolBuilder — campaign,
ad-group, keyword and search-term performance.

The Reporting API is asynchronous, and there is no synchronous
alternative.
One report is three round-trips plus a download:

  1. POST /SubmitReportRequestId
  2. POST /Poll until Status is Success (or Error)
  3. GET the returned ReportDownloadUrl → a ZIP containing one CSV

Because a caller here is a chat tool rather than a background job, polling
is bounded by POLL_TIMEOUT rather than run to completion — a report that
is still pending at the deadline returns status: :timeout so the model
says "still generating" instead of the tool call hanging until the LLM
request itself dies.

Auth mirrors ConversionsClient: OAuth bearer + the developer
token / CustomerId / CustomerAccountId custom headers.

Defined Under Namespace

Classes: DownloadTimeout, ReportTooLarge

Constant Summary collapse

SERVICE_URL =
'https://reporting.api.bingads.microsoft.com/Reporting/v13/GenerateReport'
REPORT_TYPES =

Friendly tool-facing name → Reporting API request type. Deliberately a
closed vocabulary: the request Type also dictates which columns are
legal, so letting a model pass an arbitrary type produces opaque 400s.

{
  'campaign'    => 'CampaignPerformanceReportRequest',
  'ad_group'    => 'AdGroupPerformanceReportRequest',
  'keyword'     => 'KeywordPerformanceReportRequest',
  'search_term' => 'SearchQueryPerformanceReportRequest'
}.freeze
DEFAULT_COLUMNS =

Sensible default columns per report type, so the model can ask for
"campaign performance last month" without knowing the column vocabulary.

{
  'campaign'    => %w[CampaignName CampaignStatus Spend Impressions Clicks Ctr AverageCpc Conversions
                      Revenue ImpressionSharePercent ImpressionLostToBudgetPercent].freeze,
  'ad_group'    => %w[CampaignName AdGroupName Spend Impressions Clicks Ctr AverageCpc Conversions Revenue].freeze,
  'keyword'     => %w[CampaignName AdGroupName Keyword Spend Impressions Clicks Ctr AverageCpc Conversions
                      Revenue].freeze,
  'search_term' => %w[CampaignName SearchQuery DeliveredMatchType Spend Impressions Clicks Ctr
                      Conversions].freeze
}.freeze
POLL_TIMEOUT =

Wall-clock ceiling for the poll loop. Reports of this size normally land
in 10-40s; past this the tool reports :timeout rather than blocking the
chat turn.

90
POLL_INTERVAL =
3
MAX_RANGE_DAYS =

Blast-radius bounds. The caller is an LLM, so nothing stops it asking for
"search terms for the last 3 years" — which Microsoft will happily
generate and hand back as a multi-hundred-MB CSV, in a chat process.

Every bound below is enforced before the allocation it guards, which is
the only way it actually bounds anything: the download streams to a
tempfile against a running byte count (and aborts on Content-Length first
when the server sends one), and CSV rows are pulled one at a time and
stopped at MAX_ROWS rather than parsed whole and then sliced.

400
MAX_ZIP_BYTES =

a year + change; covers YoY comparisons

25 * 1024 * 1024
MAX_CSV_BYTES =
150 * 1024 * 1024
MAX_ROWS =

Rows kept in the response. The model can't use more than this and
ChatToolBuilder truncates the JSON anyway; this caps the array itself.

1_000
BOM_CHAR =

The byte-order mark Microsoft prefixes every report with, as a character
(the zip stream delivers it as its three UTF-8 bytes).

"\uFEFF"
BOM_STRIPPER =

Belt-and-braces for a BOM that survives header parsing.

->(header) { header.is_a?(String) ? header.delete_prefix(BOM_CHAR) : header }
DOWNLOAD_OPEN_TIMEOUT =

Download timeouts. open-uri defaults to no timeout, and the size guards
don't help here: a peer that trickles bytes forever never trips the byte
cap and keeps progress_proc firing, so only a wall-clock deadline bounds
it. DOWNLOAD_DEADLINE is the total budget across the transfer; the
per-operation timeouts catch a peer that simply stops talking.

15
DOWNLOAD_READ_TIMEOUT =
30
DOWNLOAD_DEADLINE =
60

Instance Method Summary collapse

Constructor Details

#initialize(developer_token:, access_token:, customer_id:, account_id:) ⇒ ReportingClient

Returns a new instance of ReportingClient.



91
92
93
94
95
96
# File 'app/services/microsoft_ads/reporting_client.rb', line 91

def initialize(developer_token:, access_token:, customer_id:, account_id:)
  @developer_token = developer_token
  @access_token    = access_token
  @customer_id     = customer_id
  @account_id      = 
end

Instance Method Details

#run_report(type:, start_date:, end_date:, columns: nil, aggregation: 'Summary') ⇒ Hash

Run one report end-to-end and return its rows.

Parameters:

  • type (String)

    key of REPORT_TYPES

  • start_date (Date)
  • end_date (Date)
  • columns (Array<String>, nil) (defaults to: nil)

    defaults to DEFAULT_COLUMNS for the type

  • aggregation (String) (defaults to: 'Summary')

    "Summary" (one row per entity) or "Daily"

Returns:

  • (Hash)

    { status: :ok, rows: [Hash], row_count: } or
    { status: :timeout|:failed, error: }



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'app/services/microsoft_ads/reporting_client.rb', line 107

def run_report(type:, start_date:, end_date:, columns: nil, aggregation: 'Summary')
  request_type = REPORT_TYPES[type.to_s]
  return { status: :failed, error: "Unknown report type '#{type}'. Valid: #{REPORT_TYPES.keys.join(', ')}" } if request_type.nil?

  range_error = validate_range(start_date, end_date)
  return range_error if range_error

  cols = Array(columns.presence || DEFAULT_COLUMNS.fetch(type.to_s))
  request_id = submit(request_type, cols, start_date, end_date, aggregation)
  return request_id if request_id.is_a?(Hash) # error passthrough

  url = poll(request_id)
  return url if url.is_a?(Hash) # error/timeout passthrough

  download(url)
rescue Faraday::Error, OpenURI::HTTPError => e
  { status: :failed, error: e.message }
end