Costs dash: what a deployed group costs per day
GET /costs is an Access-gated, read-only page (plus a JSON twin) showing what a named group of deployed pieces costs per day on every meter Cloudflare bills a Workers deployment on, read live from the providers' own billing datasets and priced at list — and what share of the account's whole Cloudflare spend that is, so a deployer can tell this software's cost from everything else on their account. The first group is switchboard — the bot Worker + its container, the resident/sandbox/memory Workers and their containers — but the config is a map of groups so the same page serves other apps on the account later. LLM spend is layered in from the Anthropic Admin API when an Admin key and a per-group workspace are configured.
- Code:
src/core/costs.ts(parseCostsConfig,CLOUDFLARE_PRICES,DAYS_PER_MONTH,containerCostUsd,doDurationCostUsd,doRequestsCostUsd,doRowsCostUsd,storageDayCostUsd,workersCostUsd,r2OperationClass,r2OperationsCostUsd,attributionOf,buildCostReport,resolveRange,CloudflareGraphqlUsageSource,AnthropicCostReportSource,NullLlmCostSource,createCostsService);src/channels/costsView.ts(parseCostsRoute,createCostsViewHandler— serves the shared web shell with aCostsSeed, see live-view.md's Rendering paragraph); the page rendering inweb/:web/src/pages/CostsPage.vue(mobile layout included),web/src/components/costs/CostChart.vue,web/src/lib/costs.ts;src/index.ts(builds the service fromcosts:config + env, gates/costs*behind Access next to/runs*and/residents*);deploy/cloudflare/worker.ts(forwardsCF_ANALYTICS_TOKEN/ANTHROPIC_ADMIN_KEYinto the container env). - Tests:
src/core/costs.test.ts,src/channels/costsView.test.ts(routing + seed),web/src/pages/costs.test.ts(rendering). - Docs: Code map, Capacity and sizing, access-gate.md, resident-repos.md (sibling Access-gated dash).
Configuration
costs:
cloudflareAccountId: <32-hex>
cloudflareTokenEnv: CF_ANALYTICS_TOKEN # default
anthropicAdminKeyEnv: ANTHROPIC_ADMIN_KEY # default; optional feature
groups:
switchboard:
label: Switchboard
workers: [switchboard, switchboard-resident, switchboard-sandbox, switchboard-memory] # the attribution root
containerApps: { <application id>: bot, <application id>: resident, <application id>: sandbox }
durableObjectNamespaces: { <namespace id>: bot DO } # optional labels — a namespace hosted by a listed Worker is attributed anyway
r2Buckets: { <bucket name>: label } # optional — a bucket named <worker>-cache (the template's name) is attributed anyway
anthropicWorkspaceId: wrkspc_… # optional| Secret | Scope | Effect when absent |
|---|---|---|
CF_ANALYTICS_TOKEN | Cloudflare API token, Account → Account Analytics: Read only, scoped to the one account | /costs answers 503 (feature off) |
ANTHROPIC_ADMIN_KEY | Anthropic Admin API key (sk-ant-admin…) | LLM column absent; page says "LLM spend not configured" (never $0) |
Container application ids: GET /accounts/{id}/containers/applications; DO namespace ids: GET /accounts/{id}/workers/durable_objects/namespaces. A group's LLM spend is the cost report filtered to anthropicWorkspaceId; the bot's API key must live in that workspace for the attribution to be right (the org default workspace reports as null and is never attributed to a group).
Behavior
- Pricing model is Cloudflare's. vCPU bills on active seconds (
cpuTimeSec); memory and disk bill on the provisioned size for every awake second (allocatedMemory/allocatedDiskbyte-seconds). Durable Objects bill duration ($12.50 per million GB-s, metered as 128 MB × active wall-clock seconds) plus requests ($0.15/M). Duration comes fromdurableObjectsPeriodicGroups.sum.duration, which is already the billable GB-s per namespace — not from summed request wall time (durableObjectsInvocationsAdaptiveGroups.wallTime), which double-counts overlapping long requests (SSE streams,exec) and has been observed ~2× above billed (bot: 179k s summed vs 86k s active on one day). The other meters a Workers deployment is billed on are priced too, from their own datasets: SQLite rows read ($0.001/M) and written ($1.00/M) from the samedurableObjectsPeriodicGroupsrows; SQLite storage ($0.20/GB-month) fromdurableObjectsSqlStorageGroups; Worker requests ($0.30/M) and CPU time ($0.02 per million CPU-ms; the dataset reports microseconds) fromworkersInvocationsAdaptive; R2 storage ($0.015/GB-month) fromr2StorageAdaptiveGroups(payload + metadata) and operations fromr2OperationsAdaptiveGroups— class A $4.50/M (an action that mutates or lists:Put*,Create*,Complete*,Upload*,List*,Copy*), class B $0.36/M (Get*,Head*,Usage*), deletes and aborts free, an action not met before classed by its verb so it is priced rather than dropped. A GB-month rate is prorated per day over the mean month (DAYS_PER_MONTH= 365.25 ÷ 12) on the day's peak bytes. Constants inCLOUDFLARE_PRICES(Cloudflare list prices; re-check against the pricing pages when they move); the page states them. Gross — plan fees and included allowances are not subtracted. Not priced: Workers Logs volume (no analytics dataset exposes it) and Access (free at this seat count); the page says so. - Attribution is by Worker script, and the account is the denominator. A group names its Workers; everything Cloudflare bills hangs off a script, so
attributionOfderives the rest: a Durable Object namespace is attributed to the Worker that hosts it (thedurableObjectsInvocationsAdaptiveGroupsjoin ofnamespaceId→scriptName, any day in range), labelled by adurableObjectNamespacesentry when there is one and by its Worker's name otherwise — a listed namespace is attributed even when the range shows it no invocation; an R2 bucket is attributed when it carries exactly the name the deploy templates give a Worker's bucket (<worker>-cache, the resident template's{{script}}-cache) or is listed inr2Buckets— a prefix match would let a stranger'sswitchboard-2-tfstateinflateswitchboard's figure on a shared account; Worker requests and CPU byscriptName; container applications only by the configuredcontainerAppsmap (no dataset ties an application to its Worker). A resource that appears after the config was written is therefore counted, not silently dropped, as long as it belongs to a named Worker. Every row in the account — attributed or not — is priced the same way intoaccount.cloudUsd, so the page can say what share of the account's Cloudflare spend this group is; the report carries theattributionit used (Workers, namespaces, buckets, apps) so a reader can check what was counted. LLM rows count only whenworkspaceIdequals the group'santhropicWorkspaceId. - One row per UTC day, zero-filled.
resolveRange(?days)gives 1–90 days (default 30, garbage → default) ending today; today is flaggedpartialLastDay. The tiles use full days only: Yesterday = last full day, 7-day average, Projected month = 7-day rate × 30.4. - LLM unavailable ≠ $0. With no Admin key (
NullLlmCostSource→null) or no workspace on the group,llmAvailableis false and the page says "LLM spend not configured"; the totals then exclude LLM rather than reporting a fake zero share. - Sources are seams.
CloudflareUsageSourceandLlmCostSourceare interfaces; the real ones take an injectablefetch. The Cloudflare source POSTs one GraphQL query (containersUsageAdaptiveGroups+durableObjectsPeriodicGroups+durableObjectsInvocationsAdaptiveGroups,date-bucketed, exclusive end = start of the day afterto) with the token only inAuthorization; a non-200 or a GraphQL-levelerrorsarray (the API 200s on those) throws. The Anthropic source walksGET /v1/organizations/cost_report?group_by[]=workspace_id&bucket_width=1d&limit=31page by page (next_page), converts cent-strings to dollars, and refuses a non-USD row — or a report still paginating past 20 pages — rather than mis-summing or returning a truncated total. Neither error message carries the credential. - Live per request, nothing stored. Every page/JSON load calls both sources; there is no cache and no persistence (AGENTS.md invariant 6 is moot — there is no state to lose).
- Routes.
/costsand/costs.json→ the first configured group;/costs/<group>and/costs/<group>.json; group slugs are[a-z0-9-]{1,40}. GET-only (405 otherwise). Unknown group → 404. Not configured → 503 naming the config keys. Upstream failure → 502 with a capped reason, never a 500. JSON shape per day:durableObjectsis DO duration cost keyed by namespace label; DO request cost is the siblingdoRequestsUsd(not attributable to a namespace). A consumer wanting the full DO figure sums both —totals.byResource.durableObjectsalready does. - Page. The server serves the shared web shell with the report + group list as the
CostsSeed;web/src/pages/CostsPage.vue(+CostChart.vue, the pure math inweb/src/lib/costs.ts) renders inline SVG stacked bars (one titled segment per day × component — hover works without any extra script), a legend, a per-resource split (memory / vCPU / DO / disk), and a full table view; hostile strings render as text (the seed island escapes every angle bracket, and the components never render raw markup); the sameWEB_HTML_HEADERS(strict CSP,X-Frame-Options: DENY,no-store) as/runs. Series colors are the validated categorical set with dark-mode steps, assigned by first appearance, never re-ranked. - Access gate.
/costs*sits in the same fail-closed Access branch as/runs*and/residents*insrc/index.ts. The startup log statesGET /costs (<groups>; LLM on|off)or the 503 reason.
Validation criteria
| # | Criterion | Proof |
|---|---|---|
| 1 | Container pricing reproduces Cloudflare's billing for a real resident row (→ $1.066); DO duration prices billable GB-s (a real bot DO row → $0.1378, always-on ≈ $0.135/day) and requests at $0.15/M; SQLite rows $0.001/M read and $1.00/M written; storage prorated per day over 30.4375 on peak bytes; Workers $0.30/M + $0.02/M CPU-ms from microseconds; R2 class A/B/free by named action and by verb for an unknown one | [unit] costs.test.ts containerCostUsd, durable object pricing, the other meters Cloudflare bills a Workers deployment on (×4) |
| 2 | Rows outside the group (other apps, other DO namespaces, other workers, other buckets, other/default workspaces) never reach the group's figures; a namespace is attributed through the Worker that hosts it (labelled from config or by its Worker); an R2 bucket by its <worker>-… name; the report names its attribution | [unit] buildCostReport › keeps only… (×2), › attributes an R2 bucket… |
| 2a | The whole account is priced the same way and the other tenant's rows land there and only there, so the group's share of the account is honest | [unit] buildCostReport › prices the whole account…, web/src/pages/costs.test.ts::CostsPage::says what share of the account's whole Cloudflare spend this group is, and what was attributed to it |
| 3 | Days are zero-filled oldest-first; totals and the per-resource split (eight meters) sum exactly; the group's Workers, rows and storage are priced and the other tenant's are not | [unit] buildCostReport › emits one row…, › totals…, › splits cloud spend…, › prices the group's Workers… |
| 4 | No LLM source → llmAvailable:false, page says not configured (not $0) | [unit] costs.test.ts reports llm as unavailable, web/src/pages/costs.test.ts::CostsPage::says LLM spend is not configured instead of showing $0 when there is no source |
| 5 | ?days clamps 1..90, garbage → 30, today flagged partial | [unit] resolveRange |
| 6 | Malformed costs: config throws at startup, absent → off | [unit] parseCostsConfig |
| 7 | Cloudflare source: bearer header, account-scoped variables, exclusive end date, row mapping for all seven datasets in one request (incl. durableObjectsPeriodicGroups for duration and rows, durableObjectsSqlStorageGroups, workersInvocationsAdaptive, the two R2 datasets; R2 bytes = payload + metadata); non-200 and GraphQL errors both throw; token never in URL/error | [unit] CloudflareGraphqlUsageSource (×3) |
| 8 | Anthropic source: x-api-key + anthropic-version, group_by[]=workspace_id, pagination via next_page, cents→dollars, non-USD refused, >20 pages refused (not truncated), key never in error | [unit] AnthropicCostReportSource (×4) |
| 9 | Routing: index / group / .json twin match; traversal-shaped, over-long, and foreign paths don't | [unit] parseCostsRoute |
| 10 | Page: hostile strings inert; tiles from full days plus the share-of-account tile with its numerator and denominator; one titled segment per day×component, the small platform meters (Workers, SQLite rows and storage, R2) stacked as one series and each listed in the split; the attribution (Workers, namespace and bucket counts) stated in the footer; legend + table; partial-day marker; method stated; sibling-group links; range switch; JSON twin named | [unit] web/src/pages/costs.test.ts::CostsPage (×12) |
| 11 | Handler: falls through for other paths; 503 unconfigured naming the keys; 405 non-GET; live read per request with the hardened headers; ?days passthrough; 404 unknown group; JSON twin no-store; upstream failure → capped 502 | [unit] createCostsViewHandler (×7) |
| 12 | Deployed: /costs behind Access renders the switchboard group with live Cloudflare numbers matching the dashboard's billing view for the same day | [agent] after deploy: sign in, load /costs/switchboard?days=7, compare yesterday's container total to the Cloudflare dash |
| 13 | Deployed: LLM column appears once ANTHROPIC_ADMIN_KEY + anthropicWorkspaceId are set | [gap] until the bot key is moved into its own Anthropic workspace |