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=Dataandteam=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
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:
- 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.
- Collapsed keys, in order. The first source key present on the line supplies the value.
- 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.
- The provider's value, when Can Override is on and no step above matched.
- 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:
| Line | can_override: false (default) | can_override: true |
|---|---|---|
| Carries the provider key | Keeps the provider value | Takes the first collapsed key or config that matches, and keeps the provider value when none does |
| Lacks the provider key | Takes the first collapsed key or config that matches | Takes 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:
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:
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:
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.yamlDimensions and operators
| Operator | Matches when the line's value |
|---|---|
is (default) | equals one of values |
is_not | equals none of values |
contains | contains one of values |
not_contains | contains none of values |
starts_with | starts with one of values |
ends_with | ends with one of values |
flexible_match | equals one of values once case, spaces, - and _ are ignored on both sides |
not_flexible_match | equals none of values under the same comparison |
Every operator except the flexible pair is case-sensitive.
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:
tagmatches the value of a provider tag or GCP label, and needskeytaggedchecks that a key is present and takes no values. Leave outkeyto match a line carrying any keyvirtual_tagmatches the value of another virtual key, and needskey
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
| Limit | Value |
|---|---|
| Virtual keys per organization | 50 |
| Configs per key | 100 |
| Values per key | 100 |
| Rules per config | 20 |
| Conditions per rule | 10 |
| Values per condition | 200 |
| Collapsed keys per key | 20 |
| Value prefix | 64 characters |
| Nesting depth | 5 |
| Preview window | 31 days |
| Backfill | 12 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 byvirtual_tag_keyandvirtual_tag_value. l4 tags costsprints spend per value, andl4 costs breakdowngroups 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 tagskeeps 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
Filtering and sorting
Filter, sort, group and pipe LevelFour cost and recommendation data from the terminal, with the same parameters in the SDKs.
GitHub Actions
Workflows that comment a cost summary on a pull request, run a scheduled cost check with the Python or TypeScript SDK, and upload a daily report as a build artifact.