The data model
The warehouse holds the advertising statistics of every platform in one model, at the grain of a campaign and a day, with the clients of the business, the conversions of each conversion action, the sessions of web analytics and the exchange rates into one reporting currency (ADR-0024, ADR-0025). Metabase reads its marts only. Today its data comes from the demo: a fictional business on four fictional platforms in the shapes of real APIs, with a fictional source of exchange rates. The connectors of real platforms are planned.
Layers
| Schema | What it holds | Written by | Read by |
|---|---|---|---|
raw_<platform> |
The responses of each platform as dlt loads them: nested objects become columns joined by __, and lists become child tables |
loader |
transformer |
raw_<platform>_staging |
The rows of a load on their way into raw_<platform> |
loader |
nobody else |
raw_settings |
The settings of the model of the deployment and the clients of the demo, loaded at the start of each run of the transform | loader |
transformer |
staging |
A view for each table of each platform and of the settings, in the names of the model (stg_<platform>__<table>, stg_settings__<table>); the steps that the platforms share (int_<entity>__<verb>), views as well; and the seeds of the base package (seed_…) |
transformer |
nobody else |
marts |
The tables of the model, rebuilt in full on each run of the transform | transformer |
metabase_reader |
ops |
The tables in which elementary records each run of dbt and its tests, and the four views of the Ops collection over them (The schema ops) | transformer |
metabase_reader, the four views only |
The staging views of a platform are calls of one macro for each shape of an API (dbt/macros/shapes/), so that the demo platform demo_search and the planned connector of Google Ads are to share one. dbt replaces a view or a table by dropping the old one with every view that depends on it, so build nothing of your own on a relation of staging or marts: put a question of your own in Metabase, whose SQL PostgreSQL does not track. Between two tasks of one run, and after a run that a test stopped, a view of staging can be missing; the marts keep their data.
The marts
| Mart | One row is | Key |
|---|---|---|
ad_stats_campaign_daily |
A campaign on a day: impressions, clicks, spend, and the conversions of its primary conversion actions | report_date, platform, account_id, campaign_id |
ad_conversions_campaign_daily |
A campaign, a day and a conversion action: conversions and their value | the key above and conversion_action_id |
web_sessions_daily |
A property of web analytics on a day, by source, medium and campaign: sessions, engaged sessions, key events and revenue | report_date, property_id, session_source, session_medium, session_campaign_name, session_campaign_id |
ad_platforms |
A platform: its name, its kind, and for a demo platform the API that it mimics | platform |
ad_accounts |
An ad account: its client, currency, time zone and status, and the first and last day of its statistics | platform, account_id |
clients |
A client of the business: a brand, shop or legal entity | client_id |
ad_campaigns |
A campaign: its current name, status and type, the days of its statistics, and how many names it had | platform, account_id, campaign_id |
ad_campaign_names |
A name that a campaign had, from when and until when | platform, account_id, campaign_id, valid_from |
fx_rates_daily |
A currency on a day: its rate into the reporting currency, rate_into_reporting_currency |
rate_date, currency, reporting_currency |
Every column has a description in the database and in Metabase. The facts carry the names of their platform, client, account and campaign, so that a question needs no join. The marts have primary keys and no foreign keys: tests check the relationships.
Clients
A deployment is one business, which groups its ad accounts into clients (ADR-0024). Name them in extensions/model/clients.toml:
[clients.acme]
name = "Acme Outdoor"
[[accounts]]
platform = "google_ads"
account_id = "1234567890"
client = "acme"
A client ID is lower-case letters, digits, - and _, at most 64 characters, and starts with a letter or a digit; unassigned is reserved. The client of an account is one that the same file names in [clients]. An account ID is written as the API of its platform gives it, such as 1234567890 for a customer of Google Ads, without the dashes that its UI shows. Today the data has the accounts of the demo only, so an account of a real platform counts once its connector loads it.
An account that no file names belongs to the client unassigned, named Unassigned, and a test warns about it. The first task of each run of the transform reads the file and checks it before anything is written; a mistake fails that task with one line that names the file and the entry, and the marts keep their data. A change takes effect at the next run of the transform: trigger agoranomos_demo_transform in Airflow to see it now. The clients of the demo come from a file of the same form in the base package and show in clients only while the data has accounts or properties of the demo; a client or an account that both name is the deployment's. Configuration lists the limits of the files.
Conversions
Each platform reports conversions by conversion action: an action of Google, an action type of Meta, a metric of TikTok. ad_conversions_campaign_daily has every action, with a category (purchase, lead, sign_up, add_to_cart, app_install or other) and whether it is primary. The conversions and conversion_value of ad_stats_campaign_daily are the sums of its primary actions. The seed seed_conversion_actions of the base package says which actions are primary; a deployment overrides a row, or sets an action of its data that the seed does not name, in extensions/model/conversion_actions.toml:
[[actions]]
platform = "demo_social"
conversion_action_id = "offsite_conversion.fb_pixel_lead"
is_primary = false
conversion_category = "lead"
platform, conversion_action_id and is_primary are required, and conversion_category is optional: an entry without it keeps the category of the seed, or the one that its platform reports. An action that neither file names is not primary, and its category is the one that its platform reports, or other. Meta counts a purchase both by the pixel and in omni_purchase: in the seed the pixel's is primary, and only one of them should be, or a purchase counts twice.
Currencies
Money is numeric(18,6), and no amount is rounded to cents in the marts. A fact has its money in the currency of its account (spend, conversion_value, beside currency) and in the reporting currency (spend_reporting, conversion_value_reporting, beside reporting_currency); the sessions have total_revenue and total_revenue_reporting in the same way. An amount in the reporting currency is the amount of the account times the rate of its day, to six places. Add up money across accounts only in the columns that end in _reporting: Metabase shows them as money, and the others as plain numbers. The reporting currency is the setting AGORANOMOS_REPORTING_CURRENCY (Configuration), USD or EUR today.
The rate of a day is the reference rate of the euro published on that day, or the last one before it on a day without a publication, such as a weekend; fx_rates_daily.is_carried_forward says which. A rate between two currencies goes through the euro: one unit of a currency is worth rate(EUR → reporting) / rate(EUR → currency) units of the reporting currency, to eight places. The reporting currency has the rate 1 on every day, and another currency has a rate from the first day that the source published one. The rates of the demo are fictional; a source of real rates is planned with the connectors.
Days and time zones
report_date is the day in the time zone of the account (ad_accounts.time_zone), as the platforms report it: a daily total cannot be moved into another zone. A sum over accounts in different time zones adds up days that cover different hours, and the last day with data can differ by account. In the Etc zones of the tz database the sign is that of POSIX: Etc/GMT+8 is eight hours behind UTC.
Names of campaigns
Every row of a campaign carries its current name, so that a group by name does not split a renamed campaign. ad_campaign_names keeps the names that each campaign had, as the loads saw them; web_sessions_daily.session_campaign_name keeps the name that the links carried on the day. Link a session to its ad campaign by the campaign ID in utm_id: web_sessions_daily.ad_platform and ad_account_id name the campaign whose ID the links carried, when exactly one campaign of the data has it.
Quality
The tests of dbt run right after their model, in the same task, dbt build (ADR-0028). The critical ones are in staging, on the staging views and on the steps that the platforms share: a failure stops the models that read the model, and the marts keep their data. They check that the keys are unique and not null, that counts and money are not negative, that every currency is in ISO 4217 and every time zone in the tz database of IANA, that every day and currency of the facts has a rate, that every account and campaign of the facts is known, and that the primary actions add up to the conversions of each campaign. Warnings do not stop anything: a day missing in the data of an account, more clicks than impressions, and an account without a client. The package elementary records the status of every test of every run, and the number of rows that it found, in the schema ops, which the Ops collection shows (Watch the pipeline).
The schema ops
The schema ops holds the tables of the package elementary, in which its hooks record each run of dbt, each model, seed and test of it, and the models and tests of the project, and the views of the project that the Ops collection reads (ADR-0029). A test records its status and the number of rows that it found, never the rows. metabase_reader reads the four views and no table of elementary, which also keep the SQL of the models and the settings of each run.
| View | One row for | What it holds |
|---|---|---|
ops_loads |
Each account of each platform | The last day that a load took for the account, when it was loaded, how many hours ago, and how many loads the record holds, from _agoranomos_reloads of each schema raw_<platform> |
ops_transform_nodes |
Each model, seed and test in each run of dbt | Its status, its seconds, when it started and ended, the rows that it changed or found, what dbt said of it, and the DAG and the run of Airflow whose task ran dbt |
ops_transform_tests |
Each test in each run of dbt | The model and the column that it checks, its kind, its severity, its status and the rows that it found |
ops_warehouse_tables |
Each table of the warehouse | The rows that the last ANALYZE estimated and the bytes that the table takes with its indexes, when the view is read |
A task of the transform runs dbt for one node, so one run of the transform is many runs of dbt, which share airflow_run_id. dbt rebuilds the views on each run; elementary keeps the rows of its tables, and nothing removes old rows yet.
The demo platforms
| Code | Mimics | What it gives |
|---|---|---|
demo_search |
Google Ads API v25 | Search and Performance Max campaigns of two accounts under a manager account, cost in micros, conversions by conversion action |
demo_social |
Meta Marketing API v26.0 | Insights of campaigns with their actions and action values |
demo_video |
TikTok API for Business v1.3 | The daily report of campaigns, in an account whose time zone is Etc/GMT+8 |
demo_web |
GA4 Data API v1beta | Sessions of two properties by source, medium, campaign and channel group |
demo_fx |
none | Reference rates of the euro in US dollars, on weekdays but four holidays |
The demo has a fictional business with two clients, Tarnwick Outfitters and Brindlemoor Haus, and 18 months of history up to the day before its first start, in the time zone of each account, to which a daily load adds each new day (See the demo). The codes of real platforms, such as google_ads, stay for their connectors.
Names compared with Fivetran
The model follows the design of Fivetran's dbt package dbt_ad_reporting (v2.8.1, Apache License 2.0, read on 19 October 2026) and takes none of its code. For those who know it:
| Agoranomos | dbt_ad_reporting |
|---|---|
ad_stats_campaign_daily |
ad_reporting__campaign_report |
report_date |
date_day |
platform |
platform |
platform, one schema raw_<platform> for each |
source_relation, the source of a row when several are unioned |
account_id, account_name |
account_id, account_name |
campaign_id, campaign_name (the current name) |
campaign_id, campaign_name (the most recent) |
impressions, clicks (clicks to the site) |
impressions, clicks (link clicks of Meta) |
spend (in the currency of the account), currency |
spend (in the currency of the account), no column of the currency |
spend_reporting, reporting_currency |
none |
conversions, conversion_value (the primary actions) |
conversions, conversions_value |
ad_conversions_campaign_daily |
none: a variable lists the action types of Meta |
client_id, client_name |
none |
Upgrading from v0.1
The first ./agoranomos up of v0.2.0 removes the demo of v0.1 from the warehouse: the schemas raw_demo and raw_demo_staging, the view staging.stg_demo__ad_stats_daily and the mart marts.ad_spend_daily. With the demo profile, it then loads the history of the new demo and builds the model. The collection Agoranomos demo moves into the collection Agoranomos as Demo, and its dashboard reads the new marts. A question of your own on marts.ad_spend_daily stops working: point it at marts.ad_stats_campaign_daily, whose spend_reporting is the spend in one currency. An .env of v0.1 keeps WAREHOUSE_DB_MEMORY=256m and AIRFLOW_TRIGGERER_MEMORY=640m, which ./agoranomos init does not change: set them to 384m and 512m, the new defaults, before that start (Update to a new release).