Turn per-team database cost attribution data into a self-contained monthly chargeback report you can send to finance.
Attributing database spend is the hard part, but it is not the last part. Once you have a
tidy table of period, team, cost, someone still has to turn it into a document that a
finance business partner will accept — one that explains itself, survives being forwarded as
an email attachment, prints to a clean PDF, and holds up when a team lead says "that number
is wrong".
The usual options are a notebook whose output nobody else can open, a BI dashboard behind SSO that finance does not have, or a spreadsheet that quietly stops footing after someone rounds per-row. This tool takes the attribution CSV you already produce and emits one HTML file with every chart and stylesheet inlined. No CDN, no JavaScript, no network. Open it from a mail attachment on a plane and it renders identically.
It also does the two things people always ask for immediately afterwards: it says in plain English what moved, and it flags the movements that are actually unusual for that particular team rather than the ones that merely look big.
The generated report — header, executive summary, breakdown table, charts, anomaly callouts, budget vs actual (full page, rendered in Chromium):
preview renders the same content in the terminal, which is what you want in CI:
diff prints just the movements between two periods:
The charts are hand-authored inline SVG — no matplotlib, no chart library — and are lifted out of the report unchanged here:
The real artifact is the generated example report itself: a single ~38 KB file with everything embedded. Clone the repo and open it locally — GitHub's file view shows the source rather than rendering it, though the htmlpreview service will render it from the raw URL if you want a look without cloning.
Not published to PyPI — clone it and run it.
git clone https://github.com/db-cost-quota/chargeback-report-generator.git
cd chargeback-report-generator
python -m venv .venv
.venv/bin/pip install -e ".[dev]"Everything below runs offline against the bundled 14-month, six-team example dataset.
# Check the input before you trust it
.venv/bin/python -m chargeback_report_generator validate \
-i examples/attribution.csv -c examples/config.yaml -b examples/budgets.yaml
# Eyeball the report in the terminal
.venv/bin/python -m chargeback_report_generator preview \
-i examples/attribution.csv -c examples/config.yaml -b examples/budgets.yaml
# Build the report
.venv/bin/python -m chargeback_report_generator build \
-i examples/attribution.csv -c examples/config.yaml -b examples/budgets.yaml \
-o report.html
xdg-open report.html # or open / startIf you install the package into a virtualenv, the console script chargeback-report is
equivalent to python -m chargeback_report_generator.
Every amount is a decimal.Decimal from the moment it is parsed. Sums are exact; rounding to
cents happens once, at display time.
That last part matters more than it sounds. If you round each team's total independently, the
column does not add up to the reported total — twelve rows of 1.005 round to 12.12 while
the true total rounds to 12.06. So the per-group figures are rounded with largest-remainder
allocation: round everything to cents, then hand the residual cents back to the groups whose
rounding lost the most. The table always foots exactly, and there is a test that proves it on
adversarial inputs.
Deltas compare the reported period against the immediately preceding period present in the
data (not the calendar — validate warns separately about calendar gaps).
Percentages are deliberately omitted, and shown as n/a, whenever the prior value is zero.
A percentage against a zero baseline is undefined, and "+∞%" in a document going to finance is
worse than an honest blank. New teams are labelled new and carry an absolute figure only;
teams that stop billing are labelled ended with a −100% change.
Two independent signals, both of which must clear a minimum absolute change so that noise on tiny teams is not promoted to a callout:
- Percent change against the previous period. This is what a human notices, and it is the signal a team lead will check you on.
- Robust z-score against the team's own history, computed from the median and the median absolute deviation (MAD), scaled by 0.6745 so it is comparable to a normal z-score.
The second exists because the textbook mean/standard-deviation z-score is close to useless here: a single large spike inflates the standard deviation enough to hide itself. MAD is unaffected by up to half the samples being outliers, so a genuine one-month spike scores in the double digits while a team that swings 40% every month does not fire every month. A team needs a configurable minimum number of prior periods before the z-score is considered at all, and if a team's history is perfectly flat the score is reported as undefined rather than infinite.
Three hand-authored inline SVGs: a stacked bar of spend by team over the history window, a horizontal bar of the current period's split, and a trend line of total spend.
Horizontal bars rather than a treemap for the split, because treemap tiles for small teams are too narrow to hold a label without clipping — and the entire point of that section is that every team can read its own line.
Each chart carries role="img" with a <title> and a <desc> stating the actual numbers, so
a screen-reader user gets what a sighted reader gets from the picture. The palette is
Okabe-Ito minus the pale yellow: distinguishable under deuteranopia, protanopia and
tritanopia, and separable by lightness on a monochrome office printer. Axis ranges snap to
readable boundaries, tick labels thin themselves out when the bands get narrow, and the legend
wraps on measured text width, so labels do not overlap or spill outside the viewBox. Tests
assert all of that.
- No FX conversion. Rows must arrive pre-normalised to one currency. Mixed currencies produce a warning telling you which codes to fix upstream, not a silent conversion at some rate this tool invented.
- No cost attribution. This is a reporting tool. It presents whatever attribution you fed
it; it cannot tell you whether your allocation keys are fair. Document them in
methodologyso the appendix can defend them. - Monthly granularity. Periods are
YYYY-MM. Daily reporting is a different shape of problem and a different tool. - The z-score needs history. With fewer than
min_historyprior periods only the percent change fires. On a brand-new dataset expect the first few reports to be noisier. --group-byaffects the table, not the anomaly detector. Anomalies are always computed per team, because a per-team-per-app z-score on a sparse grid is mostly noise.
All subcommands share the input flags:
| Flag | Meaning |
|---|---|
--input, -i |
Attribution data: .csv, .json or .jsonl. Required. |
--config, -c |
config.yaml. Optional; defaults are used without it. |
--budgets, -b |
budgets.yaml. Optional; omit to drop the budget section. |
--period, -p |
Billing period to report, e.g. 2026-06. Defaults to the latest in the data. |
--periods |
History window length for charts and sparklines. Overrides the config. |
--group-by |
Comma-separated dimensions: team,app,service,environment,tenant. Default team. |
--generated-on |
Pin the report date (YYYY-MM-DD) so output is byte-reproducible in CI. |
python -m chargeback_report_generator build \
-i examples/attribution.csv -c examples/config.yaml -b examples/budgets.yaml \
-p 2026-06 --periods 12 --group-by team,app \
-f html --theme light -o report.html--format html— the self-contained report. The default, and the point of the tool.--format email-html— table-based layout with inline styles only, no<style>block. Outlook's rendering engine ignores most modern CSS and some clients strip<style>; this variant uses the attributes-and-inline-declarations approach that has worked since 2005.--format csv/--format json— the same numbers, machine-readable. The CSV carries aTOTALrow that reconciles with the body; the JSON carries the whole report including summary prose, anomalies, pools and budgets under achargeback-report/v1schema tag.--format pdf— needs the optional extra (below).--theme light|print—printflattens the card borders for paper.--template-dir DIR— a directory containing your ownreport.html.j2and/oremail.html.j2. Anything absent falls back to the packaged template.
Renders the whole report — summary, table, sparklines, pools, anomalies, budgets — as rich
terminal output. Useful for reviewing a report in a CI log without downloading an artifact.
--width N forces the console width for reproducible output.
Compares two periods and prints only the movements.
python -m chargeback_report_generator diff -i examples/attribution.csv --against 2025-05
python -m chargeback_report_generator diff -i examples/attribution.csv -f csv -o moves.csv--against names an explicit baseline (default: the period immediately before --period),
and --format table|csv|json selects the output. Useful standalone in a monthly review.
Checks the input schema, the config and the budgets, and reports:
- every bad row at once, with the row number and the specific field — not just the first one, because fixing a billing export one error per run is miserable;
- currency codes that do not match the reporting currency;
- budgets defined for teams that have no cost rows;
- calendar months between the first and last period that have no data at all.
Exit codes: 0 fine, 2 bad input or config, 3 PDF requested without WeasyPrint.
PDF is optional because its dependency chain is not:
pip install -e ".[pdf]" # WeasyPrint; also needs pango, cairo and gdk-pixbuf
python -m chargeback_report_generator build -i ... -f pdf -o report.pdfWithout it, build -f pdf exits with a message telling you exactly this. The
no-dependency path is the browser: open the HTML report and use Print → Save as PDF. The
report ships @media print rules with an A4 page box, repeating table headers, and
break-inside: avoid on charts and callouts, so this produces a clean document rather than a
screenshot of a web page.
examples/config.yaml is fully commented; every key is optional.
currency: USD
periods: 14
theme: light
brand:
org_name: Northwind Data Platform
report_title: Database Chargeback Report
accent: "#1f4e79"
logo_path: logo.png # embedded as a data URI, relative to this file
data_source: >-
AWS Cost and Usage Report joined to the service catalogue on the owner_team tag.
coverage_note: >-
94.2% of database spend carries an owner_team tag; the remainder is allocated pro-rata.
units_label: USD / 1k queries
measured_pools: [compute, storage]
allocated_pools: [io, backup]
methodology:
- Shared Aurora clusters are split by each team's share of pg_stat_statements total_exec_time.
anomalies:
pct_change: 25 # flag a period-over-period move beyond ±25%
z_score: 3.5 # ...or a robust z-score beyond ±3.5
min_abs_change: 1500 # ...but only if the absolute change clears this
min_history: 4 # periods of history required before the z-score is usedmeasured_pools and allocated_pools drive the "Basis" column and the appendix. Being
explicit about which pools are metered and which are estimated is what makes the report
survivable in an argument.
budgets.yaml accepts a shorthand mapping or full objects:
teams:
platform-core:
monthly: 46000
note: Includes the shared Aurora control plane.
checkout: 34000One row per (period, team, app, service) — or per resource; the tool aggregates.
| Column | Required | Notes |
|---|---|---|
period |
yes | YYYY-MM. |
team |
yes | The chargeback owner. |
cost |
yes | Decimal. Commas, $ and negatives (credits) are accepted. |
app |
no | Defaults to -. |
service |
no | Defaults to -. |
currency |
no | Three-letter code, defaults to USD. Must be consistent. |
environment |
no | Available to --group-by. |
tenant |
no | Available to --group-by. |
units |
no | Denominator for the cost-per-unit column: active tenants, 1k queries, whatever units_label says. |
| anything else numeric | no | Treated as a cost pool and shown in the cost-pools section (compute, storage, io, backup, …). |
Non-numeric extra columns are ignored rather than rejected, so you can carry cost-centre codes through without stripping them first.
period,team,app,service,environment,cost,currency,units,compute,storage,io,backup
2026-06,platform-core,auth-api,aurora-postgres,prod,7425.80,USD,223783,4559.00,1756.77,783.16,326.87Group the CUR by your ownership tag and the service dimension, then pivot to the columns above:
-- Athena over the CUR, one row per month/team/service
SELECT
date_format(line_item_usage_start_date, '%Y-%m') AS period,
coalesce(resource_tags_user_owner_team, 'unattributed') AS team,
coalesce(resource_tags_user_app, '-') AS app,
product_product_name AS service,
sum(line_item_unblended_cost) AS cost,
'USD' AS currency,
sum(CASE WHEN line_item_usage_type LIKE '%InstanceUsage%'
THEN line_item_unblended_cost ELSE 0 END) AS compute,
sum(CASE WHEN line_item_usage_type LIKE '%StorageUsage%'
THEN line_item_unblended_cost ELSE 0 END) AS storage,
sum(CASE WHEN line_item_usage_type LIKE '%IOUsage%'
THEN line_item_unblended_cost ELSE 0 END) AS io
FROM cur.line_items
WHERE product_product_family IN ('Database Instance', 'Database Storage')
AND line_item_usage_start_date >= date_add('month', -14, current_date)
GROUP BY 1, 2, 3, 4
ORDER BY 1, 5 DESC;Untagged shared clusters are the usual gap. Attribute them by each team's share of execution time, then multiply through the cluster's monthly bill:
-- PostgreSQL: per-team share of a shared cluster, from pg_stat_statements.
-- Assumes you map rolname -> team, and that you snapshot and reset this monthly.
SELECT
r.rolname AS team,
sum(s.total_exec_time) AS exec_ms,
sum(s.calls) AS calls,
sum(s.total_exec_time)
/ nullif(sum(sum(s.total_exec_time)) OVER (), 0) AS cost_share
FROM pg_stat_statements s
JOIN pg_roles r ON r.oid = s.userid
GROUP BY r.rolname
ORDER BY exec_ms DESC;Record what you did in the methodology list in config.yaml — it renders into the appendix,
and that is the section that ends the argument.
build is deterministic: same input, same --generated-on, byte-identical output. That makes
it safe to commit the report or diff it against last month's.
python -m chargeback_report_generator validate -i attribution.csv -c config.yaml || exit 1
python -m chargeback_report_generator build \
-i attribution.csv -c config.yaml -b budgets.yaml \
-o "reports/$(date +%Y-%m).html"
python -m chargeback_report_generator build \
-i attribution.csv -c config.yaml -b budgets.yaml \
-f email-html -o "reports/$(date +%Y-%m).email.html".venv/bin/ruff check .
.venv/bin/ruff format --check .
.venv/bin/mypy src
.venv/bin/pytest -q295 tests covering the rounding reconciliation, delta edge cases (new teams, departed teams, zero baselines), the robust z-score, budget utilization, currency handling, Jinja autoescaping against injected markup in team names, HTML structure via a parser, every SVG parsing as XML with finite coordinates and in-bounds labels, the self-contained property, the print CSS, and CSV/JSON agreement with the HTML.
Regenerating the demo artifacts under docs/:
.venv/bin/python examples/generate_data.py # only if the sample data changed
.venv/bin/python docs/generate_screenshots.pyThe terminal screenshots come from rich's recording console; the full-page report screenshot
uses headless Chromium if one is on PATH and is skipped cleanly if not. Both are pinned to a
fixed report date so re-running does not churn the diff — and tests/test_cli.py fails if
docs/example-report.html drifts from what the current code produces.
Background on the practices this tool automates:
- Chargeback reporting automation — the overall shape of a monthly chargeback cycle, and where it usually breaks.
- Allocating shared database cost with tenant keys — choosing an attribution key you can defend, which is the input this tool assumes.
- Detecting cost spikes with a rolling z-score — why robust statistics beat mean and standard deviation on spend series.
- Schema validation for billing data — validating a cost export before it reaches a report.
MIT — see LICENSE.
