Guides

Virtual tags

A virtual tag is a tag key LevelFour assigns to each line of your AWS and GCP bills, from rules you write. Your provider tags stay as they are in the bill. The virtual key sits beside them, and you group, filter and split costs by it the way you would by any tag.

Use one when:

  • your tags disagree, as when one team shows up as team=data, team=Data and team=data-prod
  • ownership follows an account, a project or a service rather than a tag
  • one key has to cover AWS tags and GCP labels
  • a shared cost, such as the AWS Support fee, has to be divided between teams
Virtual tags read detailed cost data: the Cost and Usage Report on AWS and the detailed billing export on GCP. LevelFour cannot assign a value to spend in an account without it, and a per-team total then leaves that account out. GET /api/v1/tags/dimensions names those accounts under capabilities.accounts_without_detailed_data.

How LevelFour picks a value

For each cost line, LevelFour works down this list and stops at the first step that produces a value:

  1. The provider's value, when the virtual key has the same name as a provider key and Can Override is off. A line that already carries the provider key keeps that value.
  2. Collapsed keys, in order. The first source key present on the line supplies the value.
  3. Configs, in order. The first config whose time frame covers the line's date and whose filter matches assigns its value, or splits the line across several values.
  4. The provider's value, when Can Override is on and no step above matched.
  5. Unallocated, for any line left.

A config's filter holds a list of rules, and one matching rule is enough. A rule matches when the line comes from one of the rule's providers and all of its conditions hold.

Position is the only precedence. To pin a narrow case, such as one resource owned by another team, put its config above the broad config that would claim it. l4 tags preview reports the spend a lower config matches after a higher one has taken it.

Build a Teams key

The examples below build one Teams key. A config is one entry in the key's ordered list of values, with its rules underneath. The key is a YAML file you pass to l4 tags apply, and the file format lists every field. Each snippet is one entry under configs:, and the whole file puts them together.

One value across two providers

AWS bills Amazon Redshift, GCP bills BigQuery, and the data team owns both. One rule covers both providers, and its condition lists both service names:

- value: data
  rules:
    - providers: [aws, gcp]
      where:
        - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]}

A condition with several values matches any one of them.

An AWS account and a GCP project

The mobile team owns the AWS account 111122223333 and the GCP project mobile-prod. Give the mobile value one rule per provider. The account dimension reads the account ID on AWS and the project ID on GCP:

- value: mobile
  rules:
    - providers: [aws]
      where:
        - {dimension: account, values: ["111122223333"]}
    - providers: [gcp]
      where:
        - {dimension: account, values: [mobile-prod]}

operator defaults to is, so these conditions leave it out. Quote account IDs so YAML keeps them as text.

Fold several spellings into one value

On AWS, the data team's resources carry team=data, team=data-prod and team=Data. On GCP they carry the label team=data. Add two rules to the data config and all of them become data:

- value: data
  rules:
    - providers: [aws, gcp]
      where:
        - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]}
    - providers: [aws]
      where:
        - {dimension: tag, key: team, values: [data, data-prod, Data]}
    - providers: [gcp]
      where:
        - {dimension: tag, key: team, values: [data]}

A tag condition names its key in key. is is case-sensitive, so Data needs its own entry next to data.

Match spellings nobody has typed yet

flexible_match ignores case, spaces, hyphens and underscores on both sides. One condition on Team A matches teamA, team_a, team-a and TEAM A:

- value: team-a
  rules:
    - providers: [aws, gcp]
      where:
        - {dimension: tag, key: team, operator: flexible_match, values: [Team A]}

A resource someone tags Team-A next quarter lands in team-a with no change to the key.

Change the owner on a date

The infrastructure team ran the eks-prod cluster until June 30, and the platform team took it over on July 1. Two configs with the same rule and adjoining time frames keep both halves right:

- value: infrastructure
  time_frame: {end: "2026-06-30"}
  rules:
    - providers: [aws]
      where:
        - {dimension: resource, operator: contains, values: [eks-prod]}
- value: platform
  time_frame: {start: "2026-07-01"}
  rules:
    - providers: [aws]
      where:
        - {dimension: resource, operator: contains, values: [eks-prod]}

time_frame takes start, end or both, as YYYY-MM-DD, and LevelFour compares them with each cost line's date. A config without one applies on every date.

Split a shared account by percentage

Account 999900001111 runs shared services, and the teams agreed on 60 percent for infra and 40 for mobile:

- split:
    - {value: infra, pct: 60}
    - {value: mobile, pct: 40}
  rules:
    - providers: [aws]
      where:
        - {dimension: account, values: ["999900001111"]}

Each matched line becomes one line per value, its cost multiplied by the percentage. The percentages add up to 100, and a value appears once per split.

Split a support fee by usage

The AWS Support fee serves every team. A cost-based config divides it in proportion to each team's AWS usage:

- cost_based:
    source: {origin: provider, key: team}
    input:
      - providers: [aws]
        where:
          - {dimension: charge_type, values: [Usage]}
  rules:
    - providers: [aws]
      where:
        - {dimension: service, operator: starts_with, values: [AWS Support]}

For each day and provider, LevelFour measures how the cost that input matches divides across the values of source. It then splits each line that rules matches in the same proportions. When lines tagged team=data carry 30 percent of a day's AWS usage, data takes 30 percent of that day's support fee.

The shares follow source as it reads on the bill, so data and Data receive separate shares. source can also name another virtual key, though not the key you are defining.

Backfill past months

effective_from is the first billing month LevelFour applies the key to, as YYYY-MM. Leave it out and the key starts with the current month. Past months are reprocessed from it.

name: Teams
effective_from: "2026-06"

Saving a change to the configs, the collapsed keys, Can Override or effective_from reprocesses the key from that month on. The key's status reads reprocessing until the job finishes, and l4 tags show reports its progress. Months before effective_from carry no value for the key, and Limits caps how far back it reaches.

On AWS the job rebuilds a month it no longer holds by reading the Cost and Usage Report again, so backfill reaches the full window. On GCP it reaches only as far as the billing export LevelFour has already loaded, and an earlier month stays empty for the key.

Extend or replace a provider key

Name a virtual key after a provider key, team for instance, and it builds on that key's values. can_override settles lines where both have an answer:

Linecan_override: false (default)can_override: true
Carries the provider keyKeeps the provider valueTakes the first collapsed key or config that matches, and keeps the provider value when none does
Lacks the provider keyTakes the first collapsed key or config that matchesTakes the first collapsed key or config that matches

With false, the key fills gaps: untagged lines get a value and tagged ones keep theirs. With true, your rules correct what the provider tags say.

Collapse several keys into one

Your teams spell one key three ways: environment on most resources, env on older AWS ones, Environment on GCP labels. A collapsed key copies the value of a source key, and the first source present on a line wins:

environments.yaml
name: Environments
collapsed_keys:
  - source: {origin: provider, key: environment}
  - source: {origin: provider, key: env}
    providers: [aws]
  - source: {origin: provider, key: Environment}
    providers: [gcp]
configs: []

providers limits a source to one provider's lines. prefix puts text in front of the copied value, so prefix: "gcp-" turns prod into gcp-prod and keeps two sources apart. A source can also be a virtual key, with origin: virtual.

LevelFour applies collapsed keys before any config. On the Teams key, a collapsed source on team would copy Data through before the rule that folds it into data ran.

Nest one virtual tag inside another

A virtual_tag condition reads the value another virtual key assigned. Say an Applications key maps resources to checkout, search and billing. A Business units key rolls them up:

business-units.yaml
name: Business units
configs:
  - value: commerce
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: virtual_tag, key: Applications, values: [checkout, search]}
  - value: finance
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: virtual_tag, key: Applications, values: [billing]}

LevelFour evaluates Applications first, so the condition reads its finished values. The API refuses a definition that breaks any of these:

  • a key that another key reads cannot hold split or cost-based configs
  • two keys cannot read each other in a loop
  • a chain of nested keys stops at the depth in Limits

LevelFour also refuses to delete Applications while Business units reads it.

The whole file

The Teams key with every config above, in priority order:

teams.yaml
name: Teams
description: Owning team across AWS and GCP.
effective_from: "2026-06"
can_override: false
configs:
  - value: infrastructure
    time_frame: {end: "2026-06-30"}
    rules:
      - providers: [aws]
        where:
          - {dimension: resource, operator: contains, values: [eks-prod]}
  - value: platform
    time_frame: {start: "2026-07-01"}
    rules:
      - providers: [aws]
        where:
          - {dimension: resource, operator: contains, values: [eks-prod]}
  - cost_based:
      source: {origin: provider, key: team}
      input:
        - providers: [aws]
          where:
            - {dimension: charge_type, values: [Usage]}
    rules:
      - providers: [aws]
        where:
          - {dimension: service, operator: starts_with, values: [AWS Support]}
  - value: data
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]}
      - providers: [aws]
        where:
          - {dimension: tag, key: team, values: [data, data-prod, Data]}
      - providers: [gcp]
        where:
          - {dimension: tag, key: team, values: [data]}
  - value: mobile
    rules:
      - providers: [aws]
        where:
          - {dimension: account, values: ["111122223333"]}
      - providers: [gcp]
        where:
          - {dimension: account, values: [mobile-prod]}
  - value: team-a
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: tag, key: team, operator: flexible_match, values: [Team A]}
  - split:
      - {value: infra, pct: 60}
      - {value: mobile, pct: 40}
    rules:
      - providers: [aws]
        where:
          - {dimension: account, values: ["999900001111"]}

The narrow configs sit on top. The support fee comes before mobile, so a support line billed to the mobile account still gets split by usage. A line in the shared account tagged team=data goes to data, because that config sits above the split. Move the split up and the shared account wins instead.

Try the file, then save it:

l4 tags preview -f teams.yaml
l4 tags apply -f teams.yaml

Dimensions and operators

OperatorMatches when the line's value
is (default)equals one of values
is_notequals none of values
containscontains one of values
not_containscontains none of values
starts_withstarts with one of values
ends_withends with one of values
flexible_matchequals one of values once case, spaces, - and _ are ignored on both sides
not_flexible_matchequals none of values under the same comparison

Every operator except the flexible pair is case-sensitive.

A negative operator on a tag condition also matches lines that lack the key. team is_not data catches untagged cost along with the other teams' cost. To catch only lines tagged with another team, add {dimension: tagged, key: team} to the same rule.

Conditions read these dimensions straight from the bill: account, billing_account, region, service, category, subcategory, usage_unit, resource, charge_type and marketplace. category and marketplace exist on AWS only. The rest read keys:

  • tag matches the value of a provider tag or GCP label, and needs key
  • tagged checks that a key is present and takes no values. Leave out key to match a line carrying any key
  • virtual_tag matches the value of another virtual key, and needs key

The Tags API reference maps each dimension to its AWS and GCP billing column. GET /api/v1/tags/dimensions returns the catalog per provider, and GET /api/v1/tags/dimensions/{dimension}/values returns the values a condition can match in your data.

Limits

LimitValue
Virtual keys per organization50
Configs per key100
Values per key100
Rules per config20
Conditions per rule10
Values per condition200
Collapsed keys per key20
Value prefix64 characters
Nesting depth5
Preview window31 days
Backfill12 months

A definition over a limit fails with limit_exceeded.

Unallocated cost

A line no step assigns is unallocated. A breakdown grouped by a virtual key shows that cost as its own group, so the groups add up to the bill. Without it, the per-team totals would sum to less than your spend, with nothing to say why.

The API returns the group as __unallocated__. l4 tags show prints a key's unallocated_spend, and l4 tags coverage compares the share of spend provider tags cover with the share covered once virtual tags are added. To shrink it, draft a config, run l4 tags preview, and check how much spend leaves unallocated before you save.

Where virtual keys appear

A saved virtual key works where a provider tag key does:

  • The provider cost breakdown groups by it (group_by=virtual_tag) and filters by its values. Split lines arrive with their cost divided, so totals still match the bill.
  • Costs by tag and cost allocation take a virtual key in tag_key.
  • Recommendations carry virtual_tags, the value of each virtual key on the recommendation, and filter by virtual_tag_key and virtual_tag_value.
  • l4 tags costs prints spend per value, and l4 costs breakdown groups and filters by a key with --virtual-tag-key.
  • The MCP server reads a key, tries rules before they are saved, and defines and edits one. Tools is generated from the surface the server advertises and names each one.

Permissions

Any member, and any API key, can read virtual tags. Creating, changing or deleting one takes the org:tags:write permission, which a read-write API key carries. Key scopes covers the two kinds of key.

Next

  • l4 tags keeps virtual keys in YAML files you apply from the terminal
  • Costs reads the breakdowns a virtual key groups
  • Tags API reference has every endpoint and every validation code