Class: MicrosoftAds::ReportingClient
- Inherits:
-
Object
- Object
- MicrosoftAds::ReportingClient
- 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:
POST /Submit→ReportRequestIdPOST /PolluntilStatusisSuccess(orError)- 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 requestTypealso 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
-
#initialize(developer_token:, access_token:, customer_id:, account_id:) ⇒ ReportingClient
constructor
A new instance of ReportingClient.
-
#run_report(type:, start_date:, end_date:, columns: nil, aggregation: 'Summary') ⇒ Hash
Run one report end-to-end and return its rows.
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 = 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.
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. } end |