> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thoras.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# AWS Cost Explorer

> Collect your cluster's real billed AWS spend so Thoras reports actual cost instead of estimates.

Thoras estimates cluster cost from a built-in static price list. Those estimates
are directional by design — they don't know about your reserved instances,
savings plans, or negotiated rates. See [Cost Estimates](/guides/cost-estimates)
for how that calculation works.

Enabling AWS Cost Explorer collection gives Thoras the spend AWS actually billed
you, broken down by service and usage type for each day. Cost reporting uses
this in place of the estimates, so the figures you see match your invoice.

Collection is off by default. AWS bills every Cost Explorer API request, so
turning it on is an explicit opt-in.

<Note>
  Cost Explorer collection reads AWS billing, so it applies to EKS clusters.
  Thoras skips collection on clusters it detects are running in another cloud
  rather than paying for a query that cannot return anything.
</Note>

## Before you start

You need:

* An EKS cluster with an
  [IAM OIDC provider](https://docs.aws.amazon.com/eks/latest/userguide/enable-iam-roles-for-service-accounts.html)
  associated, so the Thoras worker can assume a role through IRSA.
* [Cost Explorer enabled](https://docs.aws.amazon.com/cost-management/latest/userguide/ce-enable.html)
  on the account that pays for the cluster.
* The `aws:eks:cluster-name`
  [cost allocation tag activated](https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/activating-tags.html)
  in the Billing console of that account.

<Warning>
  Activating the cost allocation tag is the step most often missed. Thoras
  filters your account's spend down to this cluster using that tag, and Cost
  Explorer only returns tagged spend for tags that have been activated. If the
  tag is inactive, every query succeeds and returns nothing. Activation can take
  up to 24 hours to take effect, and tag data may not reach all the way back
  through the initial 90-day backfill window.
</Warning>

## Grant access to Cost Explorer

The Thoras worker reaches Cost Explorer with whatever AWS credentials its pod
picks up. On EKS, bind an IAM role to the worker's service account with IRSA.

The role needs a single permission, `ce:GetCostAndUsage`. Cost Explorer does not
support resource-level permissions, so the policy is scoped to `*`. Thoras also
resolves your AWS account ID through `sts:GetCallerIdentity`, which requires no
permission of its own.

### Terraform

```hcl theme={null}
data "aws_eks_cluster" "this" {
  name = var.cluster_name
}

# Your cluster's existing IAM OIDC provider. Create one with
# aws_iam_openid_connect_provider first if the cluster does not have it.
data "aws_iam_openid_connect_provider" "eks" {
  url = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
}

locals {
  # Trust policy conditions are keyed on the issuer host, not the full URL.
  oidc_host = replace(
    data.aws_eks_cluster.this.identity[0].oidc[0].issuer,
    "https://",
    "",
  )
}

data "aws_iam_policy_document" "thoras_cost_explorer_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [data.aws_iam_openid_connect_provider.eks.arn]
    }

    # Only the Thoras worker's service account may assume this role. Match
    # thorasWorker.serviceAccount.name if you changed it from the default.
    condition {
      test     = "StringEquals"
      variable = "${local.oidc_host}:sub"
      values   = ["system:serviceaccount:${var.thoras_namespace}:thoras-worker"]
    }

    condition {
      test     = "StringEquals"
      variable = "${local.oidc_host}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "thoras_cost_explorer" {
  statement {
    effect  = "Allow"
    actions = ["ce:GetCostAndUsage"]

    # Cost Explorer has no resource-level permissions.
    resources = ["*"]
  }
}

resource "aws_iam_role" "thoras_cost_explorer" {
  name               = "thoras-cost-explorer"
  assume_role_policy = data.aws_iam_policy_document.thoras_cost_explorer_trust.json
}

resource "aws_iam_role_policy" "thoras_cost_explorer" {
  name   = "thoras-cost-explorer"
  role   = aws_iam_role.thoras_cost_explorer.id
  policy = data.aws_iam_policy_document.thoras_cost_explorer.json
}
```

Activate the cost allocation tag in the same run. This resource belongs in the
account that pays the bill, which may be a different provider alias than the one
holding the cluster:

```hcl theme={null}
resource "aws_ce_cost_allocation_tag" "eks_cluster_name" {
  tag_key = "aws:eks:cluster-name"
  status  = "Active"
}
```

### Without Terraform

Create a role with this trust policy, substituting your account ID, OIDC issuer
host, and the namespace Thoras is installed in:

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:sub": "system:serviceaccount:thoras:thoras-worker",
          "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE:aud": "sts.amazonaws.com"
        }
      }
    }
  ]
}
```

Attach this permission policy to it:

```json theme={null}
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "ce:GetCostAndUsage",
      "Resource": "*"
    }
  ]
}
```

## Enable collection

Point the worker's service account at the role and turn the collector on:

```yaml theme={null}
thorasWorker:
  serviceAccount:
    annotations:
      eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/thoras-cost-explorer
  enableCostAndUsageCollector: true
```

Then `helm upgrade` your release. The worker restarts and begins collecting
immediately.

## How Thoras identifies your cluster's spend

Your AWS account bills for more than one cluster, so Thoras narrows each query
to a cost allocation tag. It reads that tag from your node labels:

| Node label                       | Cost allocation tag    |
| -------------------------------- | ---------------------- |
| `eks.amazonaws.com/cluster-name` | `aws:eks:cluster-name` |
| `alpha.eksctl.io/cluster-name`   | `aws:eks:cluster-name` |

If none of your nodes carry those labels — self-managed nodes often don't —
collection stops and logs the labels it looked for. Set the tag yourself in that
case:

```yaml theme={null}
thorasWorker:
  costExplorerFilter: "my-cost-tag=prod-cluster"
```

The value is a `TAG_KEY=TAG_VALUE` pair and replaces the node label lookup
entirely. Use it when your spend is tracked under your own tagging scheme rather
than the AWS-generated cluster tag. Whatever key you choose must be activated as
a cost allocation tag, the same as `aws:eks:cluster-name`.

## What Thoras collects

Each run stores one row per day, per AWS service, per usage type:

* **Billed cost** — the cost as it appears on your invoice.
* **Effective cost** — amortized and commitment-adjusted, so reserved instance
  and savings plan fees are spread across the term they cover rather than
  landing on the day they were charged.
* **Usage quantity and unit** — how much of that usage type was consumed.

The collector runs every 24 hours. AWS finalizes a day's costs well after the
day ends, so collecting more often would not make the data fresher.

* The first run backfills **90 days**.
* Every run after that re-fetches the **last 7 days**, overwriting what it
  already stored. AWS restates recent days as usage finalizes, and this picks up
  those corrections.
* The job also runs when the worker starts, but skips if the stored data is less
  than 20 hours old, so a rollout or a crash loop doesn't pay for the same days
  again.

## What this costs

AWS charges per Cost Explorer API request — \$0.01 per paginated request as of
September 15, 2026. See
[AWS Cost Management pricing](https://aws.amazon.com/aws-cost-management/pricing/)
for current rates.

A nightly run costs a handful of requests. The 90-day backfill on first run is
larger but one-time. Cost scales with how many distinct service and usage type
combinations your account bills for, since that determines how many pages each
query returns.

## Verify it's working

Check the worker logs:

```bash theme={null}
kubectl logs -n thoras deploy/thoras-worker | grep -i "cost and usage"
```

On startup you should see `Cost and usage collector is enabled`, then
`Collecting daily cost and usage` with the date window and the tag Thoras
resolved:

```
Collecting daily cost and usage start=2026-06-17 end=2026-09-14 tagKey=aws:eks:cluster-name tagValue=prod-cluster
```

Confirm `tagValue` matches your cluster. If it doesn't, set `costExplorerFilter`
explicitly.

## Troubleshooting

**`Cost and usage collection returned no rows`** — the query succeeded but
matched no billed resources. Almost always the cost allocation tag isn't
activated, or the `tagKey` and `tagValue` in the log don't match how your
resources are actually tagged. Check the Billing console first, then verify the
tag against a node in the EC2 console.

**`no cost filter could be resolved`** — none of your nodes carry a recognized
cluster name label. The log lists the labels Thoras searched for. Set
`thorasWorker.costExplorerFilter` to your own `TAG_KEY=TAG_VALUE` pair.

**`AccessDeniedException` when calling Cost Explorer** — the worker assumed a
role without `ce:GetCostAndUsage`, or didn't assume one at all. Confirm the
service account carries the annotation:

```bash theme={null}
kubectl get serviceaccount thoras-worker -n thoras -o yaml
```

If the annotation is there but the role isn't being assumed, the trust policy's
`sub` condition likely doesn't match — it must name the namespace Thoras is
installed in and the worker's service account name.

**`Could not build the AWS Cost Explorer provider`** — the worker couldn't read
its AWS configuration. It keeps running and collects nothing rather than failing
startup. Check for a malformed AWS config file or environment.

**`The cost and usage provider does not bill the cloud this cluster runs in`** —
Thoras detected the cluster is running somewhere other than AWS and skipped the
query. Setting `costExplorerFilter` bypasses this check if you're intentionally
attributing non-EKS spend.
