<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Xtract Studio]]></title><description><![CDATA[Xtract Studio]]></description><link>https://xtractstudio.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6af6a1bd8517d1ad2006baa1/45afdf8e-9c9c-42a3-8934-4423f8536bde.jpg</url><title>Xtract Studio</title><link>https://xtractstudio.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 13 Nov 2026 18:26:13 GMT</lastBuildDate><atom:link href="https://xtractstudio.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Same Question, Four Platforms: SQL vs BigQuery vs Python vs DAX]]></title><description><![CDATA[Solve 13 real analytics problems in MS SQL, BigQuery, Python and Power BI DAX side by side, and see how each engine really thinks.]]></description><link>https://xtractstudio.hashnode.dev/same-question-four-platforms-sql-vs-bigquery-vs-python-vs-dax</link><guid isPermaLink="true">https://xtractstudio.hashnode.dev/same-question-four-platforms-sql-vs-bigquery-vs-python-vs-dax</guid><category><![CDATA[SQL]]></category><category><![CDATA[bigquery]]></category><category><![CDATA[Python]]></category><category><![CDATA[Power BI]]></category><category><![CDATA[data analysis]]></category><dc:creator><![CDATA[Shijith Pulikkal]]></dc:creator><pubDate>Fri, 13 Nov 2026 17:51:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af6a1bd8517d1ad2006baa1/12f9079c-a6e2-40bc-9607-bf305e4c0ffe.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<p>When you learn several data tools at once, it's easy to memorize syntax without understanding why each tool works the way it does. So I ran an experiment: <strong>take one dataset (Superstore), ask one business question, and solve it in all four platforms.</strong></p>
<p>The code is on GitHub: <a href="https://github.com/shijithpulikkal/CodingFactory">CodingFactory</a></p>
<h2>The setup</h2>
<ul>
<li><strong>Dataset:</strong> Superstore Sales (Orders: dates, customers, products, sales, profit)</li>
<li><strong>Platforms:</strong> MS SQL Server, Google BigQuery, Python (Pandas in Colab), Power BI (DAX measures)</li>
<li><strong>Rule:</strong> the same question and the same logic on every platform, with measures used for DAX wherever possible</li>
</ul>
<h2>Challenge 1: Highest spending customer per year</h2>
<p>SQL and BigQuery lean on a window function. Python uses <code>idxmax</code>. DAX needs <code>RANKX</code>.</p>
<p><strong>MS SQL</strong></p>
<pre><code class="language-sql">WITH YearlySpend AS (
    SELECT CustomerID, CustomerName, YEAR(OrderDate) AS OrderYear,
           SUM(Sales) AS TotalSpend
    FROM Sales
    GROUP BY CustomerID, CustomerName, YEAR(OrderDate)
),
Ranked AS (
    SELECT *, RANK() OVER (PARTITION BY OrderYear ORDER BY TotalSpend DESC) AS Rnk
    FROM YearlySpend
)
SELECT OrderYear, CustomerID, CustomerName, TotalSpend
FROM Ranked
WHERE Rnk = 1
ORDER BY OrderYear;
</code></pre>
<p><strong>BigQuery</strong></p>
<pre><code class="language-sql">WITH yearly_spend AS (
  SELECT CustomerID, CustomerName,
         EXTRACT(YEAR FROM OrderDate) AS order_year,
         SUM(Sales) AS total_spend
  FROM `project.dataset.Sales`
  GROUP BY CustomerID, CustomerName, order_year
),
ranked AS (
  SELECT *, RANK() OVER (PARTITION BY order_year ORDER BY total_spend DESC) AS rnk
  FROM yearly_spend
)
SELECT order_year, CustomerID, CustomerName, total_spend
FROM ranked
WHERE rnk = 1
ORDER BY order_year;
</code></pre>
<p><strong>Python</strong></p>
<pre><code class="language-python">df['OrderYear'] = pd.to_datetime(df['OrderDate']).dt.year
yearly = (df.groupby(['OrderYear', 'CustomerID', 'CustomerName'])['Sales']
            .sum().reset_index(name='TotalSpend'))
top = yearly.loc[yearly.groupby('OrderYear')['TotalSpend'].idxmax()]
</code></pre>
<p><strong>DAX</strong></p>
<pre><code class="language-dax">Total Sales = SUM(Sales[Sales])

Customer Rank in Year =
RANKX(
    ALL(Sales[CustomerName]),
    [Total Sales]
)
</code></pre>
<p>Put Year and Customer on a table visual and filter <code>Customer Rank in Year = 1</code>.</p>
<h2>Challenge 2: Running total</h2>
<p>Window frames in SQL, <code>cumsum()</code> in Pandas, and a filter that rebuilds the date range in DAX.</p>
<pre><code class="language-sql">-- SQL Server and BigQuery (same syntax)
SELECT OrderDate, Sales,
       SUM(Sales) OVER (ORDER BY OrderDate
                        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS RunningTotal
FROM Sales;
</code></pre>
<pre><code class="language-python">df = df.sort_values('OrderDate')
df['RunningTotal'] = df['Sales'].cumsum()
</code></pre>
<pre><code class="language-dax">Running Total =
CALCULATE(
    [Total Sales],
    FILTER(ALL('Date'[Date]), 'Date'[Date] &lt;= MAX('Date'[Date]))
)
</code></pre>
<h2>Challenge 3: Month-over-month growth %</h2>
<p>SQL, BigQuery and Python all use the same idea: grab the previous row (<code>LAG</code> / <code>shift</code>). DAX uses time-intelligence functions instead, which only work with a proper <strong>Date table</strong>.</p>
<pre><code class="language-sql">WITH MonthlySales AS (
    SELECT DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1) AS SalesMonth,
           SUM(Sales) AS TotalSales
    FROM Sales
    GROUP BY DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1)
)
SELECT SalesMonth, TotalSales,
       ROUND((TotalSales - LAG(TotalSales) OVER (ORDER BY SalesMonth)) * 100.0
             / NULLIF(LAG(TotalSales) OVER (ORDER BY SalesMonth), 0), 2) AS MoM_Growth_Pct
FROM MonthlySales;
</code></pre>
<pre><code class="language-python">m = df.groupby(df['OrderDate'].dt.to_period('M'))['Sales'].sum().reset_index()
m['MoM_Growth_Pct'] = (m['Sales'].pct_change() * 100).round(2)
</code></pre>
<pre><code class="language-dax">Prev Month Sales = CALCULATE([Total Sales], PREVIOUSMONTH('Date'[Date]))

MoM Growth % = DIVIDE([Total Sales] - [Prev Month Sales], [Prev Month Sales]) * 100
</code></pre>
<h2>Challenge 4: 3-month moving average (where DAX bit back)</h2>
<p>In SQL, Pandas and BigQuery this is a one-liner:</p>
<pre><code class="language-sql">AVG(TotalSales) OVER (ORDER BY SalesMonth ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
</code></pre>
<pre><code class="language-python">monthly['Moving_Avg_3M'] = monthly['Sales'].rolling(window=3, min_periods=1).mean()
</code></pre>
<p>In DAX, my first attempt looked right, ran without errors, and <strong>returned the wrong numbers</strong>. The moving average was identical to the monthly total on every row. Two things caused it:</p>
<ol>
<li><code>DATESINPERIOD</code> returns a <strong>narrow, date-only table</strong>. It has none of your other columns.</li>
<li>The table visual already filters each row to one month, so unless you clear those filters, the 3-month window never opens.</li>
</ol>
<p>The fix is to clear the existing date filters, build the 3-month list of months, then average a measure over them:</p>
<pre><code class="language-dax">Moving Avg 3M =
VAR CurrentMonth = MAX('Date'[Date])
VAR Last3Dates   = DATESINPERIOD('Date'[Date], CurrentMonth, -3, MONTH)
VAR Months       = CALCULATETABLE(VALUES('Date'[MonthStart]), ALL('Date'), Last3Dates)
RETURN
    CALCULATE(AVERAGEX(Months, [Total Sales]), ALL('Date'))
</code></pre>
<p>Always sanity-check the output against your SQL or Python numbers. DAX won't throw an error for logic mistakes.</p>
<h2>The rest of the series</h2>
<table>
<thead>
<tr>
<th>Challenge</th>
<th>SQL / BigQuery</th>
<th>Python</th>
<th>DAX</th>
</tr>
</thead>
<tbody><tr>
<td>Year-over-year growth</td>
<td><code>LAG</code> over years</td>
<td><code>shift(1)</code></td>
<td><code>SAMEPERIODLASTYEAR</code></td>
</tr>
<tr>
<td>New vs returning customers</td>
<td>First-purchase CTE + join</td>
<td><code>groupby.min</code> + <code>merge</code></td>
<td><code>ALL()</code> + <code>VALUES()</code></td>
</tr>
<tr>
<td>Top N products per category</td>
<td><code>RANK() OVER (PARTITION BY ...)</code></td>
<td><code>groupby.rank</code></td>
<td><code>RANKX</code> + <code>FILTER</code></td>
</tr>
<tr>
<td>Cumulative distinct customers</td>
<td>Count first purchases, then running total</td>
<td><code>drop_duplicates</code> + <code>cumsum</code></td>
<td><code>DISTINCTCOUNT</code> in <code>CALCULATE</code></td>
</tr>
<tr>
<td>Customer retention</td>
<td><code>LAG</code> per customer</td>
<td><code>groupby.shift</code></td>
<td><code>DATEADD</code> inside <code>FILTER</code></td>
</tr>
<tr>
<td>RFM segmentation</td>
<td><code>NTILE(5)</code></td>
<td><code>pd.qcut</code></td>
<td>No <code>NTILE</code>, so <code>RANKX</code> plus math</td>
</tr>
<tr>
<td>% of total by category</td>
<td><code>SUM() OVER ()</code></td>
<td><code>/ .sum()</code></td>
<td><code>CALCULATE(..., ALL())</code></td>
</tr>
<tr>
<td>Market basket analysis</td>
<td>Self-join</td>
<td><code>itertools.combinations</code></td>
<td>Awkward, best done upstream</td>
</tr>
<tr>
<td>Profit margin by region</td>
<td><code>GROUP BY</code></td>
<td><code>groupby.agg</code></td>
<td>3 simple measures</td>
</tr>
<tr>
<td>Pareto (80/20)</td>
<td>Running total ordered by value</td>
<td>Sort + <code>cumsum</code></td>
<td><code>FILTER</code> comparison</td>
</tr>
<tr>
<td>Cohort analysis</td>
<td>Cohort CTE + <code>DATEDIFF</code></td>
<td><code>merge</code> + <code>pivot</code></td>
<td>Calculated columns + a measure</td>
</tr>
</tbody></table>
<h2>What I learned</h2>
<p><strong>1. SQL and Python think in sequences. DAX thinks in filter context.</strong> Window functions and <code>shift()</code> move row by row through ordered data. DAX recalculates a measure for whatever filters surround it, which is why <code>ALL()</code>, <code>VALUES()</code> and <code>CALCULATE</code> keep showing up.</p>
<p><strong>2. Columns group, measures aggregate.</strong> My cohort table collapsed into a single row because I'd built the grouping fields as measures. A measure has no rows to group by. Anything you want on the rows of a table should be a column, and the numbers you aggregate should be measures.</p>
<p><strong>3. Some problems belong upstream.</strong> Market basket analysis is natural in SQL or Python and painful in DAX. Knowing which tool to reach for matters as much as knowing the syntax.</p>
<p><strong>4. Simple questions converge.</strong> A profit margin by region looks almost identical on all four platforms. The differences only show up with time comparisons, row-to-row matching and context shifting.</p>
<h2>Try it yourself</h2>
<p>Pick one dataset and solve the same five questions in every tool you're learning. You'll understand each tool better than you would from isolated tutorials.</p>
<p>Full code and notes: <a href="https://github.com/shijithpulikkal/CodingFactory"><strong>github.com/shijithpulikkal/CodingFactory</strong></a></p>
<p>If you found this useful, let me know which challenge you'd like me to break down next.</p>
]]></content:encoded></item><item><title><![CDATA[How We Pipe DoorDash Ads API Data into BigQuery for Looker Studio and Base44]]></title><description><![CDATA[From async report downloads to dashboards: a production-ready retail media pipeline on Google Cloud]]></description><link>https://xtractstudio.hashnode.dev/how-we-pipe-doordash-ads-api-data-into-bigquery-for-looker-studio-and-base44</link><guid isPermaLink="true">https://xtractstudio.hashnode.dev/how-we-pipe-doordash-ads-api-data-into-bigquery-for-looker-studio-and-base44</guid><dc:creator><![CDATA[Shijith Pulikkal]]></dc:creator><pubDate>Fri, 13 Nov 2026 17:18:44 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af6a1bd8517d1ad2006baa1/db8af27f-0ef6-473c-9b0e-3310f55bf820.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Retail media reporting has a familiar problem: every network has its own portal, its own report builder, and its own idea of what a "campaign" is. A CPG brand we work with wanted all of its retail media performance in one place. Instacart was already covered. DoorDash was next.</p>
<p>This post walks through how we pulled DoorDash Ads reporting data into BigQuery with Cloud Run Jobs, and how that single dataset now feeds both a Looker Studio dashboard and a custom app built in Base44. It includes the architecture, the key code, and the gotchas that cost us time, so you can skip them.</p>
<h2>What we built</h2>
<ul>
<li>A <strong>Python job on Cloud Run</strong> that creates, polls and downloads DoorDash Ads reports through the API</li>
<li><strong>Three BigQuery fact tables</strong> (ad group, keyword, product), plus a run log</li>
<li><strong>Daily scheduling</strong> with Cloud Scheduler and the API token in Secret Manager</li>
<li>A <strong>Looker Studio dashboard</strong> and a <strong>Base44 app</strong> that both read the same tables</li>
</ul>
<h2>The DoorDash Ads reporting API in 60 seconds</h2>
<p>DoorDash Ads exposes the same reports you see in Ads Manager through an API. Authentication is a single bearer token (<code>Authorization: Bearer &lt;token&gt;</code>) against <code>https://openapi.doordash.com</code>.</p>
<p>Reports are generated <strong>asynchronously</strong>:</p>
<ol>
<li><code>POST /ads/api/v1/sp/reports/{recordType}/create</code> returns a <code>reportId</code></li>
<li><code>GET /ads/api/v1/sp/reports/download/{reportId}</code> returns a status. Poll until it is <code>COMPLETED</code></li>
<li>The completed response contains a URL to a CSV file</li>
</ol>
<p>A few rules from the docs are worth knowing up front:</p>
<ul>
<li>Only use endpoints under <code>/ads/api/v1/sp/reports/*</code> for reporting. The other endpoints manage campaigns and <strong>modify live campaigns</strong>.</li>
<li>Rate limits are roughly 10 reads per second and 1 write per second. Creating a report counts as a write.</li>
<li>You can look back up to 3 years, but a single report covers at most 365 days.</li>
</ul>
<h2>Architecture</h2>
<pre><code class="language-mermaid">flowchart LR
  A["DoorDash Ads API&lt;br/&gt;/sp/reports"] --&gt;|"create, poll, download CSV"| B["Cloud Run Jobs&lt;br/&gt;ADGROUP / KEYWORD / PRODUCT"]
  S["Secret Manager&lt;br/&gt;API token"] --&gt; B
  C["Cloud Scheduler&lt;br/&gt;every morning"] --&gt; B
  B --&gt; D["BigQuery staging tables"]
  D --&gt;|"delete + insert date window"| E["BigQuery fact tables&lt;br/&gt;sp_adgroup_daily&lt;br/&gt;sp_keyword_daily&lt;br/&gt;sp_product_daily"]
  E --&gt; F["Looker Studio"]
  E --&gt; G["Base44 app"]
</code></pre>
<h2>Choosing the reports (and one naming trap)</h2>
<p>The client's team already used three Sponsored Products reports in the Ads Manager custom report builder: <strong>Campaign</strong>, <strong>Product</strong> and <strong>Keyword</strong>. In the API they map like this:</p>
<table>
<thead>
<tr>
<th>Ads Manager report</th>
<th>API <code>recordType</code></th>
<th>BigQuery table</th>
<th>Grain</th>
</tr>
</thead>
<tbody><tr>
<td>Campaign</td>
<td><code>ADGROUP</code></td>
<td><code>sp_adgroup_daily</code></td>
<td>day × ad group</td>
</tr>
<tr>
<td>Product</td>
<td><code>PRODUCT</code></td>
<td><code>sp_product_daily</code></td>
<td>day × ad group × product</td>
</tr>
<tr>
<td>Keyword</td>
<td><code>KEYWORD</code></td>
<td><code>sp_keyword_daily</code></td>
<td>day × ad group × keyword</td>
</tr>
</tbody></table>
<p>The trap: the report called <strong>Campaign</strong> in the UI is actually <strong>ad group level</strong>. The CSV has an <code>Ad Group Id</code> column, and the API's <code>CAMPAIGN</code> record type is a different, campaign-level report. If you request <code>CAMPAIGN</code>, you won't get the file you expected.</p>
<p>Request bodies are small. This is all it takes to create a report:</p>
<pre><code class="language-python">def create_report(session, record_type, start, end):
    body = {
        "reportName": f"{record_type.lower()}_{start}_{end}",
        "startDate": f"{start} 00:00:00",
        "endDate": f"{end} 23:59:59",
        "campaignTypes": ["SPONSORED_PRODUCTS"],
    }
    r = session.post(
        f"{BASE_URL}/ads/api/v1/sp/reports/{record_type}/create", json=body
    )
    r.raise_for_status()
    return r.json()["reportId"]
</code></pre>
<h2>One image, three Cloud Run Jobs</h2>
<p>We wrote one Python container and deployed it three times as separate Cloud Run Jobs, each with a different <code>REPORT_TYPE</code> environment variable (<code>ADGROUP</code>, <code>KEYWORD</code>, <code>PRODUCT</code>). Separate jobs fail independently and can have different memory settings, which turned out to matter (see the gotchas).</p>
<p>Each run does the same thing for a date window:</p>
<ol>
<li>Create the report, poll until it is ready, download the CSV</li>
<li>Clean and type the data, using the <strong>BigQuery table's own schema</strong> as the source of truth</li>
<li>Load it into a staging table</li>
<li>In <strong>one transaction</strong>, delete that date window from the final table and insert the staging rows</li>
</ol>
<pre><code class="language-sql">BEGIN TRANSACTION;
DELETE FROM `doordash_ads.sp_adgroup_daily`
WHERE event_date BETWEEN @s AND @e;
INSERT INTO `doordash_ads.sp_adgroup_daily` (...)
SELECT ... FROM `doordash_ads_staging.stg_sp_adgroup_daily`;
COMMIT TRANSACTION;
</code></pre>
<p>Replacing a whole window instead of merging row by row has two advantages. Re-running a day is always safe, and rows that disappear or change upstream don't linger in your tables.</p>
<p>Column handling is mostly mechanical. DoorDash headers such as <code>New to Brand Sales (180 days)</code> become <code>new_to_brand_sales_180d</code> with one small function:</p>
<pre><code class="language-python">def norm_col(name):
    s = name.strip().lower()
    s = s.replace("(180 days)", "180d").replace("(1 year)", "1y").replace("%", "pct")
    return re.sub(r"[^a-z0-9]+", "_", s).strip("_")
</code></pre>
<p>Values like <code>$55.29</code> and <code>1.49%</code> are converted to numbers before loading. Percentages are stored as fractions (<code>0.0149</code>). Dates must be ISO <code>YYYY-MM-DD</code>, and the job stops with a clear message if they aren't. Unknown new columns from DoorDash are dropped with a warning instead of breaking the load.</p>
<p>Every run also writes one row to a <code>dd_report_runs</code> table (window, report ID, rows loaded, status), which makes "did it work this morning?" a one-line query.</p>
<h2>Scheduling around DoorDash's refresh cycle</h2>
<p>DoorDash documents exactly when data settles, and it shapes the schedule:</p>
<ul>
<li>Marketing data for <strong>today</strong> is recomputed every 3 hours</li>
<li><strong>Yesterday</strong> is restated once a day, with reports recommended after 23:00 UTC</li>
<li>All dates are <strong>UTC</strong></li>
</ul>
<p>So each daily run re-pulls a rolling window (the last 3 days, ending yesterday UTC) and replaces it. A day first appears with the latest numbers, then settles automatically on the following runs. We set the Cloud Scheduler time zone to <code>America/Los_Angeles</code> and run in the morning Pacific time, so the schedule follows daylight saving without any cron edits and the data is fresh when the client opens the dashboard.</p>
<h2>Gotchas that cost us time</h2>
<p><strong>1. UTC days are not California days.</strong> Ads Manager's "By dates" option shows UTC. A UTC day runs from 4 PM to 4 PM Pacific in winter. Keep UTC dates in your tables so the dashboard matches Ads Manager, and don't relabel them as local days.</p>
<p><strong>2. Orders don't add up across report levels.</strong> Campaign-level reports count an order once per checkout. Product, ad group and keyword reports count it once per promoted product in the checkout. Our product and ad group totals matched exactly on impressions, clicks, spend, sales and units, and differed only on orders. We stayed away from summing orders across levels.</p>
<p><strong>3. Product IDs and GTINs.</strong> Use <code>DD_SIC</code> as the product key. The <code>GTIN_14</code> field often holds several comma-separated values, so store it as a string and never join on it. Trim whitespace on names, because some ad group names had trailing spaces.</p>
<p><strong>4. Keep the "Other Clicks" bucket.</strong> DoorDash groups interactions that can't be attributed to a product under "Other Clicks". You need those rows for product totals to reconcile with campaign totals.</p>
<p><strong>5. Don't open raw CSVs in Excel.</strong> A file we re-saved in Excel came back with <code>dd-mm-yyyy</code> dates and thousands of blank rows. The raw API files use ISO dates. A strict date parser turns this from a silent bug into a loud error.</p>
<p><strong>6. BigQuery's client library returns legacy type names.</strong> Our tables were created with <code>INT64</code> and <code>FLOAT64</code>, but <code>get_table().schema</code> reports <code>INTEGER</code> and <code>FLOAT</code>. Our first production run crashed on that. If you derive types from the live schema, map the legacy names.</p>
<p><strong>7. The keyword report is big.</strong> The default 512 MiB Cloud Run Job memory ran out when we backfilled two months of keywords in one report. We gave that job 4 GiB and backfilled in 14-day windows.</p>
<p><strong>8. Read the 401 message.</strong> "Unauthorized Advertiser" means the API recognized the token but it isn't provisioned for your advertiser account. "Expired or invalid token" means the credential isn't an API token at all. The fix for the first one is on the client or DoorDash side, not in your code.</p>
<h2>From tables to dashboards</h2>
<p>The dashboards only need a handful of fields, all of them base metrics:</p>
<table>
<thead>
<tr>
<th>Dashboard metric</th>
<th>Source column</th>
</tr>
</thead>
<tbody><tr>
<td>Revenue</td>
<td><code>ad_attr_sales</code></td>
</tr>
<tr>
<td>Quantity</td>
<td><code>ad_attr_units</code></td>
</tr>
<tr>
<td>NTB (new-to-brand) revenue</td>
<td><code>new_to_brand_sales_180d</code></td>
</tr>
<tr>
<td>Spend, clicks, impressions</td>
<td><code>spend</code>, <code>clicks</code>, <code>impressions</code></td>
</tr>
</tbody></table>
<p>Ratios are <strong>never averaged</strong>. ROAS, CTR and NTB share are always calculated from summed base metrics, in a view or a calculated field:</p>
<pre><code class="language-sql">SELECT
  event_date,
  SUM(spend)         AS spend,
  SUM(ad_attr_sales) AS revenue,
  SAFE_DIVIDE(SUM(ad_attr_sales), SUM(spend))                  AS roas,
  SUM(clicks)        AS clicks,
  SUM(ad_attr_units) AS quantity,
  SAFE_DIVIDE(SUM(new_to_brand_sales_180d), SUM(ad_attr_sales)) AS ntb_sales_pct
FROM `doordash_ads.sp_adgroup_daily`
GROUP BY event_date;
</code></pre>
<h3>Looker Studio</h3>
<p>The Looker Studio dashboard follows the same structure as the client's existing Instacart dashboard: KPIs at a glance, daily performance, campaign performance, a search term view built on the keyword table, and a product view built on the product table. Keeping the layout the same across networks means the client reads DoorDash and Instacart the same way.</p>
<h3>Base44</h3>
<p>We also built a custom app in Base44 that puts every retail media network in one place, with a tab per network and DoorDash as one of them. The DoorDash tab has date presets (last 7, 14, 30 and 90 days, last month, month to date, last 12 months, all time), KPI cards, a daily performance table and a campaign performance table. Each section shows which BigQuery table it reads from, which makes the numbers easy to audit.</p>
<p>Because the app and Looker Studio read the same tables and use the same ratio definitions, the numbers agree. However you connect an app to BigQuery, keep the credentials on the server side and give it a read-only service account.</p>
<h2>Running costs and operations</h2>
<p>Three small jobs that run for a few minutes a day cost very little. For operations, we watch two things: the <code>dd_report_runs</code> table for <code>FAILED</code> or <code>EMPTY</code> runs, and a Cloud Monitoring alert on failed Cloud Run job executions. The API token lives only in Secret Manager, and it is rotated by adding a new secret version, since the jobs read <code>:latest</code>.</p>
<h2>What's next</h2>
<ul>
<li>Sponsored Brands and Promotions campaigns through the same pipeline</li>
<li>The Business reports (sales, competitive share, interest insights)</li>
<li>Views for every dashboard section, so ratio logic lives in one place</li>
</ul>
<h2>Wrapping up</h2>
<p>The DoorDash Ads API is friendly once you know its shape: asynchronous reports, UTC dates, one restatement a day, and a few naming quirks. Pull a rolling window, replace it atomically, calculate ratios from base metrics, and every dashboard built on top stays consistent.</p>
<p>If you're sorting out retail media reporting across several networks, we build these data pipelines and dashboards at <a href="https://xtractstudio.com">Xtract Studio</a>. Get in touch and we'll help.</p>
]]></content:encoded></item><item><title><![CDATA[Instacart Ads API to BigQuery: Powering Looker Studio Dashboards and a Base44 App]]></title><description><![CDATA[Pull Instacart Ads API reports into BigQuery with Cloud Run, then power Looker Studio dashboards and a Base44 app. OAuth, backfills, gotchas.]]></description><link>https://xtractstudio.hashnode.dev/instacart-ads-api-to-bigquery-powering-looker-studio-dashboards-and-a-base44-app</link><guid isPermaLink="true">https://xtractstudio.hashnode.dev/instacart-ads-api-to-bigquery-powering-looker-studio-dashboards-and-a-base44-app</guid><category><![CDATA[bigquery]]></category><category><![CDATA[Google Cloud Platform]]></category><category><![CDATA[api]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[looker studio]]></category><category><![CDATA[Python]]></category><category><![CDATA[data analytics]]></category><category><![CDATA[cloud run]]></category><dc:creator><![CDATA[Shijith Pulikkal]]></dc:creator><pubDate>Fri, 13 Nov 2026 15:35:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af6a1bd8517d1ad2006baa1/d58312ce-a82d-4869-99a2-2117b1f72da8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Instacart's Ads API has no native BigQuery export. If you run retail media reporting for a brand or a client, that means manual CSV pulls, stale numbers and dashboards that nobody trusts.</p>
<p>I built a small pipeline that fixes this: three Cloud Run jobs pull campaign, keyword and product reports from the Instacart Ads API every morning, load them into BigQuery, and feed a Looker Studio dashboard and a Base44 app. This post walks through the architecture, the code patterns that matter, and the mistakes that cost me the most time.</p>
<h2>The architecture</h2>
<pre><code class="language-plaintext">Cloud Scheduler (daily, Pacific time)
        |
Cloud Run Jobs (campaign / keyword / product loaders)
        |-- Secret Manager (shared OAuth tokens)
        |-- Instacart Ads API v3 (async reporting)
        |
BigQuery (one table per report type)
        |
Looker Studio dashboard  +  Base44 app
</code></pre>
<ul>
<li><strong>Three independent Cloud Run Jobs</strong>, one per report type, so one failing report never blocks the others.</li>
<li><strong>Secret Manager</strong> stores the OAuth tokens, shared by all three jobs.</li>
<li><strong>Cloud Scheduler</strong> triggers each job once a day, staggered by 15 to 30 minutes.</li>
<li><strong>BigQuery</strong> holds one table per report, with a <code>date</code> column on every row so you get daily trends.</li>
</ul>
<h2>Step 1: Authentication is not client_credentials</h2>
<p>Most ad APIs use a simple client-credentials flow. Instacart Ads API v3 uses OAuth2 <code>authorization_code</code> plus <code>refresh_token</code>:</p>
<ol>
<li>In Ads Manager, open <strong>Applications</strong> and create an application with a redirect URI you control. The page doesn't need to exist. You only need to read the <code>?code=</code> value off the address bar after authorizing.</li>
<li>Click <strong>Authorize</strong>. You are redirected to your URI with an authorization code, valid for 5 minutes.</li>
<li>Exchange that code once for an access token and a refresh token.</li>
</ol>
<pre><code class="language-python">requests.post(f"{BASE}/oauth/token", json={
    "client_id": CLIENT_ID,
    "client_secret": CLIENT_SECRET,
    "redirect_uri": REDIRECT_URI,
    "code": auth_code,
    "grant_type": "authorization_code",
})
</code></pre>
<p>The access token lives for 2 hours. The refresh token lives for 30 days and <strong>rotates every time you use it</strong>: the old one is revoked as soon as a refresh succeeds. That one detail drives the whole design.</p>
<h3>Store tokens in Secret Manager, not on disk</h3>
<p>Cloud Run's filesystem is ephemeral, and a rotating refresh token that is lost or stale locks you out until you re-authorize by hand. So every run reads the latest token from Secret Manager, refreshes, and immediately writes the new token back as a new secret version.</p>
<pre><code class="language-python">def get_access_token():
    tokens = _load_tokens()                        # latest secret version
    tokens = refresh_access_token(tokens["refresh_token"])
    _save_tokens(tokens)                           # persist the rotated token
    return tokens["access_token"]
</code></pre>
<p>Because one Instacart application authorizes every report type, all three jobs share the same secret. The consequence is that two jobs must never refresh at the same moment, which is why the schedules are staggered.</p>
<h2>Step 2: Async reporting in three calls</h2>
<p>Reporting goes through one endpoint, <code>POST /api/v3/charts/async/tables</code>. You choose a <code>source</code>, a date range, and the columns you want:</p>
<pre><code class="language-python">body = {
    "source": "sponsored_product_keyword",
    "query": {
        "date_range": {"start_date": "2026-01-01", "end_date": "2026-01-31"},
        "series": {"columns": ["campaign.name", "keyword.keyword",
                               "event.date", "event.spend",
                               "event.attributed_sales_linear", "event.clicks"]},
    },
    "header_map": {"event.spend": "spend", "event.date": "date"},
}
</code></pre>
<p>The flow is: create the report, poll its <code>status_url</code> until the status is <code>completed</code> (it moves through <code>pending</code>, <code>created</code>, <code>in_progress</code>), then download the CSV from the <code>location</code> it returns. Two practical notes:</p>
<ul>
<li><code>event.date</code> <strong>is a real column you must request.</strong> Leave it out and you get one aggregated row per keyword for the whole range, with no daily trend.</li>
<li><code>header_map</code> <strong>renames columns in the output CSV</strong>, so your BigQuery columns are clean (<code>spend</code>, not <code>event.spend</code>) without a pandas rename step.</li>
</ul>
<p>The reports I use are <code>sponsored_product</code> (campaign level), <code>sponsored_product_keyword</code> and <code>sponsored_product_product</code>. The API also exposes display, video, promotions, audience segments and share of digital shelf sources.</p>
<h2>Step 3: Make the load idempotent</h2>
<p>Every run deletes the date range it is about to load, then inserts fresh rows. Re-running a day, retrying a failed job or backfilling history can never create duplicates.</p>
<pre><code class="language-python">DELETE FROM `project.dataset.table`
WHERE date BETWEEN @start_date AND @end_date
</code></pre>
<p>For large backfills, the script splits the range into chunks of up to 31 days automatically. One command handles both the January-to-today backfill and the daily run:</p>
<pre><code class="language-bash">python loader.py 2026-01-01 2026-07-31     # backfill, auto-chunked
python loader.py                           # daily run
</code></pre>
<h2>Step 4: Attributed sales keep changing, so re-pull a rolling window</h2>
<p>This was the most important lesson. My first version pulled only "yesterday" each morning, and the numbers drifted. Attributed sales for a given day kept changing after I had already loaded it.</p>
<p>I could not find a documented attribution-finalization window in Instacart's API reference, so I did not guess one. Because the load is idempotent, the fix was simple: every daily run re-pulls the last <strong>15 days</strong> instead of one.</p>
<pre><code class="language-python">end_date = date.today() - timedelta(days=1)
start_date = date.today() - timedelta(days=15)
</code></pre>
<p>Late-settling attribution now corrects itself on the next run. The trade-off is that each run does more work, so check your Cloud Run task timeout.</p>
<h2>Step 5: Deploy as Cloud Run Jobs</h2>
<p>Cloud Run <strong>Jobs</strong> suit this better than Services, because the work runs to completion and exits instead of waiting for HTTP requests.</p>
<pre><code class="language-bash">gcloud builds submit --tag us-central1-docker.pkg.dev/PROJECT/instacart/keyword-loader
gcloud run jobs create instacart-keyword-loader \
  --image=us-central1-docker.pkg.dev/PROJECT/instacart/keyword-loader \
  --region=us-central1 \
  --set-env-vars=GCP_PROJECT=PROJECT,INSTACART_CLIENT_ID=...,INSTACART_CLIENT_SECRET=...
gcloud scheduler jobs create http instacart-keyword-daily \
  --schedule="0 8 * * *" --time-zone="America/Los_Angeles" \
  --uri="https://us-central1-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/PROJECT/jobs/instacart-keyword-loader:run" \
  --http-method=POST --oauth-service-account-email=SERVICE_ACCOUNT --location=us-central1
</code></pre>
<p>Two tips: set the scheduler time zone to where your client works (Cloud Scheduler handles daylight saving for you), and paste long <code>gcloud</code> commands as a single line, because stray characters after a <code>\</code> continuation broke mine more than once.</p>
<p>The service account needs Secret Manager access to the token secret (<code>secretmanager.secretVersionManager</code>) and BigQuery write access (<code>bigquery.dataEditor</code>).</p>
<p>At this volume (three short jobs a day, one secret, a few small tables) the whole thing sits inside Google Cloud's free tiers. Artifact Registry image storage is the only line worth watching, since every rebuild adds an image version.</p>
<h2>Step 6: Looker Studio dashboards</h2>
<p>Connect Looker Studio to the BigQuery tables with the native BigQuery connector. Two things to get right:</p>
<ul>
<li><strong>Never aggregate ratios by summing them.</strong> If the table sums the raw <code>roas</code> column across rows, you get nonsense like a 120x ROAS. Create a calculated field instead: <code>SUM(attributed_sales) / SUM(spend)</code>. The same goes for CTR and CPC.</li>
<li><strong>The keyword and product reports will not match.</strong> For the same day, the product report showed more spend and clicks than the keyword report. Keyword-level reporting only covers traffic tied to a keyword, while product-level reporting includes everything. Label each chart with its source so nobody compares them.</li>
</ul>
<p>Set the report time zone to match your client's. Instacart's <code>event.date</code> values are in PST.</p>
<h2>Step 7: The Base44 app with user Login &amp; Password</h2>
<p>Looker Studio works well for analysts, but I also wanted something that felt like a product rather than a report. So I built a custom dashboard in Base44 on top of the same BigQuery data. Base44 app reads from BigQuery using live Query direct connection</p>
<p>What the app includes:</p>
<ul>
<li><strong>One page per retail media platform.</strong> The sidebar has a page for Instacart Ads, and the same shell holds pages for other retail media networks. This pipeline feeds the Instacart one.</li>
<li><strong>A KPI strip at the top:</strong> spend, ROAS, clicks, attributed units, revenue, new-to-brand (NTB) sales and NTB sales percentage. Revenue and quantity are friendly labels over the attributed sales and attributed units columns.</li>
<li><strong>Date range presets:</strong> last 7, 14, 30 and 90 days, last month, month to date, last 12 months, all time, plus a custom range. The whole page recalculates from the selected range.</li>
<li><strong>A daily performance table</strong> with in-cell bars, sortable columns and a grand total row.</li>
<li><strong>A campaign performance table</strong> with revenue, NTB revenue, spend, ROAS, quantity, CTR and NTB sales percentage per campaign.</li>
<li><strong>A source label on each section</strong> that names the BigQuery table behind it, so anyone looking at a number knows where it came from.</li>
</ul>
<p>The ROAS lesson from the Looker Studio step applies here too. The grand total row calculates ROAS as total revenue divided by total spend, never as a sum of the daily ROAS values.</p>
<p>The two front ends read the same tables, so the numbers agree. Looker Studio stays the workbench for ad-hoc analysis, and the Base44 app is the cleaner view to hand to a client.</p>
<h2>Mistakes that cost me time</h2>
<ol>
<li><strong>Deploying the wrong script into the wrong folder.</strong> All three loaders had the same filename, and a campaign script ended up inside the product job's image. The job ran happily and wrote to the wrong table. Run <code>grep -E '"source"|"bq_table"'</code> on the file right before every build.</li>
<li><strong>Schema drift with</strong> <code>WRITE_APPEND</code><strong>.</strong> Adding new columns to a script breaks appends to an older table with <code>Cannot add fields</code>. Add the columns to the table first, or load into a new table.</li>
<li><strong>Placeholder credentials in the job config.</strong> A job created with literal <code>your_real_app_id</code> fails with a 401 that looks like an auth bug.</li>
<li><strong>Missing packages outside Docker.</strong> Running the script by hand in Cloud Shell needs <code>pyarrow</code> and <code>google-cloud-secret-manager</code> installed, on top of <code>pandas</code>.</li>
<li><strong>Relative URLs.</strong> The API sometimes returns a relative path for the status and download links. A small <code>_absolute_url()</code> helper handles both forms.</li>
</ol>
<h2>What the API does not give you</h2>
<p>The reporting sources expose spend, impressions, clicks, CTR, CPC, ROAS, attributed sales and units (linear and last-touch), and new-to-brand metrics. I found no order count and no conversion metric. Attributed units is the closest proxy, and a clicks-to-units rate is a derived approximation you can calculate yourself, as long as you label it that way.</p>
<h2>Wrapping up</h2>
<p>The result is a hands-off daily pipeline: authenticate once, then every report refreshes itself, corrects late attribution, and lands in BigQuery ready for dashboards and apps. The patterns here (token rotation in Secret Manager, idempotent range loads, rolling re-pulls) carry over to any ad platform with async reporting and delayed attribution.</p>
<p><em>Need a reporting pipeline for a retail media platform? I build these as a freelance data and analytics consultant at</em> <a href="https://xtractstudio.com"><em>Xtract Studio</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[From Kroger Ad Platform API to BigQuery, Looker Studio and Base44 App]]></title><description><![CDATA[Load Kroger Ad Platform reporting into BigQuery with Cloud Run, then build Looker Studio and Base44 dashboards, with retries and alerts.]]></description><link>https://xtractstudio.hashnode.dev/from-kroger-ad-platform-api-to-bigquery-looker-studio-and-base44-app</link><guid isPermaLink="true">https://xtractstudio.hashnode.dev/from-kroger-ad-platform-api-to-bigquery-looker-studio-and-base44-app</guid><category><![CDATA[bigquery]]></category><category><![CDATA[google cloud]]></category><category><![CDATA[cloud run]]></category><category><![CDATA[looker studio]]></category><category><![CDATA[retail media]]></category><category><![CDATA[Python]]></category><category><![CDATA[api integration]]></category><category><![CDATA[base44]]></category><dc:creator><![CDATA[Shijith Pulikkal]]></dc:creator><pubDate>Fri, 13 Nov 2026 07:50:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af6a1bd8517d1ad2006baa1/af82dab1-4936-4823-ac2a-9d09cb7eb282.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<p>Retail media reporting has a familiar problem: the numbers live inside the retailer's ad platform, and everyone ends up exporting CSVs by hand. A CPG brand I work with advertises on the Kroger Ad Platform and wanted daily campaign and product performance in dashboards, with no manual work. This post walks through how I built it: the API, the BigQuery model, the Cloud Run pipeline, the dashboards and the problems I hit on the way, including a very annoying intermittent 403.</p>
<h2>TL;DR</h2>
<ul>
<li>The Kroger Ad Platform (KAP) reporting API uses OAuth 2.0 client credentials against a <strong>custom authorization server</strong>, with one report endpoint per ad experience (PLA, carousel, TOA).</li>
<li>A small Python <strong>Cloud Run Job</strong>, triggered daily by <strong>Cloud Scheduler</strong>, pulls two daily reports (campaign level and product level) and loads them into date-partitioned <strong>BigQuery</strong> tables.</li>
<li>Loads are <strong>idempotent</strong>: each run replaces the dates it pulled, so retries and backfills are safe.</li>
<li><strong>Looker Studio</strong> and a <strong>Base44</strong> app read from BigQuery. Rates such as CTR, CPC and ROAS are calculated from summed totals, never averaged.</li>
<li>The biggest surprise was not the API itself but an intermittent CDN block on cloud-provider IP addresses.</li>
</ul>
<h2>Architecture</h2>
<pre><code class="language-plaintext">Cloud Scheduler (daily)
        |
        v
Cloud Run Job (Python) ---&gt; Kroger Ad Platform reporting API
        |                    (OAuth 2.0 client credentials)
        v
BigQuery (date-partitioned tables)
        |
        +--&gt; Looker Studio dashboards
        +--&gt; Base44 app

Secret Manager holds the client ID and secret.
Cloud Monitoring alerts on failed runs.
</code></pre>
<h2>1. Pick the right API</h2>
<p>Kroger has more than one API. The public developer portal covers shopper-facing data such as products, store locations and carts. Ad reporting is a different system: the Kroger Ad Platform API, with its own base URL, authorization server and credentials. I lost time reading the wrong documentation first, so check this early.</p>
<p>For a reporting-only integration you receive:</p>
<ul>
<li>An OAuth client ID and secret for a <strong>sandbox</strong> and a <strong>production</strong> environment, delivered by secure email</li>
<li>Access to a shared partner <strong>Postman workspace</strong> with a ready-made collection and environments</li>
<li>Learning Center documentation for reports, metrics and dimensions</li>
</ul>
<p>The sandbox is good for learning the shape of responses, but it may hold little real data. Expect to switch to production credentials for the real pipeline.</p>
<h2>2. Authentication: use the custom authorization server</h2>
<p>The API uses OAuth 2.0 <strong>client credentials</strong> with the scope <code>offline_access e451.api.access</code>. This cost me an afternoon: that scope does not exist on the identity provider's <em>default</em> authorization server. Every guessed token URL returned <code>400 Bad Request</code> until I used the token URL of the custom server from the partner documentation, which contains an authorization server ID, in the form <code>.../oauth2/&lt;server-id&gt;/v1/token</code>.</p>
<p>If your client ID is recognised but every scope attempt fails, you are probably calling the wrong authorization server, not using wrong credentials.</p>
<pre><code class="language-bash">curl -s -X POST "$TOKEN_URL" \
  -d grant_type=client_credentials \
  --data-urlencode "scope=offline_access e451.api.access" \
  -d client_id="$CLIENT_ID" \
  --data-urlencode client_secret="$CLIENT_SECRET"
</code></pre>
<p>Tokens last one hour. Client credentials has no refresh flow, so the pipeline simply requests a fresh token at the start of every run.</p>
<h2>3. Validate in Postman first</h2>
<p>Fork the partner collection and environment into your own workspace, put your credentials in the environment's <strong>current value</strong> (which stays local to you), select the environment, and run the "Get Access Token" request.</p>
<p>Two mistakes I made:</p>
<ul>
<li>Running requests with <strong>no environment selected</strong>, so variables like <code>{{client_id}}</code> were sent as literal text.</li>
<li>Pasting credentials into places they should not go. Keep secrets out of screenshots and out of any AI assistant built into your API client.</li>
</ul>
<h2>4. The reporting endpoints</h2>
<p>There is one endpoint per ad experience:</p>
<table>
<thead>
<tr>
<th>Endpoint</th>
<th>Use</th>
</tr>
</thead>
<tbody><tr>
<td><code>POST /report/pla</code></td>
<td>Product Listing Ads</td>
</tr>
<tr>
<td><code>POST /report/carousel</code></td>
<td>Promoted product carousels, including video</td>
</tr>
<tr>
<td><code>POST /report/toa</code></td>
<td>Targeted onsite (display) ads</td>
</tr>
<tr>
<td><code>POST /report/pacing_ratio</code></td>
<td>Budget pacing per ad group (latest value only)</td>
</tr>
</tbody></table>
<p>My client only runs PLA, so the pipeline uses the first one. A request body looks like this:</p>
<pre><code class="language-json">{
  "startDate": "2026-10-19",
  "endDate": "2026-10-19",
  "advertiserIds": [12345],
  "filters": [],
  "dimensions": ["daily_date", "campaign_id", "campaign_name"],
  "metrics": ["impressions", "clicks", "cost"],
  "pagination": {"offset": 0, "size": 10000},
  "sort": []
}
</code></pre>
<p>Things worth knowing:</p>
<ul>
<li>The default page size is 250 rows and the maximum is 10,000. Always send <code>size</code> and page with <code>offset</code>. An offset beyond the data returns 200 with an empty <code>data</code> array, which gives a clean stop condition for the loop.</li>
<li>Filters are only needed for multi-brand campaigns. My first request failed because the sample filter used placeholder field names. The error message is useful: it lists every valid metric and dimension for the endpoint. <strong>Trust that list over the documentation page</strong>, because some names differ (for example <code>halo_roas</code> rather than <code>clicked_roas</code>).</li>
<li>Pull the <strong>previous day, no earlier than 3 AM Eastern</strong>. The documentation recommends this buffer, and says conversions are attributed to the day they occur, so historical rows do not change.</li>
</ul>
<h2>5. Choosing metrics and dimensions</h2>
<p>The dashboard needed these fields:</p>
<table>
<thead>
<tr>
<th>Dashboard field</th>
<th>API name</th>
</tr>
</thead>
<tbody><tr>
<td>Date</td>
<td><code>daily_date</code></td>
</tr>
<tr>
<td>Campaign name</td>
<td><code>campaign_name</code></td>
</tr>
<tr>
<td>Product name</td>
<td><code>entity_name</code></td>
</tr>
<tr>
<td>Impressions, Clicks, Cost</td>
<td><code>impressions</code>, <code>clicks</code>, <code>cost</code></td>
</tr>
<tr>
<td>CPC, CTR, CPM</td>
<td><code>cost_per_click</code>, <code>click_through_rate</code>, <code>cost_per_thousand</code></td>
</tr>
<tr>
<td>ROAS goal</td>
<td><code>goal</code></td>
</tr>
<tr>
<td>Clicked transactions, units, revenue</td>
<td><code>halo_transactions</code>, <code>halo_units</code>, <code>halo_revenue</code></td>
</tr>
<tr>
<td>Clicked ROAS</td>
<td><code>halo_roas</code></td>
</tr>
<tr>
<td>Viewed transactions, units, revenue</td>
<td><code>halo_exposed_transactions</code>, <code>halo_exposed_units</code>, <code>halo_exposed_revenue</code></td>
</tr>
<tr>
<td>Viewed ROAS</td>
<td><code>halo_exposed_roas</code></td>
</tr>
</tbody></table>
<p>I made two requests: a <strong>campaign-level</strong> report (which includes <code>goal</code>, since it is a campaign attribute) and a <strong>product-level</strong> report (which adds <code>entity_id</code> and <code>entity_name</code>). A useful sanity check: I summed the product rows per campaign and they matched the campaign report exactly for clicks, cost, impressions and transactions. It is worth building that reconciliation into any pipeline.</p>
<p>Quirks to handle in code:</p>
<ul>
<li><code>goal</code> arrives as a string such as <code>"1"</code> even though it is declared numeric.</li>
<li><code>entity_id</code> arrives as a number although the response header calls it text.</li>
<li>Product names can contain invisible <strong>soft-hyphen</strong> characters. Strip them, or joins and filters fail silently.</li>
<li>Ratios come back as fractions (0.058 means 5.8%).</li>
<li>"Viewed" metrics use impression-or-click attribution, so they already include the clicked ones. Do not add the two together.</li>
</ul>
<h2>6. BigQuery design</h2>
<p>Two tables, partitioned by <code>daily_date</code> and clustered by campaign (and product for the second):</p>
<pre><code class="language-sql">CREATE TABLE IF NOT EXISTS `my-project.kroger_ads.kap_pla_campaign_daily` (
  daily_date DATE, campaign_id INT64, campaign_name STRING,
  impressions INT64, clicks INT64, cost FLOAT64,
  -- ...remaining metrics...
  goal FLOAT64, ingested_at TIMESTAMP
) PARTITION BY daily_date CLUSTER BY campaign_id;
</code></pre>
<p>Loads are <strong>idempotent</strong>: each run deletes the rows for the dates it is about to load, then inserts the new rows. Two safety rules make this robust:</p>
<ol>
<li>Never delete when the API returns zero rows. An empty or blocked response should not wipe good data.</li>
<li>Re-pull a short <strong>lookback window</strong> (I use 3 days) on every run, so a missed day heals itself on the next successful run.</li>
</ol>
<h2>7. The Cloud Run Job</h2>
<p>The job is about 200 lines of Python using <code>requests</code> and <code>google-cloud-bigquery</code>. The core steps are: get a token, fetch each report with pagination, cast the types, and replace the date range in BigQuery.</p>
<pre><code class="language-python">client.query(
    f"DELETE FROM `{table_id}` WHERE daily_date BETWEEN @s AND @e",
    job_config=bigquery.QueryJobConfig(query_parameters=[
        bigquery.ScalarQueryParameter("s", "DATE", start),
        bigquery.ScalarQueryParameter("e", "DATE", end),
    ]),
).result()
client.load_table_from_json(rows, table_id, job_config=load_config).result()
</code></pre>
<p>Deployment notes:</p>
<ul>
<li>Store the client ID and secret in <strong>Secret Manager</strong> and mount them with <code>--set-secrets</code>. Never put them in code or in the container image.</li>
<li>Use a dedicated service account with BigQuery Data Editor, BigQuery Job User and Secret Accessor, and nothing more.</li>
<li>Deploy with <code>gcloud run jobs deploy ... --source .</code>, then trigger it with <strong>Cloud Scheduler</strong>. Schedule it comfortably after the 3 AM Eastern buffer, for example 5 AM Eastern.</li>
<li>Exit with a non-zero code on failure so Cloud Run marks the execution as failed.</li>
</ul>
<h2>8. When the CDN says no</h2>
<p>After deployment the job started failing on some days with <code>403 Access Denied</code>. It was not a JSON error from the API but an <strong>HTML error page from the CDN</strong>, with a reference number. The token call always worked, Postman worked, and some runs worked too.</p>
<p>How I approached it:</p>
<ol>
<li>An HTML 403 means the request was blocked at the edge, before it reached the API.</li>
<li>Intermittent failures suggested a decision per source IP. Cloud Run assigns outbound IPs from a shared, dynamic pool.</li>
<li>I logged the UTC time, the reference number and the outbound IP for every blocked request, so there was real evidence to share with the API provider.</li>
<li>I added retries with backoff, but only for HTML 403 responses. Genuine permission errors are JSON and should fail fast.</li>
</ol>
<pre><code class="language-python">edge_block = r.status_code == 403 and r.text.lstrip().startswith("&lt;")
</code></pre>
<p>I raised a support ticket with the references and timestamps. Their team pointed to IP reputation scoring on cloud-provider traffic, explained that a blanket exception for cloud ranges was not possible, and suggested trying a different region. <strong>Update: after moving the job to a different region, a full week of scheduled runs finished with no blocks. That is a small sample, so I am keeping the retries and the logging in place.</strong> If blocks continue, the dependable fix is a static outbound IP through Cloud NAT, because a single dedicated address can be allowlisted. It adds some setup and a small monthly cost.</p>
<p>The lesson: a retry that "fixed it" once hid the problem for days. Make intermittent failures visible.</p>
<h2>9. Monitoring</h2>
<ul>
<li>A <strong>log-based alert</strong> on the job's failure message that emails me</li>
<li>An alert on Cloud Scheduler errors, in case a job never starts</li>
<li>A small dashboard of executions by result</li>
<li>A separate freshness check, because a run that succeeds but loads zero rows is not an error to Cloud Run</li>
</ul>
<h2>10. Dashboards in Looker Studio</h2>
<p>Connect Looker Studio to the BigQuery tables. The key rule is to store raw sums and <strong>calculate rates in Looker Studio</strong>:</p>
<pre><code class="language-plaintext">CTR           = SUM(clicks) / SUM(impressions)
CPC           = SUM(cost) / SUM(clicks)
CPM           = SUM(cost) / SUM(impressions) * 1000
Clicked ROAS  = SUM(halo_revenue) / SUM(cost)
Viewed ROAS   = SUM(halo_exposed_revenue) / SUM(cost)
</code></pre>
<p>Averaging the stored daily ratios gives wrong totals as soon as someone filters by campaign or date range.</p>
<p>A layout that works well for PLA reporting :</p>
<ul>
<li><strong>Overview:</strong> scorecards for spend, clicks, revenue and ROAS, plus a daily trend</li>
<li><strong>Campaigns:</strong> a table with ROAS against the campaign's ROAS goal</li>
<li><strong>Products:</strong> a leaderboard by revenue and ROAS, filterable by campaign</li>
</ul>
<h2>11. Putting the data in a Base44 app</h2>
<p>For the client-facing view I used Base44 with Individual Login system, an AI app builder, and connected it to BigQuery through its <strong>native BigQuery connector</strong>. The app runs <strong>live queries</strong> against the same two tables the Looker Studio dashboards use, so there is no export or sync step in between: when the Cloud Run job loads a new day, the app shows it.</p>
<p>The Kroger page of the app has these parts:</p>
<ul>
<li><strong>Layout:</strong> a sidebar with one page per retail media platform, and a KPI strip at the top of each page (cost, ROAS, clicks, transactions, units and revenue).</li>
<li><strong>Daily performance:</strong> a day-by-day table with revenue and cost bars, ROAS, transactions and units, read from the campaign table.</li>
<li><strong>Campaign performance:</strong> revenue, cost, ROAS, transactions, units, clicks, impressions and CTR per campaign, from the same table.</li>
<li><strong>Campaign and product performance:</strong> a drill-down by product, read from the product table.</li>
<li><strong>Date range:</strong> presets such as the last 7, 14 and 30 days and month to date, plus a custom range, applied as a filter on <code>daily_date</code>.</li>
</ul>
<p>Because every view runs a query, two habits keep it fast and cheap: filter on the partition column <code>daily_date</code> in every query, and select only the columns a view needs. Partitioned, clustered tables keep the data scanned small, even when someone picks "All time".</p>
<p>One design principle applies however you connect: never put BigQuery credentials in front-end code. Give the app a read-only path to the data, such as a service account limited to the reporting dataset behind a backend function or a small API, and keep write access for the pipeline's service account only.</p>
<h2>Lessons learned</h2>
<ul>
<li>Confirm which API and which authorization server you are using before debugging anything else.</li>
<li>Use the API's own validation errors as the source of truth for field names.</li>
<li>Reconcile your detailed report against the summary report to catch data problems early.</li>
<li>Make loads idempotent and add a lookback window, so failures do not leave permanent gaps.</li>
<li>Log evidence for intermittent failures, and do not let retries hide them.</li>
<li>Calculate ratios in the BI layer, from sums.</li>
</ul>
<h2>What's next</h2>
<ul>
<li>Add carousel and TOA reports if the client starts using them</li>
<li>Snapshot the pacing ratio daily, since the API only returns the latest value</li>
<li>Add automated data quality checks, including the campaign-versus-product reconciliation</li>
</ul>
<p><em>If you are building something similar and want to compare notes, feel free to get in touch.</em></p>
]]></content:encoded></item></channel></rss>