Skip to content

feat(marketing-analytics): add a ROAS column driven by revenue conversion goals#73052

Draft
jabahamondes wants to merge 6 commits into
javier/ma-mcp-write-toolsfrom
javier/ma-roas-column
Draft

feat(marketing-analytics): add a ROAS column driven by revenue conversion goals#73052
jabahamondes wants to merge 6 commits into
javier/ma-mcp-write-toolsfrom
javier/ma-roas-column

Conversation

@jabahamondes

Copy link
Copy Markdown
Contributor

Note

Sits on top of the conversion-goal stack (#73033 → ... → #72301) and also carries the currency fix from #73047 (cherry-picked, not merged), because ROAS is only correct once revenue is converted to the base currency. This branch is the one to check out to try the whole thing end to end: flags, write endpoints, MCP tools, currency conversion, and now ROAS. When #73047 and the stack land on master, a rebase drops the duplicated currency commit.

Problem

Marketing analytics can show you spend per channel and conversions per channel, but not the one ratio a marketer actually optimizes: revenue over spend. The pieces were all there (spend per channel, a per-goal value column, currency conversion from #73047) but nothing tied them together.

Changes

A ROAS column: the summed value of every conversion goal marked counts_as_revenue, divided by that channel's spend.

It reuses the exact pattern the existing "cost per conversion" column already uses (campaign_costs.total_cost over a nullif'd conversion value from the unified goals CTE), just inverted and summed across the revenue-flagged goals. Built in conversion_goals_aggregator.get_conversion_goal_columns, so it flows to the table through the same all_columns.update(...) every other conversion column does.

Three deliberate boundaries:

  • Hidden until revenue goals exist. No goal flagged counts_as_revenue means no column. The table doesn't grow an empty ROAS column for teams that haven't set one up. The existing table and aggregator snapshots are unchanged, which is the proof it's inert by default.
  • Only when spend is available. ROAS needs spend as its denominator, so it rides the same include_cost_per guard the cost-per column uses. At UTM levels where platform cost can't be attributed, both are correctly absent.
  • Revenue is already in the base currency. Thanks to fix(marketing-analytics): convert conversion goal revenue to the team base currency #73047, each goal's summed value is converted at its event-day rate before it ever reaches this column. Without that, ROAS would divide a mixed-currency numerator by a converted denominator.

No new column metadata was needed. ROAS falls through to_marketing_analytics_data to the unit / higher-is-good defaults, which is exactly right for a ratio where more is better.

How did you test this code?

3 new unit tests in test_conversion_goals_aggregator.py, run by me (Claude):

  • Numerator sums only the revenue goals. Three goals, two flagged revenue at indices 1 and 2, one plain signup at 0. Asserts the ROAS expression references the value columns of 1 and 2 and not 0. This is the core regression: if the flag filter breaks, a signup count would get added into revenue.
  • Absent when no goal is revenue. Guards the "don't show an empty column" boundary.
  • Absent without campaign_costs (include_cost_per=False). Guards the denominator boundary at UTM levels.

Full test_conversion_goals_aggregator.py (30), test_marketing_analytics_table_query_runner.py and _business.py (79) pass, snapshots unchanged. ruff clean; mypy clean on the file (the one printed error is a pre-existing unrelated one in personhog_client).

I did not add an end-to-end ClickHouse test that asserts a computed ROAS number. The column's construction is covered by the unit tests, and its propagation into the final SELECT is the same generic update() path already exercised by the cost-per columns. An end-to-end test would have needed a full cost + conversion fixture to cover a path that isn't new.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

Holding until the read-side columns settle and the dashboard redesign decides how they surface.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

I directed this, Claude wrote it. Invoked /writing-tests, which drove the test list toward the one assertion that matters (numerator sums only revenue goals) and away from re-testing the round/nullif plumbing the cost-per column already covers.

This is the first of the two read-side metric columns. CAC is deliberately not in this PR. It turned out to be structurally larger than ROAS: "new customers" means each person's first-ever conversion, which needs a historical first-time-per-user scan that the current per-window precompute doesn't provide (first_time_for_user isn't wired into the marketing processor at all yet). That deserves its own design pass rather than being bolted on here, so ROAS ships on its own.

… base currency

A sum-math conversion goal summed its math_property raw, ignoring
math_property_revenue_currency. Goals whose revenue property mixes currencies were
added together as if they were one, and the total was in no currency at all. Now each
value is converted at its own event-day exchange rate, in both the live and precompute
paths so they agree. Goals without a currency configured are unchanged.
…sion goals

ROAS per channel is the summed value of every conversion goal marked
counts_as_revenue, over that channel's spend. It only appears when at least one
goal is flagged as revenue and when spend is available for the level, so it stays
hidden until revenue goals exist. Revenue is already in the base currency thanks
to the conversion-goal currency conversion.
@jabahamondes jabahamondes self-assigned this Jul 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 CI report

ℹ️ MCP UI apps size — 31 app(s), 16602.7 KB JS

Built size of each MCP UI app (main.js + styles.css).

App JS CSS
debug 599.5 KB 187.4 KB
action 457.8 KB 187.4 KB
action-list 564.3 KB 187.4 KB
cohort 456.8 KB 187.4 KB
cohort-list 563.3 KB 187.4 KB
email-template 456.6 KB 187.4 KB
error-details 472.4 KB 187.4 KB
error-issue 457.5 KB 187.4 KB
error-issue-list 564.2 KB 187.4 KB
experiment 561.5 KB 187.4 KB
experiment-list 565.1 KB 187.4 KB
experiment-results 563.2 KB 187.4 KB
feature-flag 567.1 KB 187.4 KB
feature-flag-list 570.9 KB 187.4 KB
feature-flag-testing 461.0 KB 187.4 KB
insight-actors 562.1 KB 187.4 KB
invite-email-preview 456.0 KB 187.4 KB
llm-costs 559.5 KB 187.4 KB
session-recording 458.6 KB 187.4 KB
session-summary 463.9 KB 187.4 KB
survey 458.4 KB 187.4 KB
survey-global-stats 562.2 KB 187.4 KB
survey-list 565.0 KB 187.4 KB
survey-stats 562.2 KB 187.4 KB
trace-span 457.2 KB 187.4 KB
trace-span-list 564.2 KB 187.4 KB
workflow 457.1 KB 187.4 KB
workflow-list 563.7 KB 187.4 KB
query-results 744.8 KB 187.4 KB
render-ui 825.3 KB 187.4 KB
visual-review-snapshots 461.6 KB 187.4 KB

@trunk-io

trunk-io Bot commented Jul 23, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant