Skip to content

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 a CostsSeed, see live-view.md's Rendering paragraph); the page rendering in web/: 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 from costs: config + env, gates /costs* behind Access next to /runs* and /residents*); deploy/cloudflare/worker.ts (forwards CF_ANALYTICS_TOKEN / ANTHROPIC_ADMIN_KEY into 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

yaml
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
SecretScopeEffect when absent
CF_ANALYTICS_TOKENCloudflare API token, Account → Account Analytics: Read only, scoped to the one account/costs answers 503 (feature off)
ANTHROPIC_ADMIN_KEYAnthropic 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

  1. Pricing model is Cloudflare's. vCPU bills on active seconds (cpuTimeSec); memory and disk bill on the provisioned size for every awake second (allocatedMemory / allocatedDisk byte-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 from durableObjectsPeriodicGroups.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 same durableObjectsPeriodicGroups rows; SQLite storage ($0.20/GB-month) from durableObjectsSqlStorageGroups; Worker requests ($0.30/M) and CPU time ($0.02 per million CPU-ms; the dataset reports microseconds) from workersInvocationsAdaptive; R2 storage ($0.015/GB-month) from r2StorageAdaptiveGroups (payload + metadata) and operations from r2OperationsAdaptiveGroups — 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 in CLOUDFLARE_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.
  2. Attribution is by Worker script, and the account is the denominator. A group names its Workers; everything Cloudflare bills hangs off a script, so attributionOf derives the rest: a Durable Object namespace is attributed to the Worker that hosts it (the durableObjectsInvocationsAdaptiveGroups join of namespaceIdscriptName, any day in range), labelled by a durableObjectNamespaces entry 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 in r2Buckets — a prefix match would let a stranger's switchboard-2-tfstate inflate switchboard's figure on a shared account; Worker requests and CPU by scriptName; container applications only by the configured containerApps map (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 into account.cloudUsd, so the page can say what share of the account's Cloudflare spend this group is; the report carries the attribution it used (Workers, namespaces, buckets, apps) so a reader can check what was counted. LLM rows count only when workspaceId equals the group's anthropicWorkspaceId.
  3. One row per UTC day, zero-filled. resolveRange(?days) gives 1–90 days (default 30, garbage → default) ending today; today is flagged partialLastDay. The tiles use full days only: Yesterday = last full day, 7-day average, Projected month = 7-day rate × 30.4.
  4. LLM unavailable ≠ $0. With no Admin key (NullLlmCostSourcenull) or no workspace on the group, llmAvailable is false and the page says "LLM spend not configured"; the totals then exclude LLM rather than reporting a fake zero share.
  5. Sources are seams. CloudflareUsageSource and LlmCostSource are interfaces; the real ones take an injectable fetch. The Cloudflare source POSTs one GraphQL query (containersUsageAdaptiveGroups + durableObjectsPeriodicGroups + durableObjectsInvocationsAdaptiveGroups, date-bucketed, exclusive end = start of the day after to) with the token only in Authorization; a non-200 or a GraphQL-level errors array (the API 200s on those) throws. The Anthropic source walks GET /v1/organizations/cost_report?group_by[]=workspace_id&bucket_width=1d&limit=31 page 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.
  6. 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).
  7. Routes. /costs and /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: durableObjects is DO duration cost keyed by namespace label; DO request cost is the sibling doRequestsUsd (not attributable to a namespace). A consumer wanting the full DO figure sums both — totals.byResource.durableObjects already does.
  8. 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 in web/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 same WEB_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.
  9. Access gate. /costs* sits in the same fail-closed Access branch as /runs* and /residents* in src/index.ts. The startup log states GET /costs (<groups>; LLM on|off) or the 503 reason.

Validation criteria

#CriterionProof
1Container 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)
2Rows 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…
2aThe 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
3Days 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…
4No 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
6Malformed costs: config throws at startup, absent → off[unit] parseCostsConfig
7Cloudflare 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)
8Anthropic 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)
9Routing: index / group / .json twin match; traversal-shaped, over-long, and foreign paths don't[unit] parseCostsRoute
10Page: 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)
11Handler: 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)
12Deployed: /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
13Deployed: LLM column appears once ANTHROPIC_ADMIN_KEY + anthropicWorkspaceId are set[gap] until the bot key is moved into its own Anthropic workspace