Billing & Metrics Ingestion**: Cloud provider APIs (AWS Cost Explorer, GCP Billing Export, Azure Cost Management) push daily/hourly cost data. CloudWatch/Prometheus metrics capture resource utilization alongside billing events.
3. Normalization & Aggregation: A TypeScript processor ingests raw billing JSON, applies tag resolution, calculates cost deltas against rolling baselines, and enriches records with deployment metadata.
4. Storage & Query: Time-series or columnar storage (PostgreSQL + TimescaleDB, ClickHouse, or DynamoDB) stores normalized cost records. Materialized views enable team-level attribution and service-level cost-per-request calculations.
5. Alerting & Visualization: Webhook integrations push anomalies to Slack/PagerDuty. Grafana/Looker dashboards display real-time spend, forecasted run-rates, and RI/SP utilization.
Architecture Decisions & Rationale
- Event-Driven Over Polling: Billing APIs have strict rate limits and high latency. Using cloud-native event streams (SNS/SQS, Pub/Sub, EventBridge) ensures ingestion scales with deployment frequency without throttling.
- TypeScript for Cost Normalization: Cloud billing schemas vary significantly across providers. TypeScript’s strict typing prevents schema drift, enables compile-time validation of cost models, and integrates cleanly with Node.js serverless runtimes.
- Tag-First Attribution: Cost allocation must happen at provisioning, not reconciliation. Enforcing tags via policy-as-Code guarantees every resource carries attribution metadata, eliminating post-hoc guesswork.
- Delta-Based Alerting: Absolute budget thresholds generate noise. Calculating cost deltas against a 7-day rolling average isolates genuine anomalies from predictable scaling events.
TypeScript Implementation
import { CostExplorerClient, GetCostAndUsageCommand, Granularity } from "@aws-sdk/client-cost-explorer";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { z } from "zod";
const CostRecordSchema = z.object({
service: z.string(),
team: z.string(),
environment: z.string(),
cost: z.number(),
currency: z.string(),
timestamp: z.string(),
deltaFromBaseline: z.number(),
isAnomaly: z.boolean()
});
type CostRecord = z.infer<typeof CostRecordSchema>;
export class InfrastructureCostTracker {
private costExplorer: CostExplorerClient;
private sqs: SQSClient;
private baselineMap: Map<string, number> = new Map();
constructor(region: string, queueUrl: string) {
this.costExplorer = new CostExplorerClient({ region });
this.sqs = new SQSClient({ region });
}
async fetchCurrentPeriodCosts(days: number = 1): Promise<CostRecord[]> {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - days);
const command = new GetCostAndUsageCommand({
TimePeriod: { Start: startDate.toISOString().split("T")[0], End: endDate.toISOString().split("T")[0] },
Granularity: Granularity.DAILY,
Metrics: ["BlendedCost"],
GroupBy: [
{ Type: "DIMENSION", Key: "SERVICE" },
{ Type: "TAG", Key: "team" },
{ Type: "TAG", Key: "environment" }
]
});
const response = await this.costExplorer.send(command);
const results: CostRecord[] = [];
for (const group of response.ResultsByTime?.[0]?.Groups || []) {
const service = group.Keys?.[0] || "unknown";
const team = group.Keys?.[1] || "unassigned";
const environment = group.Keys?.[2] || "unassigned";
const cost = parseFloat(group.Metrics?.BlendedCost?.Amount || "0");
const key = `${team}:${environment}:${service}`;
const baseline = this.baselineMap.get(key) || cost;
const delta = cost - baseline;
const isAnomaly = Math.abs(delta / baseline) > 0.25; // 25% threshold
this.baselineMap.set(key, baseline * 0.7 + cost * 0.3); // Exponential smoothing
results.push({
service,
team,
environment,
cost,
currency: "USD",
timestamp: new Date().toISOString(),
deltaFromBaseline: delta,
isAnomaly
});
}
return results;
}
async publishToQueue(records: CostRecord[]): Promise<void> {
const payload = JSON.stringify(records);
const command = new SendMessageCommand({
QueueUrl: process.env.COST_QUEUE_URL || "",
MessageBody: payload,
MessageGroupId: "cost-tracking"
});
await this.sqs.send(command);
}
async runPipeline(): Promise<void> {
const costs = await this.fetchCurrentPeriodCosts();
const anomalies = costs.filter(r => r.isAnomaly);
if (anomalies.length > 0) {
await this.publishToQueue(anomalies);
console.log(`[CostTracker] ${anomalies.length} anomalies published to queue`);
}
}
}
The processor uses exponential smoothing to maintain a dynamic baseline per team/service/environment combination. The 25% delta threshold isolates genuine cost spikes from predictable scaling. Records are validated against a Zod schema before queue publication, ensuring downstream consumers never encounter malformed billing data.
Pitfall Guide
-
Relying Solely on Provider Billing Dashboards
Provider consoles aggregate data at the account level and lack engineering context. They cannot correlate cost with deployment events, making root-cause analysis impossible. Always ingest raw billing exports into your own data layer.
-
Inconsistent or Missing Cost-Allocation Tags
Without mandatory tags, cost attribution collapses into guesswork. Teams will default to unassigned buckets, masking waste. Enforce tags via policy-as-Code and block provisioning when required keys are absent.
-
Ignoring Reserved Instance & Savings Plan Utilization
Purchasing RIs/SPs without tracking utilization creates phantom savings. Underutilized commitments still incur charges. Track coverage ratios and alert when utilization drops below 80%.
-
Overlooking Data Egress & Cross-Region Transfer Costs
Compute costs are visible; network costs are not. Misconfigured replication, backup jobs, or CDN misrouting can spike egress fees silently. Monitor DataTransfer-Out-Bytes metrics alongside compute spend.
-
Static Budget Thresholds Instead of Dynamic Baselines
Fixed alerts generate fatigue. A 10% increase during peak season is normal; a 10% increase during idle hours is not. Use rolling averages or exponential smoothing to establish context-aware thresholds.
-
Treating Cost Tracking as a Post-Deployment Task
Optimization delayed is optimization lost. Integrate cost checks into CI/CD pipelines. Fail deployments that exceed projected cost deltas or violate tag policies before resources provision.
-
Not Correlating Cost with Business Metrics
Tracking raw spend without context leads to blanket cuts. Map cost to business units: cost per request, cost per active user, cost per transaction. This shifts the conversation from "reduce spend" to "optimize efficiency."
Best Practices from Production:
- Implement tag compliance gates in Terraform/Pulumi using
required_tags validation.
- Schedule cost pipeline runs at 15-minute intervals during active deployment windows, hourly during stable periods.
- Store raw billing exports in cold storage for audit reconciliation; keep normalized aggregates in hot storage for querying.
- Rotate API credentials via IAM roles; never embed access keys in pipeline configurations.
- Run monthly RI/SP coverage audits and automate expiration alerts 30 days prior.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup (<$50k/mo) | Tag-based monthly aggregation + provider console | Low engineering overhead; sufficient for early-stage visibility | 15-20% waste reduction |
| Mid-Market ($50k-$500k/mo) | Event-driven pipeline + PostgreSQL + dynamic baselines | Scales with deployment frequency; enables team-level attribution | 35-45% waste reduction |
| Enterprise Multi-Cloud | Unified cost normalizer + ClickHouse + policy-as-Code enforcement | Cross-provider schema alignment; centralized governance | 45-60% waste reduction |
| Compliance-Heavy (SOC2/ISO) | Immutable billing exports + audit trails + automated tag validation | Meets audit requirements; prevents untagged resource drift | 25-30% waste reduction + compliance assurance |
Configuration Template
// cost-tracker.config.ts
import { z } from "zod";
export const CostTrackerConfigSchema = z.object({
awsRegion: z.string().default("us-east-1"),
costQueueUrl: z.string().url(),
baselineSmoothingFactor: z.number().min(0.1).max(0.9).default(0.3),
anomalyThresholdPercent: z.number().min(10).max(50).default(25),
requiredTags: z.array(z.string()).min(3).default(["team", "environment", "service"]),
ingestionIntervalMinutes: z.number().min(5).max(60).default(15),
alertWebhookUrl: z.string().url().optional(),
storageProvider: z.enum(["postgresql", "clickhouse", "dynamodb"]).default("postgresql")
});
export type CostTrackerConfig = z.infer<typeof CostTrackerConfigSchema>;
export const defaultConfig: CostTrackerConfig = {
awsRegion: process.env.AWS_REGION || "us-east-1",
costQueueUrl: process.env.COST_QUEUE_URL || "",
baselineSmoothingFactor: 0.3,
anomalyThresholdPercent: 25,
requiredTags: ["team", "environment", "service"],
ingestionIntervalMinutes: 15,
alertWebhookUrl: process.env.SLACK_WEBHOOK_URL,
storageProvider: "postgresql"
};
Quick Start Guide
- Deploy Policy Enforcement: Add a Conftest/OPA policy to your CI pipeline that validates
team, environment, and service tags on all Terraform/Pulumi plans. Fail builds missing required keys.
- Provision Ingestion Queue: Create an SQS/SNS topic or cloud-native event stream. Attach an IAM role with
costexplorer:GetCostAndUsage and sqs:SendMessage permissions.
- Run Cost Normalizer: Deploy the TypeScript processor as a Lambda or containerized service. Configure environment variables for queue URL, region, and threshold parameters. Trigger via EventBridge schedule (every 15 minutes).
- Validate & Alert: Verify normalized records in your storage layer. Configure a webhook consumer to parse anomaly payloads and route to Slack/PagerDuty. Confirm delta calculations align with deployment events.
- Monitor & Tune: Review baseline smoothing accuracy after 7 days. Adjust threshold percentages based on deployment cadence. Schedule monthly RI/SP coverage checks.