mooracle.io
← Back to blog

Map Your Cloud Bill to Your Teams: Build Your Own FinOps Tool

A production line on one long bench, left to right. Bill slips leave three hoppers labeled AWS, AZURE and SAAS and ride a belt into a baling press, which squeezes each bundle into a small green brick. A gang cutter splits a long amber brick, a shared charge, into three pieces. An address reader with a punched rules card on top routes every brick and piece into team trays for DATA, PAYMENTS and SEARCH, each with a spend display, and a card file marked GIT LOG holds earlier rules cards. Once a cycle, when each tray holds its own brick and its piece of the shared charge, the trays are emptied.

Your cloud bill is organized by accounts and services. Your company is organized by teams, products and cost centers, and someone has to map one onto the other every month. A shared account serves three teams, a big slice of the bill carries no owner tag, the support plan and marketplace SaaS land on whichever account pays, teams share Kubernetes clusters, and every re-org moves owners. Any one of these breaks a simple account-to-team list, and people only act on numbers they can trace to their own team and a rule they can read.

This guide builds that mapping for your own org: costs split by your teams, apps and environments, in the dashboards you already use, and every rule change a pull request that shows how much cost moves before it merges. With the companion agent skill below and a billing export already landing, an agent can build a first version with you in a day. It runs for about $10 a month on S3, Athena and Lambda at 15 million billing lines a month, and needs an owner for a couple of hours a month.

The same build works on one cloud with a handful of accounts. I first built it across AWS, Azure and GCP, for more than 500 accounts, subscriptions and Kubernetes namespaces combined. Before, finance chased teams once a month. After, each team saw its own cloud, SaaS and Kubernetes cost by day, next to its traffic and errors, and a cost spike showed up the morning after the deploy that caused it.

Build, buy, or use your cloud’s own tools? On one cloud, its own tools may be enough. AWS Cost Categories group costs by account, service and tag and split shared ones, though the split shows only on the Cost Categories page, not in your exports. Azure’s cost allocation rules (on EA and MCA billing) move shared cost between subscriptions, resource groups and tags. Neither needs anything built. Build when you need a second cloud, SaaS in the same view, shared Kubernetes clusters under the same rules as your accounts, or cost in the dashboards your teams already watch. The reason isn’t price. It’s a tool shaped like your org that changes when the org does, with rules reviewed before they change rather than logged after. Buy if you need optimization, if nobody will own it, or if a central FinOps team serves dozens of budget holders who need a UI. If you already pay for a tool, keep it: the rules in Git and the invoice check work alongside it.

Rather have an agent build it? A free, MIT-licensed agent skill, mooracle/finops-skills, does this build with you. Try it on sample data first, with no credentials.

Contents

Part 1: The architecture

Three pipelines and a feedback loop, all in your cloud account except the rules, which live in Git: exports land in S3, a Lambda rebuilds each month in Athena (AWS’s SQL engine over files in S3) when an export arrives, and dashboards query the views.

Pipeline 1: keep only what you allocate on. Billing exports (in FOCUS, the standard format; Part 2) carry every line item with dozens of columns. You keep the few your org allocates on (account, service, region, Azure resource group and the tag keys you route on), sum the costs into a daily table and a monthly rollup, and drop the rest. SaaS billed directly is pulled from its vendor’s API into the same columns (Part 5). Providers revise a month while it runs and for a while after it ends. Each delivery rebuilds its month from the raw export, which stays in S3.

Pipeline 2: split shared costs by weight. The support plan, a shared node pool’s VMs and a Marketplace SaaS charge each serve many teams and have to be fanned out. In this build, Enterprise Support is spread across accounts by their share of spend, though that’s a rule you choose. A shared Kubernetes node is split across namespaces by the CPU and memory their pods request, idle capacity included by default (Part 6). A Marketplace SaaS charge is split by the vendor’s own usage data (Part 5). In each case the total stays what it was.

Pipeline 3: allocate at query time. This handles the shared accounts, untagged spend and re-orgs. You write the rules as short patterns in Git: an account (payments-* belongs to Payments), a service, a kept tag, a resource group or a namespace. When several match, the more specific one wins (Part 4). This is what tells three teams in one shared account apart. It’s also why untagged spend still gets an owner, from its account and service. Every change is a pull request, a reviewed change in Git: CI expands the rules into a lookup and shows how much cost moves between teams, whoever you choose approves it, and it applies on merge.

A view joins the lookup to the cost rows at query time. A re-org is then a rule change with nothing to reprocess. Write the team into each row instead and every re-org becomes a backfill job, and old and new months disagree until it finishes. If finance closes months, a snapshot keeps each one as it was reported (Part 4).

The loop. Look at what’s still Unallocated, write a rule for the top of it, and merge. Leave the long tail, but keep the total small: past a few percent, I’ve found finance starts treating the whole report as approximate. Every run checks the totals against raw and stops if they don’t match. Once a month the totals are checked against the invoice, so the numbers hold up whether teams only see their cost (showback) or are billed for it (chargeback) (Part 2).

Animation of the whole build as small tables joined by connectors, with the three pipelines and the loop numbered. 1: the sources are Cloud FOCUS exports, SaaS APIs and Kubernetes. The cloud exports land in raw in S3 as aws_focus, azure_focus and gcp_focus, the SaaS bills as saas_focus, and each delivery rebuilds its month in the base tables, base_daily and base_monthly, grouped and summed. 2: weights from the SaaS vendors' APIs and from Kubernetes, account_weights, vendor_shares, namespace_weights, split the shared charges into the base tables, and a check confirms the totals equal raw. 3: rules in Git are expanded by CI into rules_lookup, one row per combination of the columns the rules match, and the services_daily view joins the cost rows with the lookup: payments $64,000, data $41,000, search $28,000, Unallocated $9,000, $142,000 in all. 4: a rule is written for the top of Unallocated, the lookup expands again, and that cost moves to its team: data $48,000, Unallocated $2,000, with the total unchanged.
Steps 1 to 3 are the pipelines above and 4 is the loop. The dollar figures are example data.

Part 2: Billing terms and the base tables

Pipeline 1 first. A month of raw billing data is millions of lines, each with dozens of columns. Deciding who pays takes a handful of them.

Build on FOCUS and charge teams the amortized cost

FOCUS = FinOps Open Cost & Usage Specification, the FinOps Foundation’s vendor-neutral schema for cloud bills. As of November 2026, AWS, Azure, Google Cloud and Oracle export FOCUS 1.2, and a growing list of SaaS vendors export 1.2 or later (current list). Build on 1.2. For a vendor without it, a small shim maps its billing API to the same columns (Part 5).

FOCUS isn’t only shared column names. It also defines how a reservation, a credit or a tax should be counted, though the clouds’ exports follow it unevenly. The base tables keep three cost columns, summed side by side, because each answers a different question (FOCUS spec; Microsoft’s column walkthrough):

  • EffectiveCost is the amortized cost: what you pay after discounts, with any commitment (reservations, savings plans, committed-use discounts), paid up front or monthly, spread over the time it covers. It’s what this build shows and charges teams, since buying a commitment up front doesn’t spike one month.
  • BilledCost is the invoice amount. It’s what the invoice check sums.
  • ListCost is the public list price, before any discount. On usage rows, ListCost minus EffectiveCost is what your discounts saved.
BilledCostthe invoiceEffectiveCostthe team’s shareListCostbefore discounts
EC2 · 1-year Reserved Instance, $12,000 paid up front, fully used
Purchase, month 1$12,000$0$12,000*
Usage, each month$0$1,000$1,250
Year$12,000$12,000$15,000
S3 · on demand, 10% negotiated discount
Usage, each month$900$900$1,000
Year$10,800$10,800$12,000
* Not added to the year: the purchase row carries the reservation’s own list price, and adding it would count the same EC2 twice.

The purchase row’s EffectiveCost is 0 by spec, so nothing counts twice. In a month you buy a commitment up front, the teams’ total won’t match the invoice: the invoice has the purchase, the teams have this month’s share of it.

Why the teams’ total isn’t the invoice

Finance will ask. The bridge, per month:

  • the month’s invoices (BilledCost), the purchase’s own invoice included,
  • minus commitments paid up front this month,
  • plus this month’s share of everything paid up front,
  • minus whatever finance keeps out of chargeback, tax for most,
  • is the chargeback total: EffectiveCost summed over the rows that stay in chargeback.

In the table’s first month that’s $12,900 invoiced, minus the $12,000 purchase, plus $1,000 of it, which leaves $1,900 charged: $1,000 of EC2 and $900 of S3. Whether credits flow to the teams that earned them or stay central is your org’s choice. In my build they flowed. Unallocated is one line inside the total, not a gap.

Turn on the exports

A few minutes in each billing console. On AWS and Azure, pick FOCUS 1.2, Parquet and overwrite, which keeps one current snapshot in each month’s folder. Each delivery ends with a manifest listing its files. Trigger on the manifest, not the data files. GCP writes its export to BigQuery instead.

The console steps for AWS, Azure and GCP
  • AWS: in the payer account (the management account that receives the consolidated invoice), Billing → Data Exports → a Standard data export of the table FOCUS with AWS columns. Created in any other account, it covers only that one. Activate the tag keys you might route on now, under Billing → Cost allocation tags: the export carries only activated keys. Backfill tags on that page reaches back up to 12 months, for tags the resources already had. For older months, open a support case from the payer account; AWS can backfill up to 14 months (tag backfill, export backfill).
  • Azure: Cost Management → Exports, at the billing account scope, a FOCUS export (Azure labels it 1.2-preview) with Overwrite data on. Created on a subscription, it covers only that subscription. It needs an EA or MCA billing account: a classic pay-as-you-go (MOSP) subscription can’t export FOCUS, but pay-as-you-go on an MCA can. It backfills up to 13 months from the portal, one month per run, and up to 7 years through the Exports REST API (exports).
  • GCP: Billing export → BigQuery export → Enable FOCUS export. It’s still in Preview. There’s no backfill. Put the dataset in the US or EU multi-region and the data starts at the beginning of the previous month; a regional dataset starts on the day you enable the export (setup).

Keep every raw month, one table per provider

Each provider gets its own raw table, because providers deliver at different times. Keep every raw month: a backfill or a fix rebuilds from it, and at a few GB a month that’s cheap.

s3://finops-data/
├── raw/                    what arrived, every month kept
│   ├── aws/focus/billing_period=2026-05/      the AWS export
│   ├── azure/focus/billing_period=2026-05/    copied from Azure
│   └── gcp/focus/billing_period=2026-05/      exported from BigQuery
└── warehouse/              rebuilt from raw, never edited
    ├── base_daily/billing_period=2026-05/provider=aws/
    └── base_monthly/billing_period=2026-05/provider=aws/

Each provider still adds its own x_* columns, writes tags its own way and may bill in its own currency. Even that out in one view per provider. Convert currency there at your org’s monthly rate, never a live one. A rerun then gives the same number.

Pick the columns and tags you allocate on

Profile your real data column by column, and keep a column if it passes three tests:

  • Useful cardinality. One value across all rows = no signal. One value per resource = ResourceId in disguise. Aim for a handful to a few thousand distinct values, far fewer than the rows.
  • Routing signal. It helps answer “who pays for this?”
  • Coverage, by cost. A tag on 20% of the rows can still cover most of the spend.

For tags, rank the keys by the cost they cover, fold near-duplicates like costcenter and cost-center into one, and keep each top key as its own column, like tag_product.

Add one column the bill doesn’t have, x_namespace, for a unit inside a charge that a team owns, like a Kubernetes namespace or a SaaS workspace. Part 5 and Part 6 fill it. Everywhere else it stays empty.

Shrink the bill into base tables

The columns you keep, plus the day, the summed costs and the partition keys, become base_daily, and base_monthly is the same without the day. A few rows look like this:

dtsubaccountnameservicename…effectivecost
2026-05-14payments-ledger-prodAmazon Elastic Compute Cloud…41.20
2026-05-14payments-ledger-prodAmazon Simple Storage Service…3.85
2026-05-14data-lake-prodAWS Glue…7.10
2026-05-15payments-ledger-prodAmazon Elastic Compute Cloud…41.20

In my build, a month of bills went from about 15 million lines and 2 GB of Parquet to about 610 thousand lines and 13 MB: 25 times fewer lines, 150 times fewer bytes. That’s why it runs for about $10 a month. Everything after the rebuild reads megabytes and grows with the number of distinct column combinations, not with resources. Add ResourceId and the table grows back toward raw size: if you must allocate per resource, this is the wrong design. What you give up is the exact resource behind a jump, and the raw table still has it.

Animation of a three-ram baling press. Loose bill slips, each printed with rows of line items and amber amounts, drop one by one into a barred chamber. The main ram presses them flat while a pressure gauge swings up, a back ram pushes the flat sheet forward into a strip, and an end ram squeezes the strip against the gate into one small strapped green box, about a hundred and fiftieth of the pile. The gate lifts and the box is pushed out onto the table, next to a stand reading base_daily.

Rebuild the whole month and check it against the invoice

Each export is the whole month to date, and yesterday’s cost can still change. Rebuild the month instead of appending days. Every delivery starts the Lambda from Part 1, which runs three steps on that provider’s month:

  1. Delete the provider’s month. Delete its files, not just the partition, or the rebuild counts them twice.
  2. Group and sum it again from raw. Read the month from the provider’s raw table, group by the kept columns and add up the costs.
  3. Check the totals against raw, within a small rounding tolerance: the monthly table on every run, the daily one once the month is over, since it stops at yesterday (below). If they differ, clear the partition and stop. I’d rather explain a gap on a dashboard than a number I can’t trace.

That proves the pipeline kept its input, not that the input is what you paid. Once a month, when the invoices land, sum BilledCost per invoice and compare it with the invoice itself, not with anything in the export. Store the result with the month.

The invoice check per cloud

Sum every charge category, within the same tolerance as the totals check. AWS issues several invoices for a month: usage, upfront purchases like the RI above, Marketplace, and now and then a credit memo. Tax is on each. One check per invoice. Google Cloud has no InvoiceId, so check per billing account and invoice month. Azure’s export has no tax and no Azure credits, so on MCA compare each invoice’s total before those two. EA has no InvoiceId and invoices only what the prepayment doesn’t cover, so check each billing account and month against the portal’s Total Charges.

Running it twice gives the same result. A late delivery, a retry, a backfill, a bug fix: all of them are just a rerun. I never built delta tracking and never missed it.

Stop the load at yesterday, too. Usage reaches the exports hours late, so a panel that includes today shows spend falling off a cliff every morning.


Part 3: Shared costs, split fairly

Pipeline 2 splits one row into many, and each piece is then routed on its own.

Two kinds of rows in the bill have the wrong shape for allocation. The first is a charge every team relies on that lands on one account. Enterprise Support is billed once a month to the payer account, the one that receives the whole org’s invoice, say AWS Support (Enterprise) = $50,000 (a bigger org than the one in Part 1). Whoever owns the payer account carries all of it, and every other team shows zero.

The second is a charge that covers a period. A monthly fee arrives as one row with chargeperiodstart=Apr-1 and chargeperiodend=May-1, and its whole cost sits on April 1. Charted, it’s a $3,000 spike on the 1st and nothing for the rest of the month.

The fix is the same for both. One row becomes many, with different keys and the same total. The split runs inside the load, in step 2 of the rebuild (Part 2). The query joins each row to a weights table. A row with weights comes out as one row per weight, and a row without stays as it was billed. Nothing is loaded first and removed later, so nothing can count twice.

Split a shared charge across teams

Give each account a share of the support bill equal to its share of the month’s EffectiveCost. Use the same month. April’s support shouldn’t be split by January’s mix. Compute the weights once a month, store them with the month (a rebuild has to reproduce them), and reuse them for every charge you split by spend.

Count only first-party spend. Software bought through the Marketplace, Bedrock models aside, doesn’t add to the support bill, so it doesn’t belong in the weights. My first version counted it anyway, and the month a vendor prepayment landed on one account, that account picked up a big slice of Enterprise Support it had nothing to do with. Credits and the support charge itself stay out too. Past that filter, the weights don’t copy AWS’s math: AWS charges on gross spend and counts a commitment in full the month it’s bought.

That gives one row per account, with weights summing to 1.0. Joined to the support row, each account gets support_total × weight:

Animation of three small tables. aws_focus holds one support row: Support on the payer account, $50,000. account_weights lists four accounts with their first-party spend for the month: data-eu $142,000, pay-eu $284,000, search-eu $177,500, payer $106,500, $710,000 in all, and each weight fills in as its share: 0.20, 0.40, 0.25, 0.15. Then the join writes one row per account, with the account and weight from account_weights and Support from the charge, and a cost of $50,000 times the weight: $10,000, $20,000, $12,500, $7,500. The original support row is struck out and left out of the load, and the new rows total $50,000.
The support row joins every row of account_weights, so one $50,000 charge on the payer account becomes one row per account at its share of spend. The original row stays out of the load, and the four new rows add back up to $50,000.

The same split shows up twice more with different weights: a SaaS bill from an aggregated vendor, weighted by the vendor’s own shares (Part 5), and Kubernetes namespaces, weighted by the CPU and memory their pods request (Part 6). In each case SUM(out) == SUM(in), and the totals check from Part 2 stops the run if it doesn’t.

Spread a period charge across its days

Write one row per day of the charge period, each carrying the cost divided by the number of days. A $3,000 charge over 30 days becomes 30 rows of $100.

Animation of two small tables. aws_focus holds one row: a monthly fee with a charge period from 04-01 to 05-01 and a cost of $3,000. Its day count fills in as 30, because the period ends at midnight on 05-01, so the last day is 04-30. Then base_daily, one row per day from 04-01 to 04-30, with two cost columns. Unsplit is the charge loaded as one row: $3,000 on 04-01 and nothing on the other days. Prorated fills in day by day with $100 each. Both columns total $3,000.
The unsplit column is the charge loaded as one row: $3,000 on 04-01 and nothing after. Prorated, every day carries $100, and both columns total $3,000.

Spread only charges whose period falls inside the month. Each rebuild writes only its own month, which leaves an annual license whole on its start date. Finance can amortize it in its own books, or the skill builds a spread for it if you want one in the views. Reservations and savings plans don’t hit this, because EffectiveCost already spreads them over their term (Part 2).

The split in SQL, simplified

Athena SQL, trimmed to the lines that do the work.

-- bill: the month's rows, each with the days it covers: one, or every day
-- of a period charge that starts and ends inside the month.
SELECT d.dt,
       COALESCE(w.subaccountid, c.subaccountid) AS subaccountid,
       c.servicename,
       SUM(c.effectivecost * COALESCE(w.weight, 1) / c.days) AS effectivecost
FROM bill c
LEFT JOIN account_weights w  -- only the support row matches: one per account
  ON c.shared_cost = 'support' AND w.billingaccountid = c.billingaccountid
CROSS JOIN UNNEST(sequence(c.first_day, c.last_day)) AS d (dt)
GROUP BY 1, 2, 3

Part 4: Who owns which cost, as rules in Git

Pipeline 3 is the rules. They route every row in the base tables, the split rows from Part 3 included.

The rules map what the bill knows (accounts, services, resource groups, the tags you kept, workspaces and namespaces) onto what finance knows (teams, apps, environments). They see only the columns kept in Part 2.

Start with the simplest mapping finance can use today: each account and service to one team. If finance wants a hierarchy (division → cost center → team), keep it in a separate table joined in the view. A team moving to another division is then a one-row edit there. A team that splits or merges is a rule change.

Write the rules and review the money they move

A rule says which rows belong to which team, by any mix of those columns. Account names alone are enough in a simple org, and a naming convention lets one pattern (payments-*) cover accounts nobody has created yet.

When two rules match a row, the more specific one wins. More specific means closer to the workload: a namespace over a tag, a tag over a resource group, a resource group over a service, a service over an account, and on the same column an exact name over a pattern. That’s how one account splits between teams: “S3 in the ledger account belongs to Data” beats “the ledger account belongs to Payments”. Two equally specific rules on one row are an error, never a coin toss.

A rule can set app and environment too. The winner sets everything it names and inherits nothing, so a narrow rule that forgets the environment leaves it Unallocated.

Every change is a PR, so “who moved this account last quarter?” is git log. A rule change moves money between teams, so the PR shows the money, not only the code.

What the PR shows

For a PR that gives Data the ledger’s S3 and Search its resource group in a shared account:

Cost moved, last 3 months
  payments → data       $11,700
  platform → search      $5,330

payments → data                 $11,700
  payments-ledger-prod
  Amazon Simple Storage Service
platform → search                $4,350
  shared-prod · rg-search-api
  Virtual Machines
platform → search                  $980
  shared-prod · rg-search-api
  Storage Accounts

Everyone whose number changes sees it before the merge, and a dispute (“why is my number carrying the data platform’s S3?”) is just another PR. Since CI knows the amounts, it can run whatever approval your org already uses: a change under $100 merges on its own, a bigger one waits for both teams the money moves between, and a large one waits for finance too. You pick the strategy and shape the tool around your own workflow.

Expand the rules into a lookup table

Running the patterns on every query would be slow, so a script runs them once. It takes every distinct combination of the columns rules can match, called a key, and writes the winning rule’s team into a lookup table, one row per matched key. Rules can name an account; the key holds its id. CI reruns it on every merge, and a daily run catches new keys.

Animation of three small tables. rules.json in Git holds three rules, ordered narrowest field first: pay-eu with S3 goes to data, pay-* to payments, search-* to search. Expansion takes every account and service pair on the bill, and a pointer walks each pair down the rules and stops at the first match, and that rule's team moves into the lookup: pay-eu EC2 payments, pay-eu S3 data, pay-us EC2 payments, search-eu RDS search. sandbox EC2 matches no rule and is not written. Then two days of base_daily cost rows: each row finds the lookup row with the same account and service and takes its team, and the sandbox row finds none and comes out Unallocated.
Expansion walks every account and service pair down the rules, narrowest field first, and writes the first match's team into the lookup. At query time each cost row takes its team from the lookup row with the same account and service. A pair no rule matched has no row, so its cost comes out Unallocated.

In my build, about 400 rules expand to some 10,000 keys. My accounts predate any naming convention, so most account rules name one account.

Join it at query time

A view joins every cost row to its lookup row on the key. The patterns already ran during expansion, so the join is a plain lookup, and a row with no match comes out Unallocated.

A change is cheap this way. A rule that sends the ledger’s S3 to Data rewrites only those lookup rows: the S3 moves for every month at once, and nothing in base_daily rebuilds. The run checks that the views still add up to the base tables, since a duplicate key would double someone’s cost. If finance needs closed months to stay put, snapshot them.

The view in SQL, simplified

Most key columns are NULL on most rows, and NULL never equals NULL in SQL, so both sides read NULL as an empty string and join with a plain =. Joining the columns into one key string, with a separator no value contains such as chr(31), keeps the expansion script and the view from disagreeing on the key: both build lookup_key from the same columns in the same order.

SELECT b.*, COALESCE(r.team, 'Unallocated') AS team
FROM base_daily b
LEFT JOIN rules_lookup r
  ON r.lookup_key = concat_ws(chr(31),
       COALESCE(b.subaccountid, ''), COALESCE(b.servicename, ''),
       COALESCE(b.x_resourcegroupname, ''), COALESCE(b.tag_product, ''),
       COALESCE(b.x_namespace, ''))

Old months change on purpose. The live views show every month under today’s rules, so a trend compares apples to apples: your org now against itself, not against the org you had six months ago. A rule or a correction restates history the moment it lands. If finance needs a closed month to stay as it was reported, snapshot it after the invoice check and report from that. The gap between the snapshot and the live views is your drift report.


Part 5: SaaS bills, split by the vendor’s usage data

A usage-based SaaS vendor is the Support problem from Part 3 again: one charge, many teams. Only the weights differ. They come from the vendor’s billing API, which reports usage per workspace, whatever unit your teams own: Elastic by deployment, OpenAI by project, Datadog by child organization or tag.

Each vendor adds three things:

  1. A match for its charges on the bill: the seller in FOCUS PublisherName (Elastic, say), optionally narrowed to the account that bought it.
  2. A shim, a Lambda that reads the vendor’s billing API and writes each workspace’s share of the month. The API’s numbers set only the proportions, so the split always adds up to the charge on the bill. The API can lag behind the bill: until it covers the charge’s whole period, the charge stays where it was billed.
  3. Rules for the workspaces. The split writes the workspace into x_namespace (Part 2). The rules route workspaces to teams like any account.

Say Elastic bills $10,000 on shared-prod, and its API reports usage per deployment in its own units:

Animation of four small tables. aws_focus holds one marketplace charge: Elastic on the shared-prod account, $10,000. vendor_shares, which the shim writes from Elastic's billing API, lists usage per deployment in Elastic's own units, not dollars: search-prod 5,400, data-prod 2,700, no deployment 900, 9,000 in all, and each share fills in as usage divided by the total: 0.6, 0.3, 0.1. The split multiplies the bill's $10,000 by each share and writes three rows on shared-prod into base_monthly: x_namespace elastic/search-prod $6,000, elastic/data-prod $3,000, and $1,000 with x_namespace empty. The original charge is struck out, and the three rows total $10,000. Last, rules.json routes each row, x_namespace first, then the account: elastic/search-prod to search, elastic/data-prod to data, and the row with no namespace falls through to the shared-prod account rule and goes to platform.
Elastic's usage, in its own units, only sets the shares. The bill's $10,000 splits by them into rows on shared-prod, each deployment in x_namespace, and the $1,000 no deployment used keeps x_namespace empty. The rules then route the rows as in Part 4: by x_namespace where there is one, by the account where there isn't.

A vendor that bills you directly has no charge on the cloud bill, so its shim writes one too: the vendor’s invoice as FOCUS rows, split the same way. Check their total against the invoice finance pays.

Rank vendors by spend and stop where a shim costs more than the clarity it buys.


Part 6: Kubernetes cost, split by namespace

A Kubernetes cluster runs many teams’ apps on the same VMs. Each team’s apps live in a namespace, one VM runs apps from many namespaces, and the cloud bills the VMs, disks and load balancers, so no line on the bill belongs to one team. This part splits each one across the namespaces that run on it. The namespace lands in x_namespace (Part 2) as cluster/namespace, like prod/pikachu, and the rules route it to a team like any account.

A cluster groups its VMs into node pools, sets of identical VMs it adds and removes together. A pool can serve one namespace or many. The bill’s tags say which cluster and pool a VM belongs to, and a disk or load balancer a team created carries its namespace. That gives three cases:

  • It belongs to one namespace. A VM in a pool that serves one namespace, or a disk or load balancer tagged with its namespace, goes straight to that namespace. Nothing is split. You say which pools serve one namespace, with a list or a naming convention.
  • It’s in a shared pool. The pool’s VMs, their boot disks and a shared ingress’s load balancer are split by how much of the pool each namespace reserved: the CPU and memory its apps asked for, read from the cluster’s metrics. It’s the split from Part 3 and Part 5, with a third source of weights.
  • Neither. The cluster’s management fee (the control plane), or anything tagged with a cluster but no pool, lands in a visible bucket for the team that runs the cluster (below).

How much each namespace reserved is a fact, so the split runs in the load. Which team owns a namespace is a decision, so it stays in the rules, where x_namespace is a key like the account. When a namespace changes hands, you change the rule and leave the cost tables alone.

Dedicated node pools: allocate by tags

The pool tag names the pool, your list says which namespace owns it, and the cluster tag names the cluster. The load writes cluster/namespace, and the system pods that run on every node ride with the tenant.

The EKS tag keys

These reach the export only after you activate them as cost allocation tags in the payer account.

ResourceTagNamespace
Node group VMeks:nodegroup-name=pikachuprod/pikachu
EBS volume (from a PVC)kubernetes.io/created-for/pvc/namespace=pikachuprod/pikachu
ALB (from an Ingress)ingress.k8s.aws/stack=pikachu/webprod/pikachu
NLB (from a Service)service.k8s.aws/stack=pikachu/webprod/pikachu

On Karpenter clusters the pool tag is karpenter.sh/nodepool. The cluster comes from aws:eks:cluster-name on the VMs and elbv2.k8s.aws/cluster on load balancers. A volume carries a cluster or pool tag only if whatever created it adds one, so check yours. AKS, ARO and GKE use their own keys, and on some of them a load balancer can’t be traced to its namespace from the bill: it lands in the cluster’s shared bucket (below).

Shared node pools: allocate by requests

A shared pool needs weights: which namespaces ran on it, and in what proportion. The weights add up to 1.0 per pool and day, and every row the pool bills joins them: the VMs, their boot disks and the shared ingress’s load balancer. I measure them from Prometheus.

If you already have per-namespace numbers from OpenCost, Kubecost or AWS’s split cost allocation data for EKS, they can fill the weights instead of the job below. Each treats idle capacity its own way, so their split won’t match this one exactly.

A daily job asks Prometheus, per cluster and pool, for two totals over the billing day. One is what each namespace’s running apps requested on the pool, summed over the day’s hours: CPU in core-hours, memory in GiB-hours. The other is what the pool could hold, in the same units. Summing hours rather than taking a snapshot means a spike, or a job that ran for twenty minutes, counts for as long as it ran, provided the samples come often enough to see it (below). The job writes each day to a file next to the exports, because Prometheus forgets and a rebuild months later must not depend on it.

The Prometheus side

PromQL over kube-state-metrics, for pool general on EKS:

# Core-hours requested by each namespace's running pods on pool "general":
# one sample an hour, added up. Evaluate it at 23:59:59 UTC of the billing day.
sum_over_time(
  sum by (namespace) (
    kube_pod_container_resource_requests{resource="cpu"}
      and on (namespace, pod) (kube_pod_status_phase{phase="Running"} == 1)
      and on (node) kube_node_labels{label_eks_amazonaws_com_nodegroup="general"}
  )[24h:1h]
)
# Core-hours the pool's nodes could hold, counted the same way.
sum_over_time(
  sum(
    kube_node_status_allocatable{resource="cpu"}
      and on (node) kube_node_labels{label_eks_amazonaws_com_nodegroup="general"}
  )[24h:1h]
)

The pool is picked by its node label, not the bill’s tag: on EKS the label is eks.amazonaws.com/nodegroup and the tag eks:nodegroup-name; on Karpenter both are karpenter.sh/nodepool. kube-state-metrics exports a node label only when it’s listed in --metric-labels-allowlist, and without that the queries return nothing, with no error. If one Prometheus serves several clusters, add the cluster’s label to every selector. Either way, the job writes each namespace as cluster/namespace under the cluster’s name on the bill, not the one in the metrics.

For memory, use resource="memory" and divide by 2^30 for GiB-hours; for GPUs, resource="nvidia_com_gpu". On a pool of short batch jobs, use [24h:5m] and divide by 12, or a job that starts and ends between samples reserves for free.

Why requests rather than actual usage? A team that requests 16 cores and uses 4 is holding 16 cores of scheduling capacity. Bill them for 16. The flip side is that a pod with no requests reserves nothing and pays nothing. Give every namespace default requests (a LimitRange does it) and nothing runs free. A pod that bursts past its request still pays only for the request. If that’s common on your clusters, weight by the larger of request and usage, as OpenCost and Kubecost do. The skill in Part 7 weights by requests alone.

Dominant resource. For each namespace, take the larger of its CPU and memory shares. A namespace requesting 60% of the memory and 5% of the CPU is bound by memory, because memory is what fills the pool first and blocks further scheduling. That larger share is the namespace’s binding. On a GPU pool, use GPU requests alone, since the card is most of the price.

Worked example. Three namespaces on one shared pool, and the pool’s bill for one day:

Animation of two tables. The first, namespace_weights, has one row per namespace on shared pool G and a pool row, and holds one day of Prometheus requests in core-hours and GiB-hours: pikachu 480 and 2,016, eevee 240 and 960, snorlax 120 and 384, against the pool's capacity of 1,200 and 4,800. Each request divides by the capacity into a CPU and a memory share: pikachu 0.40 and 0.42, eevee 0.20 and 0.20, snorlax 0.10 and 0.08, and the larger one binds: 0.42, 0.20, 0.10, which total 0.72. Each binding divided by 0.72 is the namespace's weight: pikachu 0.583, eevee 0.278, snorlax 0.139, summing to 1.0, so the namespaces carry the whole pool, idle capacity included. The second table starts as the day's aws_focus rows for pool G, one per service: EC2 $1,000, EBS $40, ALB $25. Each row joins the weights on the pool and expands into one row per namespace with cost times weight, and the rows below slide down: EC2 into pikachu $583, eevee $278, snorlax $139; EBS into pikachu $23, eevee $11, snorlax $6; ALB into pikachu $15, eevee $7, snorlax $3. The 9 rows add back up to the bill, $1,065.
Each namespace's requests divide by the pool's capacity, and the larger of its CPU and memory shares, in bold, binds. Each of the pool's aws_focus rows then expands into one row per namespace at cost × weight, and the new rows add back up to the bill.

Dividing each binding by their sum makes the weights add up to 1.0, so the namespaces carry the whole pool, idle capacity included.

Who carries idle is a policy choice. The tenants can, since their requests decided how big the pool had to be. Or the team that runs the cluster can: keep each binding as its weight and send the rest, here 28% of the pool, to an idle bucket. When the bindings add up past 1.0, as a CPU-heavy and a memory-heavy namespace can, nothing is left over and the pool splits as above. I put it on the tenants. System namespaces such as kube-system and monitoring get weights like any tenant, and a rule routes them to the cluster team.

Not everything is worth splitting

What no weight covers, such as the control plane, a pool nobody measures or a disk without its pool tag, lands in a named bucket like prod/cluster:shared, and a rule gives it to the cluster team. Track the share of cluster spend that reached a namespace each month, and when it drops, fix the biggest bucket first.

A noisy service, one that hammers the logging stack or moves most of the cluster’s traffic, is never split right by requests. Give it a dedicated pool and its cost, traffic included, maps straight to its namespace. I’d take that over modeling sidecars and shared traffic, a model that’s never finished.


Part 7: Build it with an agent skill

The skill (mooracle/finops-skills) is this guide turned into instructions for a coding agent such as Claude Code. It ships no product and no code. It brings the logic of Parts 1 to 6 and the right questions about your org, and with them it guides your agent to build your own tool, in your stack. The code it writes is specific to your org, not to the run: once built, it moves money exactly the same way every time, by your rules. Another org gets different code because its process is different. Either way, it holds what that org needs and nothing else.

Logic without code needs its own proof. Before release, I had fresh agents rebuild each piece from the specs alone and pass the golden cases that ship with the skill, inputs with known right answers. Where no case covers a piece, such as the plan checker, a grader’s tests did the same job. Wherever an agent had to guess, the spec got one more line.

What you give it

  • A FOCUS export from at least one cloud, read access to where it lands and, on AWS, to the payer’s list of cost allocation tags. No export yet? The skill starts by turning one on with you (Part 2).
  • A repo and your stack: Athena, BigQuery, Snowflake or Databricks, with dbt on top if you use it.
  • To split SaaS vendors or shared Kubernetes pools, read access to the vendors’ billing APIs and to your cluster metrics in Prometheus or Datadog (it doesn’t read OpenCost or Kubecost). Read-only kubectl is optional: without it, the skill finds the namespaces from the bill’s tags.
  • Answers to what the bill can’t show. It reads your last closed month first and shows you every result. Then it asks a few questions at a time, each with what it found, why it matters and, where it’s safe, the answer it recommends. Anything that moves money or ownership is never guessed: until someone answers, the part that needs it waits.
One question from a test run

On a synthetic bill, shortened:

Found:    21 accounts. 16 follow <team>-<app>-<env> and carry 88.7% of the cost.
          Outside it: the payer, a legacy analytics account, a sandbox and two more.
Question: Is this naming convention real policy, and who provisions accounts?
Why:      If it's policy, a few pattern rules cover every future account.
Unknown:  treated as "not policy" and logged, so you can overturn it later.

What it plans and builds

It writes your org down in three files in your repo: how your org maps to the bill, every finding and answer, and the status. From them it proposes a plan with a verdict per component: ready, blocked until a named question is answered, not a fit, or skipped. A “not a fit” comes with the simpler alternative, which can be “buy a tool” or “don’t build”: if nobody will own it, or your console’s own allocation is enough, that’s its answer. Nothing is built until you approve.

Then it builds only the pieces of Parts 2 to 6 your bill needs, in this guide’s order, as code in your repo and your cloud account that you review like any other:

  • the exports and base tables, with their totals checks and the monthly invoice check;
  • the rules repo and its CI;
  • vendor shims and namespace weights, if you need them;
  • a weekly Unallocated worklist, and alarms on a failed check and on Unallocated above a threshold you pick.

From the base tables on, each phase brings the skill’s golden cases into your test runner, plus one for each place your org differs, and every phase passes a gate on your real data before the next one starts.

The gates from a test run

For the synthetic AWS and Azure bills:

Gate 3 (base tables), 2026-09, aws: PASSED
  bill 137,831.50 · monthly 137,831.50 · daily 137,831.50 · drift 0.00
  support: 1 row → 21 accounts, weights sum 1.000000
Gate 3 (base tables), 2026-09, azure: PASSED
  bill 20,097.00 · monthly 20,097.00 · daily 20,097.00 · drift 0.00
Gate 4 (rules), 2026-09: PASSED
  Unallocated 8,965.00 of 157,928.50 USD (5.7%) · 105 of 106 resources matched

That 5.7% is a first pass. In the same run, the SaaS splits then took it to 0.3%.

It’s done when whoever reports cost answers “what did team X cost last month?” from a view without asking an engineer, every closed month passes the totals check, and Unallocated keeps going down. Later, tell it about a re-org, a new vendor or a new cluster, and it reopens only the phases that depend on it.

Try it on sample data, then on your bill

Install it in Claude Code (any agent that reads SKILL.md skills takes the same folder in its own skills directory):

git clone https://github.com/mooracle/finops-skills
mkdir -p ~/.claude/skills
cp -r finops-skills/skills/cost-attribution ~/.claude/skills/

Try it on sample data before giving an agent any access: ask in plain words (“show me on sample data first”). It generates a synthetic month of an AWS bill, builds the base tables and rules in DuckDB on your laptop with no credentials (it needs Python 3; the agent installs DuckDB and PyYAML), checks every number against an answer key written into the skill, and breaks a split on purpose so you can watch the totals check stop the run. In my test runs, a fresh agent finished it in about 15 minutes.

For the real build, ask in your repo (“help me build cost allocation for our AWS and Azure org”) or run /cost-attribution. It deploys with your credentials, or you merge its pull requests and deploy yourself. What it reads goes to the model you use, so clear it the way you’d clear any coding agent with access to your billing data.

Don’t build it all at once

The cloud will keep billing by account, and your company will keep reorganizing. This build is what translates one into the other: rules in Git, totals checked against the bill, cost by team through yesterday, for about $10 a month. The skill won’t hand it to you finished. It brings the logic and the right questions, and you end up with a tool you own.

Start with your cloud bill and the accounts you can already name, then add SaaS shims and Kubernetes when someone asks for them. Every stage ships numbers a team can trace to a rule it can read, and those are the numbers people act on.

Budgets, showback, an internal app: build them on the views, with whatever tools you like. Pull requests with notes on SaaS vendors you’ve onboarded are welcome, and issues for clusters its weights don’t fit.

Contents ↑