Skip to content

feat: measure real GCP usage in deployml costs - #74

Open
andrewtclim wants to merge 6 commits into
mainfrom
andrew/deployml-costs
Open

andrewtclim wants to merge 6 commits into
mainfrom
andrew/deployml-costs

Conversation

@andrewtclim

Copy link
Copy Markdown

Supersedes #64 (its commits da56a5f and ed65724 are carried over here, so nothing is lost). Closing that one.

deployml estimate got usable in #64: it feeds infracost a light/heavy usage profile and splits the output into always-on vs usage-based. But deployml costs never got the same treatment — it just re-ran the zero-usage scan against the deployed Terraform, so it printed exactly the misleading numbers the redesign existed to kill: Cloud Run, BigQuery and GCS at $0, only Cloud SQL real. It answered "what would this cost if nobody used it", which isn't a question anyone has. The follow-up section of #64 named this as the next step.

This finishes the pair: estimate = before you deploy, costs = what you're actually running at.

summary

  • deployml costs now measures real usage instead of assuming it. New src/deployml/utils/measured_usage.py reads Cloud Monitoring over a window (--days, default 30), maps each metric to the infracost usage key it corresponds to, and keys it to the Terraform address. Why: once the stack exists we don't have to guess, and the student's actual behaviour is the only number that answers "what is this costing me".
  • Usage is pinned per resource, not per type. render_usage_yaml() grew a resource_usage: block keyed by Terraform address. Why: infracost's resource_type_default_usage applies one number to every resource of a type, so 50k requests measured across three Cloud Run services would bill 3×50k.
  • Measured usage retires the guessed profiles. New classify_usage_profile() compares real request volume against light/heavy on a log scale. Why: light/heavy stop being pricing inputs the student is billed for and become a label — "you are a LIGHT user".
  • Nothing is invented. The scan gets an empty profile plus measured per-address usage only, so anything Monitoring couldn't report stays at zero and the reason prints under "About these numbers". Why: a silent $0 is the bug being fixed; an explained $0 is fine. The three approximations are disclosed in the same section (GCS class A/B inferred from method names; project-scoped BigQuery bytes split evenly across tables; Cloud Run concurrency left at infracost's default).
  • No new dependency. requests was already a dep, and the Monitoring REST API is called with an ADC token from gcloud auth application-default print-access-token. Why: matches the repo's run_tool shell-out style rather than pulling in google-cloud-monitoring.
  • deploy's confirmation prompt no longer shows a number we know is wrong. It used the zero-usage scan; it now uses the same usage-aware estimate. Why: it was asking students to approve a misleading figure. This also let run_infracost_scan, run_infracost_analysis, display_cost_breakdown and fetch_resource_costs be deleted as duplicates (~143 lines).
  • Expired infracost tokens now say what to do. check_infracost_authenticated() can only prove a token file exists, so an expired token passed pre-flight and failed later with raw JSON stderr. _report_scan_failure() recognises the auth case and answers infracost auth login. Why: hit this exact false positive twice while working on this.
  • Smaller correctness fixes: the bigquery module description won for both the dataset and the tables, so storage rows were labelled as query cost; google_cloud_run_v2_job (teardown/offline_scoring/explainability) was in neither label map and printed as a raw google_* type; a failed infracost inspect returned [] silently, rendering as "nothing costs money" under a non-zero total; costs defaulted to workspace "development" while deploy/destroy/get-urls use "default", so it could look in a directory deploy never wrote; costs and estimate bypassed _load_config_or_exit.
  • Config and docs: added roles/monitoring.viewer to REQUIRED_GCP_IAM_ROLES so doctor --project-id checks it for free (monitoring.googleapis.com was already in REQUIRED_GCP_APIS); added the cost_analysis block to config.example.yaml, which three commands read and no example documented; rewrote docs/features/costs.md, which still claimed usage-based services show $0; added estimate and costs to docs/api/cli-commands.md, where neither had appeared at all.

Sample output:

  ACTUAL COST  ·  gcp-mlops-stack-mlflow
  Measured from your real GCP usage over the last 7 days.

  Run-rate: ~$36 / month   ($34.55 always-on  +  ~$1.42 measured usage)  USD
  Charged so far: ~$8.39   (7.0 days since deploy, approximate)

  ALWAYS-ON  (billed 24/7 while the stack exists)
    $   34.55  Cloud SQL    MLflow's backend database  ·  up 100% of window

  USAGE-BASED  (measured over 7 days, scaled to a month)
    $    0.52  Cloud Run    MLflow tracking server  ·  21.4k req/mo, 240 ms median
    $    0.41  BigQuery     prediction & feature queries  ·  0.041 TB/mo scanned
    $    0.27  GCS Bucket   object / artifact storage  ·  4.82 GB stored

  You are a LIGHT user — your real usage is 0.6x the 'light' profile.
  Biggest lever: Cloud SQL is 96% of your cost and runs 24/7.

test plan

  • PYTHONPATH=src pytest tests/ — 108 passing, up from 85.
  • Address contract verified against a real infracost scan. This was the load-bearing assumption: measured usage is keyed on Terraform addresses, but infracost parses HCL rather than state, so if the two disagree the usage file is silently ignored and every usage line prices at $0 — the same bug, relocated. Diffed infracost's addresses (it records them in each resource's name field) against build_resource_map() on the deployed state: all 10 costable addresses match exactly, count indices included (module.experiment_tracking_mlflow[0].google_cloud_run_service.mlflow[0]). Those strings are now pinned in a test, since the tempting cleanup is to normalise the index away.
  • All 9 metric/aligner pairs validated against the live Monitoring API. This caught a real bug: cloudsql.googleapis.com/database/up is GAUGE/INT64, not BOOL, so ALIGN_FRACTION_TRUE returns HTTP 400 and the uptime line degraded to a "could not read" note. ALIGN_MEAN over a 0/1 gauge is the same fraction; a test pins the aligner.
  • build_resource_map() against a genuine 145KB terraform.tfstate from a real deploy. Caught that terraform show -json includes data sources (module.*.data.google_project.current), which inflated the resource map from 10 to 15; now filtered on mode == "managed".
  • Resource isolation on real data: the only live GCS metric series in the test project is the _cloudbuild staging bucket, and it is correctly excluded because it isn't in the stack's state. A student's unrelated resources can't inflate their deployml cost.
  • Degradation paths, each printing a fix rather than a traceback: no deployment; --days out of range; missing config; malformed config; expired infracost token (verified against a real oauth2: "invalid_grant" failure while the pre-check still returned True).
  • End-to-end costs against a live deployment with real traffic. Not run — the test stack is destroyed and standing it up bills Cloud SQL. The measurement layer is verified against the live Monitoring API and the pricing layer against a real cached scan, but the two have not been exercised together on a running stack.
  • deploy's new estimate prompt. Not run — needs a real deploy.

follow-up (not in this PR)

  • GCS class A/B operations are inferred from method names. GCS reports methods, not billing classes, so the mapping is one we maintain. Unrecognised methods count as class B (the cheaper one) so an unknown method can't silently inflate the estimate.
  • BigQuery bytes are project-scoped, not per-dataset. Scanned and stored bytes are split evenly across the deployment's BigQuery addresses. Both are priced linearly, so the total is exact and only per-row attribution is approximate — but a project with non-deployml BigQuery usage would over-attribute to the stack. INFORMATION_SCHEMA.JOBS_BY_PROJECT would fix this properly.
  • Cloud Run concurrency isn't measured. Not exposed as a metric; left at infracost's default and disclosed in the output.
  • No billing-export integration. Real charged dollars need a BigQuery billing export, which requires billing-account admin and has a ~24h lag — out of reach for most students, which is why this measures usage instead. The output says explicitly that it is not a billing statement.
  • check_infracost_authenticated() still can't prove a token is valid without making a live call. The failure is now handled where it surfaces, but the pre-flight check remains a file-existence test.
  • --compare (sqlite vs postgres) from the original proposal is still unbuilt. Given that Cloud SQL is ~96% of the bill, a one-command "what if I switched" is probably the highest-value thing left.

…sts commands

- Rewrites infracost.py for v2 CLI (scan + inspect --json, dropped deprecated breakdown command)
- Adds deployml estimate: pre-deploy cost prediction from config, no GCP credentials needed
- Adds deployml costs: cost check against live deployed Terraform workspace
- Adds check_infracost_authenticated() with macOS + Linux credential path support
- Fixes copy_modules_to_workspace to include cloud_sql_postgres when mlflow uses postgresql
- Updates deployml doctor to show infracost install + auth status
- Adds 14 unit tests for infracost.py
- Updates docs/features/costs.md and tutorial with estimate/costs commands
Infracost defaults usage-based services (Cloud Run, BigQuery, GCS) to zero
usage, so the previous estimate only surfaced the always-on Cloud SQL cost
and showed $0 for everything else -- misleading for students.

The estimate command now feeds infracost a realistic usage profile
(--profile light|heavy, default light) and renders a two-bucket view:
ALWAYS-ON (fixed 24/7) vs USAGE-BASED (scales with activity), each line
labelled in plain English, plus a 'biggest lever' call-out.

- add usage_profiles.py: LIGHT/HEAVY dicts + render_usage_yaml()
- infracost.py: ResourceCost dataclass, run_infracost_scan_with_usage(),
  fetch_resource_costs_detailed(), display_estimate(), run_estimate_analysis();
  pin 'infracost inspect --file <scan>' instead of the global scan cache
- cli.py: estimate gains --profile and calls the new flow
- tests: 22 passing
Brings the branch up to date with 50 commits of main (GCP Cloud Run
hardening, Windows compatibility, Grafana Secret Manager, repo cleanup).

Conflict resolutions:

- utils/infracost.py: main routed external tool calls through run_tool from
  platform_compat so .cmd wrappers work on Windows. Git aligned main's v1
  run_infracost_breakdown against this branch's v2 run_infracost_scan_with_usage
  because both end in a subprocess.run call; they are different functions and
  the v1 one was intentionally removed here. Kept the v2 scan logic and
  converted every infracost invocation in this file to run_tool, including the
  two call sites git did not flag as conflicts.

- tests/test_infracost.py: repointed the five subprocess.run patches at
  run_tool to match the new call path, following the convention main used in
  test_helpers.py, and updated the inspect argv assertion since run_tool takes
  the tool name separately from its args.

- docs/features/costs.md: kept main's per-service price breakdown and Cost
  Optimization section, folding in this branch's two concrete tips (the
  backend_store_uri: sqlite config key and the explicit deployml destroy step).

- docs/tutorials/gcp-cloud-run.md: adopted main's section renumbering, keeping
  the estimate step as 3.5 and renumbering the costs step to 9.5. Main dropped
  the prerequisites bullet list in favour of deployml doctor; preserved only
  the infracost install note, which doctor checks but does not install.

config.yaml is no longer tracked, per main's cleanup in #73.
`deployml costs` re-ran the zero-usage infracost scan against the deployed
terraform, so it reported the same misleading numbers the estimate redesign
existed to fix: Cloud Run, BigQuery and GCS at $0, only Cloud SQL real. It
answered "what would this cost if nobody used it", which nobody is asking.

It now measures what the student actually did. New utils/measured_usage.py
reads Cloud Monitoring over a window (--days, default 30), maps each metric
onto the infracost usage key it corresponds to, and keys it to the terraform
address via `terraform show -json`, so per-service usage prices per service
instead of charging every Cloud Run service the whole stack's traffic.

Deliberately no billing integration: GCP has no API for "my spend so far", and
the BigQuery billing export needs billing-account admin plus a ~24h lag, which
is out of reach for a student. Measuring usage needs only monitoring.viewer.

Design rule is that nothing is invented. The scan gets an empty profile and
measured per-address usage only, so anything Monitoring could not report stays
at zero and the reason prints under "About these numbers". A silent $0 is the
bug being fixed; an explained $0 is fine. The three approximations (GCS class
A/B inferred from method names, project-scoped BigQuery bytes split evenly,
Cloud Run concurrency left at infracost's default) are disclosed the same way.

Measured usage also retires the guessed profiles: classify_usage_profile
compares real request volume against light/heavy on a log scale, so those stop
being pricing inputs and become a label ("you are a LIGHT user").

Also folded in, since they are the same code path:

- deploy's confirmation prompt was built from the zero-usage scan, i.e. it
  asked students to approve a number we know is wrong. It now uses the same
  usage-aware estimate, which let run_infracost_scan, run_infracost_analysis,
  display_cost_breakdown and fetch_resource_costs be deleted as duplicates.
- check_infracost_authenticated can only prove a token file exists, so an
  expired token passed pre-flight and failed at scan time with raw JSON
  stderr. The failure is now recognised and answered with "infracost auth
  login". Verified against a real expired token.
- the `bigquery` module description won for both the dataset and the tables, so
  storage rows were labelled as query cost. Split into per-type descriptions.
- google_cloud_run_v2_job (teardown, offline_scoring, explainability) was in
  neither label map and printed as a raw google_* type name.
- a failed `infracost inspect` returned [] silently, rendering as "nothing
  costs money" under a non-zero total. It now says so.
- costs defaulted to workspace "development" while deploy/destroy/get-urls use
  "default", so it could look in a directory deploy never wrote.
- costs and estimate bypassed _load_config_or_exit, turning a missing key into
  " Estimate failed: 'provider'".

Metric and aligner pairs were validated against the live Monitoring API.
database/up is GAUGE/INT64 rather than BOOL, so ALIGN_FRACTION_TRUE is
rejected with HTTP 400; ALIGN_MEAN over a 0/1 gauge is the same uptime
fraction and is pinned by a test.

Tests: 85 -> 106, all passing.
docs/api/cli-commands.md never mentioned either cost command. Both now have a
section with options, sample output, and their prerequisites.

docs/features/costs.md claimed both commands show $0 for usage-based services,
which stopped being true when estimate became usage-aware and is now the whole
point of costs. Rewritten around the two questions the commands answer -- what
will this cost (assumed usage) versus what is it costing me (measured usage) --
with the fixed-vs-usage distinction up front, since that is what makes the
numbers legible: how hard you use the stack barely matters next to whether the
database is running.

tests/README.md described only the helpers tests. Now lists all six test
modules, the mocking conventions, and the regression guards worth not breaking.
Verified the one load-bearing assumption in the measured-usage path: that the
terraform address `terraform show -json` reports is byte-identical to the
address infracost keys `resource_usage` on. If they disagree the usage file is
silently ignored and every usage line prices at $0 -- the exact bug measured
usage exists to fix, just relocated.

Checked against a real cached `infracost scan` of the rendered deployml Cloud
Run stack (infracost records each resource's address in its "name" field) and
diffed it against build_resource_map() run on the deployed state. All 10
costable addresses match exactly, count indices included:

  module.experiment_tracking_mlflow[0].google_cloud_run_service.mlflow[0]
  google_storage_bucket.artifact_tracking_mlflow_artifact
  ...

Those strings are now pinned in a test, since the tempting "cleanup" here is to
normalise or strip the count index, which would break the binding silently.

Reading the real scan also turned up an unescaped-quote bug. for_each addresses
embed their own quotes, e.g.

  module.cloud_sql_postgres.google_project_service.required["cloudkms.googleapis.com"]

and render_usage_yaml wrapped addresses with a naive f-string, so such an
address closed the YAML string early and made the whole usage file
unparseable. Not reachable today -- no measured type uses for_each -- but it
would have been a silent trap for whoever added one. Now emitted via
json.dumps, whose escaping is valid YAML double-quoted style.

Tests: 106 -> 108.
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 58b32f77-8e37-4b41-9264-8af5963db2f3


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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