Background
I run a set of small sites on Google Cloud Platform. Usage-based billing means the number is not final until the invoice arrives at the end of the month, and one bad setting can move it.
Opening the Cloud Console billing report every day did not last. A habit that does not survive a busy week is not a safeguard.
So I put the number where I already look: my own dashboard. The whole thing is GCP's billing export plus two SQL queries.
Billing export is a one-time setting that makes GCP append your billing line items to a BigQuery table every day. BigQuery is Google's analytics warehouse, so once the rows are there you can just ask SQL for them.
How it works
There is no service in the middle. The pipeline below is the whole thing, from the one-time console setting to the field my dashboard renders.
GCP billing export (configure once)
-> BigQuery table <project>.billing_export.gcp_billing_export_v1_<billing account id>
-> bq query (two aggregations)
-> one field in my dashboard snapshot
I keep two queries instead of one, because they answer different questions.
| Question | Column to filter on | Grouping |
|---|---|---|
| What am I spending on right now? | invoice.month |
per service |
| When did it start going up? | usage_start_time |
per day, last 30 days |
Those columns are not interchangeable. invoice.month is the month a line item is billed in; usage_start_time is when the resource was actually used. Usage near a month boundary can land on the next invoice, so reusing one query for both questions gives you a wrong answer that still looks plausible.
Implementation
Current month, grouped by service:
SELECT service.description svc, ROUND(SUM(cost),0) cost
FROM `<project>.billing_export.gcp_billing_export_v1_<billing account id>`
WHERE invoice.month = FORMAT_DATE("%Y%m", CURRENT_DATE())
GROUP BY svc
HAVING cost > 0
ORDER BY cost DESC
invoice.month is a string like 202609, so I build today's year-month with FORMAT_DATE("%Y%m", CURRENT_DATE()) and compare strings. HAVING cost > 0 keeps unused services out of the table so the panel stays readable.
Run with --format=json and you get:
[{"svc":"Cloud Storage","cost":"123"},{"svc":"Cloud Run","cost":"45"}]
Note that cost comes back as a string. Summing it without Number() gives you string concatenation, not a total.
The caller shapes that into a total plus a breakdown:
const rows = JSON.parse(out);
if (!rows.length) return null;
const total = rows.reduce((a, r) => a + Number(r.cost), 0);
return { total, currency: "JPY", services: rows.map((r) => ({ service: r.svc, cost: Number(r.cost) })) };
The line I thought about longest is if (!rows.length) return null;.
Billing export only contains data from the moment you enable it, and the command fails outright if credentials have expired. In both cases the correct answer is "unknown", not "zero". Returning 0 would render as "GCP cost this month: 0" — the same pixels a genuinely free month produces. Returning null lets the view say "not configured" or "unavailable" instead.
Gotchas
Do not pass the SQL through a shell if the same code has to run on Windows.
Measured on 2026-07-30: every run left two empty (0 byte) files in the repository root, named
0
DATE_SUB(CURRENT_DATE()
cmd.exe does not treat single quotes as quoting, so > and ( inside the SQL were read as shell metacharacters. HAVING cost > 0 became "redirect output into a file called 0".
Before, broken:
execSync(`bq query --use_legacy_sql=false --format=json '${q}'`);
After, fixed:
execFileSync("bq", [
"query",
"--project_id=<project>",
"--use_legacy_sql=false",
"--format=json",
q,
], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
With an argument array the string never goes through a shell, so no OS quoting rules apply. The junk files stopped appearing.
Two smaller notes. --use_legacy_sql=false is required; without it the queries above are syntax errors. And 2>/dev/null is POSIX-only — on Windows it does nothing, so silence stderr with stdio: ["ignore", "pipe", "ignore"] instead.
The result
A site that runs on this: https://hashitosystem.com
Wrap-up
The SQL is the easy half. The design decision that mattered was what to return when the measurement fails.
Give "could not measure" its own value, distinct from 0. Otherwise a dead pipeline renders as a free month, and the panel you built to reassure yourself starts hiding the thing you built it to catch.
And when you hand a string full of punctuation to an external CLI, pass it as an argument array. That applies to SQL, regular expressions, and JSON alike.
This article is about my own side project. It was written with AI assistance.
Top comments (0)