PPC reporting tool: one live dashboard across Google Ads and Meta
Almost every PPC reporting tool is the same three things in a trench coat: a set of platform connectors, a chart builder, and a per-seat invoice. The connectors are the only part that's genuinely hard, the chart builder is the part you'll fight with, and the invoice is the part that decides whether reporting for a fourth client is worth doing at all. This guide takes the pieces apart — what PPC reporting actually has to solve, why cross-platform numbers disagree, and how to assemble reporting that your own AI agent drives from the terminal instead of you driving a UI.
What a PPC reporting tool actually has to solve
Strip away the feature grids and there are only three real jobs:
- Get the data out, repeatedly. Not one export — a scheduled pull that survives token expiry, API quotas, and the platform restating yesterday's numbers.
- Make the platforms comparable. Google Ads and Meta each have their own idea of a conversion, a click, and a currency-denominated value. Until those are reconciled, "which channel has better CPA" is not a question your data can answer.
- Deliver something that stays true. A PDF is wrong the day after you send it. A link that re-queries live data isn't.
Everything else — themes, logo placement, drag-and-drop widgets — is packaging. Most tools spend their roadmap on the packaging because it demos well, and leave you to discover that job #2 was never really done.
Why your PPC numbers never match
If you've ever put Google Ads and Meta side by side in a spreadsheet and had the totals refuse to reconcile, you already know the failure modes. They're worth naming, because a reporting tool that doesn't handle them is just automating a wrong answer faster:
- Attribution windows differ, and they move. Meta re-attributes conversions over a trailing window of roughly a week. The ROAS you reported on Monday is not the ROAS the platform will show you on Friday for the same days.
- Conversion definitions differ. A platform-reported "purchase" and your GA4 conversion event are different populations counted by different rules. Neither is lying; they're answering different questions.
- Derived metrics get re-derived badly. CPA is cost over conversions — but averaging a column of per-campaign CPAs is not the same as dividing total cost by total conversions, and spreadsheets make that mistake silently and constantly.
- Freshness is uneven. Ad platforms report within hours; Search Console finalizes two to three days late. A "yesterday" column that mixes them is not a comparison.
The rule that saves you: normalize on ingest, not in the report. If spend, clicks, conversions and conversion value are reconciled the moment data lands, then every downstream query — every chart, every client answer — is automatically consistent. If you reconcile at report time, you reconcile forever.
A different shape: the tool your agent drives
TableBI is a data backend built for exactly this, and its interface is a CLI rather than a dashboard editor — because the intended operator is your own Claude Code session, not a human clicking through menus. Three moves:
- Pipe. Connect Google Ads and Meta once via OAuth. Data lands in a unified
factstable and in lossless per-platform raw tables underneath. - Ask. Read-only SQL, from the terminal, over either altitude — unified when you're comparing channels, raw when you're doing forensics on one.
- Pin. Turn any query set into a public read-only URL that re-runs itself as data syncs. That's the client deliverable.
Setup is about five minutes:
# install the CLI, then teach your agent to drive it npm i -g @tablebi/cli tablebi login tablebi install # one workspace per client — keeps accounts and reports separate tablebi connect google_ads -w acme tablebi connect meta_ads -w acme # anything without a live connector arrives as CSV, same definitions tablebi connect csv --file tiktok-export.csv --platform tiktok_ads -w acme
tablebi install plants a skill in ~/.claude/skills, which is the step people skip and then wonder why their agent ignores the tool. A binary on your PATH is not the same as an agent knowing it exists.
One definition of CPC, CPA and ROAS
The unified layer ships with dimensions and metric macros shared across every platform, so the arithmetic is never re-invented per report. Ask the backend what it knows:
tablebi schema -w acme --json
You get the shared dimensions — date, platform, account, campaign, ad_group, ad, channel, country — the shared measures (impressions, clicks, cost, conversions, conversion_value), and the macros that define every rate metric exactly once:
cpc(SUM(cost), SUM(clicks))— cost / clickscpm(SUM(cost), SUM(impressions))— 1000 × cost / impressionscpa(SUM(cost), SUM(conversions))— cost / conversionsctr(SUM(clicks), SUM(impressions))— clicks / impressionscvr(SUM(conversions), SUM(clicks))— conversions / clicksroas(SUM(conversion_value), SUM(cost))— revenue / costaov(SUM(conversion_value), SUM(conversions))— revenue / conversions
Because these are macros over sums rather than averages of pre-computed rates, the "average of averages" bug simply can't happen. A cross-platform PPC comparison becomes one query:
# the whole account, both platforms, last 30 days tablebi ask "SELECT platform, SUM(cost) AS spend, cpc(SUM(cost), SUM(clicks)) AS cpc, cpa(SUM(cost), SUM(conversions)) AS cpa, roas(SUM(conversion_value), SUM(cost)) AS roas FROM facts WHERE date >= (SELECT MAX(date) FROM facts) - 30 GROUP BY platform ORDER BY spend DESC" -w acme --json
And the question every PPC report is really trying to answer — where is money leaking? — is a sort, not a slide:
# campaigns burning budget with nothing to show for it tablebi ask "SELECT platform, campaign, SUM(cost) AS spend, SUM(conversions) AS conv, cpa(SUM(cost), SUM(conversions)) AS cpa FROM facts WHERE date >= (SELECT MAX(date) FROM facts) - 14 GROUP BY platform, campaign HAVING SUM(cost) > 0 ORDER BY conv ASC, spend DESC LIMIT 20" -w acme --json
For a quick read without writing SQL at all, tablebi metrics does the common case directly:
tablebi metrics --metric roas --group-by channel -w acme --json
The part most reporting tools skip: trust metadata
Every --json response carries a trust block alongside the rows: how fresh each connected source is, the lineage of each metric (the actual formula behind the number), and caveats that keep an agent from drawing a confident wrong conclusion — for instance, that Search Console clicks and GA4 sessions are different populations and shouldn't be expected to match.
This matters more with an AI in the loop than it ever did with a human analyst. A human knows Monday's Meta ROAS is provisional. An agent will happily write "ROAS fell 18% week-over-week" about a week that hasn't finished re-attributing — unless the data tells it not to. Check freshness before you report:
# connected sources + how far behind each one is tablebi sources -w acme --json # full rehydrate: definitions, sources, freshness, existing dashboards tablebi context -w acme --json
Pinning the report: the deliverable is a URL
Here's where the per-seat economics break in your favour. A pinned dashboard stores the query, not a snapshot — so it re-runs against live data every time someone opens it, and "someone" can be your whole client team without buying anyone a viewer licence.
tablebi pin --title "Acme · PPC performance" \ --widget "Daily spend (30d)::line=SELECT date, SUM(cost) AS spend FROM facts …" \ --widget "CPA by platform=SELECT platform, cpa(SUM(cost), SUM(conversions)) AS cpa FROM facts …" \ --widget "Top campaigns by spend=SELECT campaign, SUM(cost) AS spend FROM facts …" \ -w acme ✓ published → https://dk.tablebi.com/d/dsh_… (public, read-only, self-refreshing)
Here's a real pinned dashboard, built exactly this way — it isn't a mockup, and it refreshes as its sources sync: