/

/

Why we built enrichments: what two years of cloud cost allocation taught us about tagging

Why we built enrichments: what two years of cloud cost allocation taught us about tagging

Cost allocation

Why we built enrichments: what two years of cloud cost allocation taught us about tagging

A shared database can be perfectly tagged and still tell you nothing about what each product costs to run. Enrichments apply the ownership table you already keep, with dates.

·

Costory team

·

6 min read

A shared database can be perfectly tagged and still tell you nothing about what each product costs to run. Its owner is the platform team; its consumers are several product teams. Adding more tags won’t tell you how much of the bill belongs to each.

We just released enrichments in beta. Rule-based tools ask you to describe ownership as conditions; an enrichment takes the ownership table you already have, with dates, and applies it as is. You tag one stable key, such as a component, and connect it to the business relationships you already maintain in your service catalog. For shared resources, fixed shares or usage metrics distribute the cost between consumers. Each product’s cost view can then include its share of the database alongside its own infrastructure, while technical ownership stays a separate dimension.

AWS split cost allocation data and GKE cost allocation make a similar idea familiar: look inside shared infrastructure and attribute costs to the workloads running there. Enrichments let you define that breakdown beyond Kubernetes, using your own mappings and allocation metrics for resources such as shared databases.

Where we started: allocate first, tag later

The usual FinOps advice is to write a tagging policy, get every team to apply it, then start allocating. We never built Costory that way, because it didn’t match what we saw when customers connected their bills.

Their first question was nearly always how much production costs compared to everything else. Cost per feature came next. Cost per team came later, and how hard it was depended on the infrastructure: easy with one cloud account per team, a real project with shared Kubernetes clusters.

Consistent tags were rare. Tagging was usually sparse and came from one team’s initiative: the data team labelled its jobs, the platform team tagged its Terraform modules, Kubernetes workloads were identified only by their names, and the rest of the estate was bare. Customers often didn’t know what was tagged and what wasn’t, and the first group-by in the cost explorer was usually a surprise.

Asking them to wait for a company-wide tagging project would have meant months without numbers. And the bill already said more than it seemed. Account and project names contain prd or staging. Kubernetes namespaces and workload names are often the technical name of a feature. Resource IDs and resource groups follow naming conventions that teams have used for years without writing them down. Each clue is reliable locally. They are just spread over a dozen columns.

So our first belief was simple: allocation should start on day one with whatever the bill already says.

Tag cleanup and virtual dimensions: allocation from tiny clues

We built that belief in two layers.

The first layer is plain feature engineering. Labels that mean the same thing get merged, so user_env, environment and k8s_label_environment become one env column. Values get normalised, so prd and production stop counting as two environments.

Virtual dimensions sit on top. A virtual dimension is an ordered list of rules (the same idea as AWS Cost Categories or what many tools call virtual tags) that builds a new column out of all the small clues: if the sub-account ID contains prd, or the namespace contains prod, the row is production. Rules can mix account IDs, resource names, projects, namespaces and whatever partial labels exist. The Costory MCP helps here: an assistant connected to it can search many dimensions at once, notice that a namespace, a resource ID and a couple of stray labels point to the same feature, and propose a rule that a person reviews.

This still gets customers a long way quickly, and we don’t see it as a workaround to outgrow. Virtual dimensions are also how teams build their own groupings and filters, and how we try an allocation idea before anyone commits to it.

Where rule-based virtual dimensions stop scaling

Over time, some customers piled up a lot of rules. As dimensions grew long, the same trouble kept coming back: two rules match the same row, and their order decides the answer. One rule says a row is Production because the sub-account ID contains prd. Another says Staging because the resource name contains stg. A test resource in a production account matches both, and the order of the rules, not the data, decides the result. Plenty of long dimensions work fine, but in the ones with conflicting rules, explaining why a row landed in a given bucket started to take real time.

The second issue was about shape. A virtual dimension sets one column. Customers often wanted to set several at once from the same fact: this component belongs to this team, this cost centre and this business capability. Several customers who came to us from internal allocation tools arrived with that exact request, a way to set many dimensions from a single key.

The third change was on the customer side, and it is the one that made us revisit our belief. With coding assistants, adding a label across a Terraform codebase, or writing a large and fiddly mapping, became easy. Customers got noticeably more willing to do some tagging work up front. What they asked for was one well-maintained key on their resources, with the business context kept somewhere else.

We could have kept adding features to virtual dimensions. We decided the need was different: these customers didn’t want better inference, they wanted to state facts they already knew: a middle ground between virtual dimensions and full tagging.

Mapping a Backstage or CMDB catalog onto the bill

Two customers turned this into a design. Both had already spent real time tagging at a low level with a single key, the component. A component is a small technical unit. Some are owned by one team and serve one business purpose. Others, like a shared database, are used by several teams for several purposes.

Both also already had the mapping of which component is used by whom and for what. One kept it in Backstage, the other in an internal CMDB tool. The missing piece was a clean way to get that mapping onto the bill, and each design choice in enrichments comes from one of their needs.

They wanted their catalog to stay the source of truth, so an enrichment is a lookup table generated from it, not a set of rules to infer. One key column goes in, several business columns come out, and a file can hold thousands of keys. Both customers generate their files with a script from Backstage or the CMDB, keep them in git, and publish them through the API or the Costory Terraform provider.

They wanted reorgs to stop rewriting the past, so entries carry dates. A reorg adds a new entry from the date it takes effect, and past costs keep the ownership that applied at the time. Native tags can’t do that: a tag added today never reaches last year’s bill. Corrections work the other way. If last year’s mapping was wrong, you fix the dated entry, and every published change is backfilled over the full billing history. Because the files live in git, each of those changes has an author, a date and a review, so finance can see why a closed quarter moved.

A simplified file looks like this (the names are invented):

name: component_ownership
description: Generated from the service catalog
keyField: component   # the column your component label lands in
#...
targetColumns:
  - name: team
    mode: replace
  - name: cost_center
    mode: replace
  - name: business_capability
    mode: replace
entries:
  # a reorg: past costs keep the old owner
  - key: checkout-api
    when: { start: "2025-01-01", end: "2026-03-31" }
    then:
      team: Payments
      cost_center: CC-410
      business_capability: Checkout
  - key: checkout-api
    when: { start: "2026-04-01" }
    then:
      team: Commerce Platform
      cost_center: CC-520
      business_capability: Checkout
  # a shared database: each team pays its share of daily query time
  - key: orders-postgres
    then:
      metric:
        source: datadog
        formula: sum:postgresql.queries.time{db:orders} by {team,cost_center}
      propagates

name: component_ownership
description: Generated from the service catalog
keyField: component   # the column your component label lands in
#...
targetColumns:
  - name: team
    mode: replace
  - name: cost_center
    mode: replace
  - name: business_capability
    mode: replace
entries:
  # a reorg: past costs keep the old owner
  - key: checkout-api
    when: { start: "2025-01-01", end: "2026-03-31" }
    then:
      team: Payments
      cost_center: CC-410
      business_capability: Checkout
  - key: checkout-api
    when: { start: "2026-04-01" }
    then:
      team: Commerce Platform
      cost_center: CC-520
      business_capability: Checkout
  # a shared database: each team pays its share of daily query time
  - key: orders-postgres
    then:
      metric:
        source: datadog
        formula: sum:postgresql.queries.time{db:orders} by {team,cost_center}
      propagates

name: component_ownership
description: Generated from the service catalog
keyField: component   # the column your component label lands in
#...
targetColumns:
  - name: team
    mode: replace
  - name: cost_center
    mode: replace
  - name: business_capability
    mode: replace
entries:
  # a reorg: past costs keep the old owner
  - key: checkout-api
    when: { start: "2025-01-01", end: "2026-03-31" }
    then:
      team: Payments
      cost_center: CC-410
      business_capability: Checkout
  - key: checkout-api
    when: { start: "2026-04-01" }
    then:
      team: Commerce Platform
      cost_center: CC-520
      business_capability: Checkout
  # a shared database: each team pays its share of daily query time
  - key: orders-postgres
    then:
      metric:
        source: datadog
        formula: sum:postgresql.queries.time{db:orders} by {team,cost_center}
      propagates

The last entry splits the shared orders-postgres database each day by the time each team’s queries spent executing, which tracks what actually loads the database better than a raw query count. The team and cost_center tags on those metrics come from the applications themselves, for example by giving each service its own database role or tagging its connections. That instrumentation is the real work behind a usage-based split, and it pays off well beyond cost.

The component tag lives on the resources because it is stable: checkout-api is still checkout-api after a reorg. Team and cost centre move, so they live in the mapping.

Splitting a shared database across its consumers should give you more detail, not a much larger billing dataset. One of our design partners had built its own allocation before Costory: it loaded the GCP billing export into BigQuery and duplicated rows to split costs between owners, so one billing row could become 100 and a small export became a massive table. We store the shares as BigQuery arrays instead, so a split row stays one row.

Knowing who owns a cost doesn’t tell you how to split it

Tagging a shared Postgres cluster with its component says nothing about how much each consumer should pay. For both customers, the shared costs were mostly Kubernetes cluster waste, shared databases and network. Costory already reallocates shared costs from telemetry (Datadog, Tsuga, Snowflake, BigQuery, CloudWatch and others): each consumer’s share of a day follows its usage that day. In an enrichment file, the same kind of split is a dynamic entry that reads a daily metric, from a Datadog formula like the one above or from a BigQuery table.

Enrichments vs virtual dimensions: when to use each

Enrichments don’t replace virtual dimensions, and we didn’t design them to. They only work once a customer has done some internal work: a reliable key on resources and a mapping someone maintains. The customer does the part only they can do, deciding what a component is and who it belongs to, and Costory takes care of the history, the dated ownership and the usage-based splits. The parts of the bill without that foundation, including spend that can’t carry a label at all, such as support fees or some network charges, keep running on virtual dimensions, and exploration still happens there.


Virtual dimensions

Enrichments

Starts from

Any billing column and partial labels

One stable key, such as a component

Writes

One dimension

Several dimensions

Logic

Ordered rules that infer a value

Lookup files mapping keys to values

Changes over time

Applies the same way to all history

Dated entries, so past costs keep past ownership

Best for

Exploring, and spend with no reliable key

Ownership you already keep in a catalog or CMDB

Complete allocation still doesn’t require complete tagging. Two years in, our belief holds, with one addition: where a customer can maintain one stable key, an explicit mapping on top of it beats piling up inference rules, and that is why we built enrichments. The enrichments documentation covers the file format, testing and publishing with Terraform.

Costory offers a 30-day free trial, then a flat €250/month billed annually for up to €10M of annual cloud and AI spend.

Put this into practice with your own cost data

30-day free trial. Plans from €250/month, billed annually. We recommend Team (€600/month) once more than one team uses Costory.

First optimization recommendation

The next day

ROI

Pays for itself with the first recommendation you act on

Setup

Under 30 minutes

Data residency

EU

Access

Read-only on your cloud