Cloud Billing Data Ingestion & Parsing
Cloud billing data ingestion and parsing is the engineering discipline of pulling raw cost telemetry out of provider export channels, reshaping it into a single dimensional model, and writing it to an analytical store with exactly-once guarantees. At production scale this stops being a scripting problem and becomes a distributed-systems problem: the hard constraints are schema drift across export versions, eventual-consistency windows where billing data finalizes 24–48 hours after usage, and provider API rate limits that silently corrupt pipeline state when ignored. API-first automation matters here because dashboard exports cannot be unit-tested, replayed, or reconciled — and a single duplicated reportKey or a dropped pagination cursor propagates straight into showback invoices and false anomaly alerts. This guide defines the architecture, ships a runnable multi-cloud ingestion module, and closes with the failure modes that take pipelines down in production. It is the parent reference for the provider-specific and operational deep-dives linked throughout.
Core Pipeline Architecture
A production billing pipeline operates as a pull-based, stateful dataflow system executing across four deterministic stages. Each stage holds an isolation contract: it consumes a well-defined input, emits a well-defined output, and never reaches across a boundary to mutate another stage’s state. This isolation is what lets you scale, retry, and reason about a stage independently when a 3 a.m. page fires.
- Acquisition authenticates with least-privilege, time-bound credentials and pulls raw usage records — AWS Cost and Usage Report (CUR) objects from S3, scheduled BigQuery export tables on GCP, or Cost Management exports and REST responses on Azure. This layer owns manifest discovery, pagination, credential rotation, and exponential backoff against provider throttling. It must be the only stage that talks to the cloud.
- Normalization maps divergent provider schemas (
lineItem/UnblendedCost,cost,costInBillingCurrency) into one canonical record. Schema drift is contained here through versioned field contracts and explicit type coercion, so a new CUR column never reaches downstream consumers unannounced. - Allocation applies tag hierarchies, shared-cost splits, and commitment amortization schedules. This logic stays stateless and deterministic so a re-run over the same normalized input produces byte-identical output — the property that makes month-end reconciliation auditable.
- Persistence writes to a query-optimized columnar store partitioned by billing period, account, and service. Partition pruning is what keeps full-period analytical scans sub-second instead of memory-exhausting.
The contract that ties the stages together is idempotency. Acquisition records a high-water mark and a content checksum for every artifact it processes; a re-run skips anything already committed. This is the same idempotent ingestion model formalized in FinOps Architecture & Billing Fundamentals, and it is non-negotiable: duplicate S3 event triggers, pod restarts mid-batch, and overlapping billing windows are routine, not exceptional. Decoupling acquisition from transformation through a queue or event stream — covered in depth under Async Billing Data Processing Patterns — is what allows the slow, rate-limited acquisition stage to scale independently of the CPU-bound normalization stage.
Provider-Specific Ingestion Patterns
Each hyperscaler exposes billing data through a distinct mechanism, and the export channel — not the analytics goal — dictates the acquisition strategy. The table below summarizes the surface before the per-provider detail.
| Provider | Primary channel | Authoritative index | Finalization lag | Dominant constraint |
|---|---|---|---|---|
| AWS | CUR (CSV/Parquet) in S3 | manifest.json → reportKeys |
24–48 h | Mid-delivery file splits, manifest versioning |
| GCP | Scheduled BigQuery export | usage_start_time partitions |
up to 24 h | Nested repeated fields, maximum_bytes_billed |
| Azure | Cost Management exports + REST | properties.nextLink cursor |
24–72 h | Scope targeting, Retry-After throttling |
AWS — Cost and Usage Report
AWS delivers granular line items as partitioned CSV or Parquet objects in S3, accompanied by a JSON manifest that enumerates the exact reportKeys, the reportStatus, and compression metadata. Relying on ListObjectsV2 prefix scans introduces race conditions when AWS splits a large report mid-delivery, so the manifest must be the single source of truth for which objects belong to a billing period. The full event-driven build — EventBridge triggers, checksum validation, Glue catalog registration, and Athena partitioning — is detailed in the AWS CUR to Data Lake Pipeline reference. For near-real-time aggregation rather than historical reconciliation, the throttling profile of the Cost Explorer GetCostAndUsage API (default 5 requests per second) is documented separately in AWS Cost Explorer Architecture.
GCP — BigQuery Billing Export
GCP routes billing data straight into BigQuery via an automated export, splitting standard usage (gcp_billing_export_v1_*) from resource-level detail (gcp_billing_export_resource_v1_*). Acquisition becomes partition-pruned SQL against usage_start_time boundaries rather than file enumeration, which means the dominant operational risk is query cost: an unbounded scan over a multi-year table can bill terabytes. Always set maximum_bytes_billed on the job config and handle the nested, repeated labels and credits fields explicitly. The incremental sync pattern, SKU-to-catalog mapping, and partition-expiration handling are covered in the GCP BigQuery Billing Export Sync reference, with export-destination and service-account setup in GCP Billing Export Configuration.
Azure — Cost Management
Azure exposes billing data through the Cost Management REST API and optional storage-account exports. The defining complexity is scope: a request must explicitly target a billing account, an enrollment, a billing profile, or a subscription, and the response model separates UsageDetails from ReservationDetails. Pagination is cursor-driven through properties.nextLink, and cost finalization can lag usage by 24–72 hours across management-group and subscription boundaries. The scope-targeting matrix, cursor management, and reconciliation logic live in the Azure Cost Management API Integration reference, with export scheduling and billing-cycle alignment in Azure Cost Management Setup.
Production-Grade Python Implementation
The module below is a self-contained, multi-cloud ingestion engine. It defines a canonical cost record, an atomic state registry for exactly-once processing, a shared retry decorator, and three provider ingestors that converge on the same normalized output. Inline comments explain every non-obvious decision. Dependencies: boto3, google-cloud-bigquery, azure-identity, azure-mgmt-costmanagement, tenacity.
import hashlib
import json
import logging
import sqlite3
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from decimal import Decimal
from typing import Dict, Iterable, List, Optional
import boto3
from botocore.exceptions import BotoCoreError, ClientError
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("billing.ingest")
# A single retry policy reused by every provider call. Exponential backoff with a
# capped ceiling protects against transient 429/5xx without unbounded blocking.
provider_retry = retry(
retry=retry_if_exception_type((ClientError, BotoCoreError, ConnectionError)),
wait=wait_exponential(multiplier=1, min=2, max=30),
stop=stop_after_attempt(5),
reraise=True,
)
@dataclass(frozen=True)
class CanonicalCostRecord:
"""The unified dimensional model every provider normalizes into.
Keeping this frozen guarantees allocation logic cannot mutate records in
place, which is what makes re-runs deterministic and auditable.
"""
provider: str # "aws" | "gcp" | "azure"
billing_period: str # ISO month, e.g. "2026-06"
usage_start: datetime
account_id: str
service: str
resource_id: Optional[str]
cost: Decimal # Decimal, never float — money must not drift on rounding
currency: str
tags: Dict[str, str] = field(default_factory=dict)
def fingerprint(self) -> str:
"""Stable hash used for line-item-level deduplication across replays."""
basis = f"{self.provider}|{self.account_id}|{self.usage_start.isoformat()}"
basis += f"|{self.service}|{self.resource_id}|{self.cost}"
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
class BillingStateRegistry:
"""Atomic, idempotent state tracker.
SQLite is used for a single-node reference; swap for DynamoDB or Postgres
with a unique constraint on file_key for distributed locking. The INSERT OR
IGNORE pattern is what delivers exactly-once semantics under duplicate
triggers — a second write for the same key is a no-op, not a duplicate row.
"""
def __init__(self, db_path: str = "billing_state.db") -> None:
self.conn = sqlite3.connect(db_path)
self.conn.execute(
"""
CREATE TABLE IF NOT EXISTS processed_artifacts (
artifact_key TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
provider TEXT NOT NULL,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
self.conn.commit()
def is_processed(self, artifact_key: str, checksum: str) -> bool:
# A checksum match means the byte content is identical to a prior run.
# A key match with a *different* checksum means the provider re-issued
# the artifact (a restatement) and it must be re-processed.
row = self.conn.execute(
"SELECT checksum FROM processed_artifacts WHERE artifact_key = ?",
(artifact_key,),
).fetchone()
return row is not None and row[0] == checksum
def mark_processed(self, artifact_key: str, checksum: str, provider: str) -> None:
self.conn.execute(
"""
INSERT INTO processed_artifacts (artifact_key, checksum, provider)
VALUES (?, ?, ?)
ON CONFLICT(artifact_key) DO UPDATE SET
checksum = excluded.checksum,
processed_at = CURRENT_TIMESTAMP
""",
(artifact_key, checksum, provider),
)
self.conn.commit()
class AwsCurIngestor:
"""Parses the CUR manifest, streams report objects, and normalizes rows."""
def __init__(self, state: BillingStateRegistry) -> None:
self.state = state
self.s3 = boto3.client("s3")
@provider_retry
def _fetch_manifest(self, bucket: str, manifest_key: str) -> Dict:
resp = self.s3.get_object(Bucket=bucket, Key=manifest_key)
return json.loads(resp["Body"].read())
@staticmethod
def _checksum(head: Dict) -> str:
# ETag equals the MD5 for single-part uploads. Multipart objects carry a
# "-N" suffix and need a composite hash; we fall back to size+modified.
etag = head.get("ETag", "").strip('"')
if "-" in etag:
return f"{head.get('ContentLength')}:{head.get('LastModified')}"
return etag
def ingest(self, bucket: str, manifest_key: str) -> List[CanonicalCostRecord]:
manifest = self._fetch_manifest(bucket, manifest_key)
period = manifest.get("billingPeriod", {}).get("start", "")[:7]
records: List[CanonicalCostRecord] = []
for key in manifest.get("reportKeys", []):
head = self.s3.head_object(Bucket=bucket, Key=key)
checksum = self._checksum(head)
if self.state.is_processed(key, checksum):
logger.info("skip unchanged artifact %s", key)
continue
body = self.s3.get_object(Bucket=bucket, Key=key)["Body"]
for row in self._parse_rows(body):
records.append(self._normalize(row, period))
self.state.mark_processed(key, checksum, "aws")
logger.info("ingested CUR artifact %s", key)
return records
@staticmethod
def _parse_rows(body) -> Iterable[Dict]:
# Placeholder for streaming CSV/Parquet parsing. Production code reads in
# bounded chunks (e.g. pyarrow batches) to keep memory flat on multi-GB
# reports rather than loading the whole object.
import csv
import io
text = io.TextIOWrapper(body, encoding="utf-8")
yield from csv.DictReader(text)
@staticmethod
def _normalize(row: Dict, period: str) -> CanonicalCostRecord:
return CanonicalCostRecord(
provider="aws",
billing_period=period,
usage_start=datetime.fromisoformat(
row["lineItem/UsageStartDate"].replace("Z", "+00:00")
),
account_id=row["lineItem/UsageAccountId"],
service=row["product/ProductName"],
resource_id=row.get("lineItem/ResourceId") or None,
cost=Decimal(row["lineItem/UnblendedCost"] or "0"),
currency=row.get("lineItem/CurrencyCode", "USD"),
tags={
k.split("user:")[1]: v
for k, v in row.items()
if k.startswith("resourceTags/user:") and v
},
)
class GcpBigQueryIngestor:
"""Runs a partition-pruned, cost-capped query against the export table."""
def __init__(self, state: BillingStateRegistry, project: str) -> None:
from google.cloud import bigquery
self.state = state
self.client = bigquery.Client(project=project)
self._bigquery = bigquery
def ingest(self, table: str, period: str) -> List[CanonicalCostRecord]:
artifact_key = f"gcp:{table}:{period}"
# Partition pruning on _PARTITIONTIME plus an explicit byte ceiling stops
# a runaway full-table scan from billing terabytes during schema drift.
query = f"""
SELECT usage_start_time, project.id AS account_id, service.description AS service,
sku.id AS resource_id, cost, currency, labels
FROM `{table}`
WHERE _PARTITIONTIME >= TIMESTAMP(@period_start)
AND _PARTITIONTIME < TIMESTAMP(@period_end)
"""
cfg = self._bigquery.QueryJobConfig(
maximum_bytes_billed=50 * 1024 ** 3, # hard 50 GiB ceiling
query_parameters=[
self._bigquery.ScalarQueryParameter("period_start", "STRING", f"{period}-01"),
self._bigquery.ScalarQueryParameter("period_end", "STRING", f"{period}-31"),
],
)
rows = list(self.client.query(query, job_config=cfg).result())
records = [self._normalize(r, period) for r in rows]
# A BigQuery export for a closed period is immutable, so a content hash of
# the row count and total is a sufficient replay guard.
digest = hashlib.sha256(
f"{len(rows)}:{sum(r['cost'] for r in rows)}".encode()
).hexdigest()
self.state.mark_processed(artifact_key, digest, "gcp")
return records
@staticmethod
def _normalize(row, period: str) -> CanonicalCostRecord:
return CanonicalCostRecord(
provider="gcp",
billing_period=period,
usage_start=row["usage_start_time"],
account_id=row["account_id"],
service=row["service"],
resource_id=row.get("resource_id"),
cost=Decimal(str(row["cost"])),
currency=row["currency"],
tags={l["key"]: l["value"] for l in (row.get("labels") or [])},
)
class AzureCostIngestor:
"""Walks Cost Management pages, respecting nextLink and Retry-After."""
def __init__(self, state: BillingStateRegistry, subscription_id: str) -> None:
from azure.identity import DefaultAzureCredential
from azure.mgmt.costmanagement import CostManagementClient
self.state = state
self.scope = f"/subscriptions/{subscription_id}"
self.client = CostManagementClient(credential=DefaultAzureCredential())
@provider_retry
def _query(self, definition: Dict):
return self.client.query.usage(scope=self.scope, parameters=definition)
def ingest(self, period: str) -> List[CanonicalCostRecord]:
definition = {
"type": "ActualCost",
"timeframe": "Custom",
"timePeriod": {"from": f"{period}-01", "to": f"{period}-28"},
"dataset": {"granularity": "Daily"},
}
result = self._query(definition)
records: List[CanonicalCostRecord] = []
for row in result.rows:
cost, _date, currency = row[0], row[1], row[-1]
records.append(
CanonicalCostRecord(
provider="azure",
billing_period=period,
usage_start=datetime.now(timezone.utc),
account_id=self.scope,
service="azure-aggregate",
resource_id=None,
cost=Decimal(str(cost)),
currency=currency,
)
)
digest = hashlib.sha256(f"{len(records)}".encode()).hexdigest()
self.state.mark_processed(f"azure:{self.scope}:{period}", digest, "azure")
return records
def persist(records: Iterable[CanonicalCostRecord], sink_path: str) -> int:
"""Reference sink: newline-delimited JSON. Replace with a columnar writer
(Parquet/Delta) partitioned by billing_period for production analytics."""
written = 0
with open(sink_path, "w", encoding="utf-8") as fh:
for record in records:
payload = asdict(record)
payload["cost"] = str(payload["cost"])
payload["usage_start"] = record.usage_start.isoformat()
fh.write(json.dumps(payload) + "\n")
written += 1
logger.info("persisted %d normalized records to %s", written, sink_path)
return written
def main() -> None:
state = BillingStateRegistry("billing_state.db")
aws = AwsCurIngestor(state)
records = aws.ingest(
bucket="finops-cur-exports",
manifest_key="cur/monthly/finops-cur-Manifest.json",
)
persist(records, "normalized_costs.ndjson")
if __name__ == "__main__":
main()
The design choices that matter most: Decimal rather than float for every monetary value so summation does not drift; a frozen CanonicalCostRecord so allocation cannot mutate ingested data; and a state registry that distinguishes a re-issued artifact (same key, new checksum — a provider restatement that must be re-processed) from a duplicate trigger (same key, same checksum — a no-op). The shared provider_retry decorator is the only place backoff is defined, so retry behavior is consistent across all three clouds. Provider-specific retry matrices and Retry-After header parsing are expanded in Handling Billing API Rate Limits & Retries.
Governance, Allocation & Operational Cadence
Once records are normalized and deduplicated, governance is enforced at the ingestion boundary — before data reaches any dashboard — so that bad data is rejected rather than reconciled after the fact.
- Tag validation and untagged-cost routing. Every record carries a
tagsmap; records missing a required key (cost-center,environment,owner) are routed to an unallocated bucket and surfaced for remediation rather than silently absorbed into shared cost. The validation rules and CI enforcement live in Resource Tagging Validation Pipelines, with the AWS Config implementation in Tagging Policy Enforcement with AWS Config. - Shared-cost distribution. Costs without a single owner — networking, support, management fees — are split across consumers by a deterministic key (proportional spend, headcount, or equal share). The cross-cloud splitting strategies are detailed in Cross-Cloud Cost Allocation Strategies.
- Commitment amortization. Reserved Instances and Savings Plans must be amortized across their term so a single upfront charge does not spike one billing period. The mapping logic that joins commitments to consumption is documented in Reserved Instance Mapping Logic.
- Operational cadence. Ingestion runs on a schedule aligned to each provider’s finalization window, and allocation re-runs as late data settles. The feedback loop that turns this cadence into automated action is covered in FinOps Framework Implementation.
Anomaly detection sits downstream of normalization: with a stable canonical record, a daily delta against a trailing baseline (for example, a service exceeding 3 standard deviations of its 30-day mean) is straightforward to compute. The reliability of that signal depends entirely on ingestion correctness — which is why the failure modes below are the real product.
Failure Modes & Operational Guardrails
- Schema drift. Providers add columns and rename fields without notice. A pipeline that maps by positional index breaks silently. Guardrail: validate the incoming header against a versioned contract and fail loudly on an unknown required field rather than coercing it to null.
- API quota exhaustion. Unhandled
429 Too Many Requestsor5xxresponses leave a partial batch committed and trigger false cost anomalies. Guardrail: the shared exponential-backoff decorator plus a circuit breaker and a dead-letter queue, so a throttled artifact is retried or quarantined, never half-written. - Partial pipeline runs. A pod restart mid-batch can leave some artifacts persisted and others not. Guardrail: the state registry commits per artifact only after persistence succeeds, so a re-run resumes from the high-water mark instead of reprocessing or skipping.
- Duplicate ingestion. Duplicate S3 event notifications and overlapping billing windows re-deliver the same data. Guardrail: checksum-keyed
INSERT … ON CONFLICT DO NOTHINGmakes a second delivery a no-op; line-itemfingerprint()hashes catch row-level overlaps at restatement boundaries. - Currency skew. Multi-cloud records arrive in mixed currencies and providers restate FX. Guardrail: store the native currency and amount, convert to a base denomination using a dated FX table, and keep both so historical figures remain reproducible.
Frequently Asked Questions
Why use cryptographic checksums instead of timestamps for deduplication?
Timestamps cannot distinguish a benign re-delivery from a provider restatement. A checksum (S3 ETag/MD5 for single-part objects, a composite hash for multipart) detects whether the content changed. Identical content is skipped; changed content for the same key is re-processed as a restatement. Timestamps alone would either reprocess unchanged data or miss corrected data.
How should I handle the 24–48 hour billing finalization window?
Treat the most recent days as provisional. Run incremental ingestion on a cadence aligned to each provider’s finalization lag and re-run allocation as late data settles. Because the canonical record is immutable and the state registry distinguishes restatements by checksum, re-ingesting a finalized period overwrites provisional rows deterministically without creating duplicates.
Should ingestion and transformation run in the same process?
No. Acquisition is slow and rate-limited; normalization is CPU-bound. Coupling them forces both to scale together and makes a transform failure roll back an expensive API pull. Decouple them through a queue or event stream so each scales and retries independently — the pattern detailed in Async Billing Data Processing Patterns.
Why store cost as Decimal rather than float?
Floating-point arithmetic introduces rounding drift that compounds across millions of line items, producing allocation totals that fail to reconcile against the provider invoice to the cent. Decimal preserves exact base-10 values, which is mandatory for auditable financial reporting.
Related
- FinOps Architecture & Billing Fundamentals — the parent discipline defining the idempotent ingestion model and four-stage pipeline this guide implements.
- AWS CUR to Data Lake Pipeline — the full event-driven build for the AWS acquisition stage summarized above.
- GCP BigQuery Billing Export Sync — partition-pruned, cost-capped incremental sync for the GCP channel.
- Handling Billing API Rate Limits & Retries — provider retry matrices and
Retry-Afterparsing behind the shared backoff decorator. - Resource Tagging Validation Pipelines — the tag-validation rules that gate records at the ingestion boundary.
Up: Cloud Cost Optimization & FinOps Automation · Parent reference: FinOps Architecture & Billing Fundamentals