The cost guard
Projecting volume and cost before anything is sent, the five spend policies, live AWS Pricing API rates, and the persistent spend ledger.
The cost guard answers "what is about to happen, and roughly what will it cost" before a single record leaves the machine, then decides whether the run is allowed to proceed.
Two modules, with a deliberate split:
proteus/guard/estimate.pyproduces aProjection. It knows nothing about permission.proteus/guard/policy.pyturns aProjectionplus aGuardPolicyinto aGuardDecision. It only reports what should happen: it never prompts, never prints, never sends, and never touches the network. The CLI and the web API own all of that.
Projection#
@dataclass(slots=True)
class Projection:
spans: int = 0
metric_points: int = 0
log_events: int = 0
rum_events: int = 0
new_series: int = 0
bytes_traces: int = 0
bytes_metrics: int = 0
bytes_logs: int = 0
requests: int = 0
est_usd: float = 0.0
warnings: list[str] = field(default_factory=list)
pricing: dict[str, Any] = field(default_factory=dict)
Counts are totals for the whole window. bytes_* are uncompressed wire bytes.
requests is the total HTTP request count across all signals. est_usd is an
approximate figure and must be presented as one.
pricing is the provenance block — source, region, fetched, stale — and it
exists so that a fallback or stale price is visible rather than implied to be
live. Show it next to the figure.
def project(model, window, *, now, session=None, region=None) -> Projection: ...
window accepts a timedelta (a window of that length ending at now), a
(start, end) pair, or any object with .start and .end. now is passed
explicitly so projections are reproducible.
Where the counts come from#
Counts come from render.engine.estimate_volume whenever it is importable, and
from a documented per-service heuristic otherwise. Any use of the fallback is
reported, never blended in silently.
That is not caution for its own sake. guard/_volume.py says why:
A table-derived figure that looks identical to a measured one is how a
new_seriescount stayed frozen at one value while metric volume doubled — the dominant term in the cost estimate, structurally unable to move.
And _engine_counts records the other half of the same lesson:
An earlier version guessed at three possible signatures; none matched, so it silently fell back to the heuristic and overstated span volume by roughly 600x. Guessing hid the mismatch — call it directly and let a real change break loudly instead.
So the probe now calls estimate_volume(model, Window(...)) with its real
signature, and every failure lands in _EngineProbe.failure as a string rather
than as a bare None. Swallowing the exception is how the previous mismatch
survived: a broken engine and an absent engine looked identical downstream.
The heuristic, and when it is used#
_heuristic_counts(model, minutes) derives counts from the model's shape using
hard-coded per-archetype tables: request volume is base_rps * 60 * minutes, spans
scale by _SPANS_PER_REQUEST, series by _SERIES_PER_INSTANCE × instances, log
lines by _LOG_LINES_PER_REQUEST plus an idle floor, and RUM events only for
browser services.
It exists only for when the renderer is genuinely absent, and its use produces a warning naming the reason:
proteus.render.engine.estimate_volume was unavailable, so these counts and the
series count come from the per-service heuristic in guard/estimate.py (base_rps x
60 x minutes, scaled per archetype). Treat them as order-of-magnitude only.
Reason: ...
Choosing the series count#
new_series drives most of est_usd, so _choose_series is defensive about it.
The engine's measurement wins whenever it supplied a usable one. Two engine answers
are rejected as unusable:
- an absent field;
- zero series alongside a non-zero datapoint count, which cannot both be true and is the signature of an unset default rather than a real measurement.
Even when the engine's answer is used, the table is still consulted as a cross-check:
SERIES_DIVERGENCE_FACTOR = 2.0
A disagreement worse than 2x produces a warning saying which way the table is wrong and that the measured figure is the one being used — because the table is still the fallback when the renderer is absent, so a stale table is a latent mispriced spend gate.
Faults inflate records but not series#
amplify = _fault_multiplier(model, start, end)
projection.spans = int(counts["spans"] * amplify)
projection.metric_points = int(counts["metric_points"]) # not amplified
projection.log_events = int(counts["log_events"] * amplify)
projection.rum_events = int(counts["rum_events"] * amplify)
_fault_multiplier adds 0.5 × severity × (overlap / window) per overlapping
fault, capped at 4.0 in total. Metric points are not amplified, and new_series is
not either: faults do not create series, only the estate's shape does.
Requests#
metric_requests = ceil(metric_points / 1_000) # the tightest cap
log_requests = ceil(log_events / 10_000)
trace_requests = ceil(spans / 10_000)
rum_requests = ceil(rum_events / 200)
Warnings the projection raises#
Three families, all in Projection.warnings.
Cardinality. NEW_SERIES_LIMIT_PER_10_MIN = 1_000_000 is the account/region
ceiling on new time series per 10-minute window. At or above it, the warning says
CloudWatch will throttle or reject. Above 50% of it
(NEW_SERIES_WARN_FRACTION = 0.5), the warning says high cardinality is the
fastest way to both get throttled and run up a bill.
Quota. METRICS_TPS_LIMIT = 500 and LOGS_TPS_LIMIT = 5_000. The wording is
careful about what actually happens, because the emitter self-throttles:
QUOTA: 1,240 metric requests/s projected against a 500 TPS account limit. The
emitter self-throttles, so the run will simply take longer than the window it
covers.
The standing caveat, appended to every projection:
Cost is list price only and never a quote: it cannot see negotiated discounts,
credits or private pricing agreements. Custom metric series are billed per
metric-month and this figure charges a full month for every new series rather than
prorating, so a short demo looks dearer than it bills.
That second sentence is worth internalising. The per-series term dominates the estimate on a small estate, and Proteus charges a whole month for a series created minutes ago. The figure is biased high, deliberately.
Pricing#
price_projection sets est_usd and returns the provenance block. It prefers live
AWS Pricing API rates through proteus/pricing.py and falls back to a built-in
table.
def _estimate_usd_from_card(p: Projection, card) -> float:
logs_gb = p.bytes_logs / _GIB
metrics_gb = p.bytes_metrics / _GIB
return (
logs_gb * card.usd_per_gb_logs_ingest
+ logs_gb * card.usd_per_gb_logs_storage
+ metrics_gb * card.usd_per_gb_otel_metrics_ingest
+ p.new_series * card.usd_per_metric_month
+ p.requests * card.usd_per_api_request
+ (p.spans / 1_000_000.0) * card.usd_per_million_spans_indexed
+ (p.rum_events / 1_000_000.0) * card.usd_per_million_rum_events
)
Every term prices a quantity the renderer computes exactly — span count, request count, series count, bytes — against a published list rate. There is no invented constant anywhere in that calculation.
Spans are priced per span, not per GB. AWS publishes no per-GB span-ingest dimension, and the renderer knows the exact span count, so converting a known count into bytes only to divide it back out would add error rather than remove it.
The RateCard#
proteus/pricing.py fetches seven rates via pricing:GetProducts and caches them
at $PROTEUS_HOME/pricing-{region}.json.
| Field | Unit |
|---|---|
usd_per_metric_month |
per custom metric (time series) per month |
usd_per_gb_logs_ingest |
per GB ingested, Standard class |
usd_per_gb_logs_storage |
per GB-month retained |
usd_per_million_spans_indexed |
per 1,000,000 spans indexed by Transaction Search |
usd_per_million_rum_events |
per 1,000,000 RUM events |
usd_per_gb_otel_metrics_ingest |
per GB through the CloudWatch OTLP endpoint |
usd_per_api_request |
per API request |
All seven resolve live from the API, so a healthy card contains no invented constants.
Two useful details about where the rates live. Everything Proteus needs is under
the AmazonCloudWatch service code, including Transaction Search span indexing
and RUM — which is not under AmazonCloudWatchRUM, since that service code
returns no products. AWSXRay holds only trace storage and retrieval, neither of
which applies to the OTLP traces path, so it is never queried.
The Pricing API is served from us-east-1 or ap-south-1 regardless of the
region being priced; region is passed as a location filter. That filter is a
human location name, not a region code, and botocore's own region descriptions are
not usable for it: botocore calls eu-west-1 "Europe (Ireland)" while Pricing
calls it "EU (Ireland)", a mismatch covering every eu-* region. So
pricing_cache.py::REGION_LOCATIONS is an explicit map, verified against the live
API, and a region missing from it is not guessed:
'ap-southeast-7' is not in this build's region-to-location map, so the Pricing API
was not queried for it - a guessed location name would silently return another
region's prices. Add it to REGION_LOCATIONS in proteus/pricing_cache.py.
Two biases, both toward over-estimating: where a dimension is tiered the first and most expensive tier is taken, and free-tier allowances are ignored entirely.
Nothing in the pricing path ever raises#
A pricing lookup is a convenience on the way to emitting telemetry, so every
failure degrades and records why. The ladder in rate_card is: a fresh cache
(unless refresh=True), then a live fetch, then a stale cache with a note
giving its age, then BUILTIN_RATES_USD. Default staleness threshold is 24 hours.
source reports which, and it can be mixed:
source |
Meaning |
|---|---|
aws-pricing-api |
every rate live |
cache |
served from a fresh local cache |
builtin-fallback |
the hand-copied table |
aws-pricing-api+builtin-fallback |
the API resolved only some rates |
Treat the mixed value as its own state, not as fully live. It happens where a feature has not launched in a region.
RateCard.notes names every assumption made and every rate that fell back. A card
with a plausible-looking number and a note saying it was assumed is the normal
case, not an error, so surface notes to a human.
One bug worth knowing about, because the fix reads like an oddity:
price_projection constructs a boto3.Session itself when the caller passes
none. Without that, project() with no session raised AttributeError inside the
pricing lookup, silently fell back to the built-in table, and reported a stale
figure that looked live.
The built-in fallback#
APPROX_RATES_USD in guard/estimate.py and BUILTIN_RATES_USD in
pricing_cache.py are both hand-copied us-east-1 list prices, stale by
construction, used only when nothing else is available. The comment above
APPROX_RATES_USD is a standing instruction: check them against the published
CloudWatch and X-Ray pricing pages before anyone quotes a number from this tool.
Free-tier allowances, volume tiers and regional differences are all ignored.
Offline projection#
project() can reach the network. That is a real exception to the layering rule
in CONTRACTS.md, and estimate.py documents it as one rather than pretending
otherwise:
that file's style rule says "No network calls anywhere outside
proteus/emit/,proteus/cfn/, andproteus/ai/", andproteus/guard/is not on that list. Live pricing is currently the default path, so a cold or stale cache reaches the network from here. Either the rule needsguard/added or the lookup needs to become opt-in; this docstring describes what the code does today rather than what the rule says it should.
Pass region="" to disable the lookup entirely. Every test must do this.
So must any caller that has promised the user nothing will leave the machine —
which is exactly what the paranoid policy promises. proteus/cli/inject.py does
it automatically:
return "" if guard is GuardPolicy.PARANOID else region
The ordering is what makes this necessary: project() runs before evaluate()
can refuse anything. A paranoid run that priced itself against the live Pricing
API would have already broken its own promise by the time the guard said no.
The five policies#
class GuardPolicy(StrEnum):
ESTIMATE_CONFIRM = "estimate-confirm" # the default
BUDGET_CEILING = "budget-ceiling"
WARN_ONLY = "warn-only"
PARANOID = "paranoid"
UNLIMITED = "unlimited"
evaluate(policy, projection, *, ledger_path=None, monthly_cap_usd=50.0, ttl_seconds=None) returns:
@dataclass(slots=True)
class GuardDecision:
allowed: bool = True
require_confirmation: bool = False
dry_run: bool = False
ttl_seconds: int | None = None
banner: str | None = None
reasons: list[str] = field(default_factory=list)
allowed=False means do not send at all. dry_run forces the emitters into
dry-run mode regardless of what the caller asked for. reasons explains the
decision and carries the projection's own warnings through.
| Policy | allowed | confirm | dry_run | Behaviour |
|---|---|---|---|---|
estimate-confirm |
yes | yes | no | Show the projection, require an explicit yes. Grants DEFAULT_TTL_SECONDS = 1800 when the caller gives no TTL. |
budget-ceiling |
conditional | yes | no | Checks the persistent ledger against monthly_cap_usd (default 50.0). Refuses when the run would exceed it. |
warn-only |
yes | no | no | Proceeds without confirmation. |
paranoid |
yes | no | yes | Renders and costs everything; sends nothing. |
unlimited |
yes | no | no | No spend check at all. |
Two policies carry a prominent banner:
PARANOID GUARD: telemetry will be rendered and costed but NOTHING will be sent to AWS.
UNLIMITED GUARD: every projected record will be sent to AWS with no spend check
and no confirmation. You are responsible for the bill.
paranoid and unlimited are checked first and return immediately, so neither
touches the ledger.
The spend ledger#
Only budget-ceiling and record_spend read or write it. It lives at
$PROTEUS_HOME/spend.json, keyed by UTC calendar month:
{
"version": 1,
"months": {
"2026-09": {"usd": 12.34, "runs": 3, "updated": "2026-09-23T14:05:00+00:00"}
}
}
def ledger_path() -> Path:
home = os.environ.get("PROTEUS_HOME")
return (Path(home) / "spend.json") if home else DEFAULT_LEDGER_PATH
Resolved at call time, not at import. The docstring says why:
a test or a side-by-side estate that sets
PROTEUS_HOMEcannot write synthetic spend into the user's real ledger — which would silently refuse their next genuine run with a correct-looking "monthly cap reached" message.
There is exactly one ledger per home, deliberately: the CLI and the web UI must agree on what has been spent.
A missing file is an empty ledger with no warning. An unreadable or malformed one is an empty ledger with a warning, and the warning is explicit about the consequence:
Spend ledger at /Users/<you>/.proteus/spend.json could not be read (JSONDecodeError);
starting a fresh ledger. Previously recorded spend for this month is not being
counted.
It never raises.
ledger_spend tolerates an older bare-number form for a month entry. record_spend
adds estimated USD to the month, increments runs, stamps updated, and creates
the parent directory if needed. An unwritable path is ignored and the would-be
total returned: the run already happened, so failing there would be worse than
losing the record.
Call record_spend after a run that actually sent data. The value is estimated
dollars, not billed dollars, and everything downstream should keep saying so.
A refusal under budget-ceiling#
BUDGET CEILING REACHED: this run would take estimated 2026-09 spend to ~$61.40,
over the $50.00 cap. Refusing to send.
with the reasons carrying both the arithmetic and the ways out:
Spend ledger /Users/<you>/.proteus/spend.json records ~$38.20 USD estimated for
2026-09; this run would bring it to ~$61.40 against a $50.00 cap.
Refused by the budget-ceiling policy. Raise the cap deliberately, shorten the
window, or run under the paranoid policy to render without sending.
confirmed is load-bearing on the web path#
The browser confirm dialog is not the gate. Under any policy that sets
require_confirmation, POST /api/inject refuses with 412 and the projection
body unless the request body says "confirmed": true:
if decision.require_confirmation and not inject_request.confirmed:
return fail(412, ..., detail='re-send with "confirmed": true once the '
'operator has seen the estimate')
A scripted POST hits exactly the same check. Only one inject job runs at a time, process-wide; a second attempt gets 409 with the running job's id.
format_projection#
def format_projection(p: Projection) -> str: ...
Renders a projection as a multi-line console summary. It contains no square
brackets, so it is safe to hand to rich.print without anything being swallowed
as markup — a small thing that has bitten this project before.
Produced here for a 14-day, 60-second window over the 18-service northwind-rx
preset, with region="" so no pricing call was made:
Projected volume
spans 7,983,360
metric points 9,354,240
log events 1,874,880
RUM events 483,840
new metric series 469
Projected wire traffic (uncompressed)
traces 3.1 GiB
metrics 1.0 GiB
logs 679.4 MiB
total 4.8 GiB
requests 12,762
Estimated cost ~ $187.50 USD (approximate, not a quote)
Warnings
! Cost is list price only and never a quote: it cannot see negotiated
discounts, credits or private pricing agreements. ...
Because region="" forced the offline path, Projection.pricing came back as
{'source': 'builtin-fallback', 'region': None, 'stale': True} — which is exactly
what a caller should be showing next to that dollar figure. With a live rate card
the number will differ by region and by how recently AWS changed a rate.
The worked examples that produce numbers for a real estate against live pricing are in Operations.