Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 60 additions & 31 deletions src/monitoring/cloud/aws-cost-collector.service.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,100 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { CostTrackingService } from '../cost-tracking.service';

/**
* AWS Cost Explorer collector
* - Requires @aws-sdk/client-cost-explorer and credentials with Cost Explorer read access.
* - If the SDK or credentials aren't available, the service logs and no-ops.
* The result returned by a successful Cost Explorer fetch.
* `billingPeriod` is the ISO-8601 date range that the amount covers,
* formatted as "YYYY-MM-DD/YYYY-MM-DD" (start inclusive, end exclusive),
* matching the TimePeriod convention used by Cost Explorer.
*/
export interface HourlyCostResult {
/** Sum of UnblendedCost in USD for the queried period. */
amount: number;
/** ISO-8601 billing period: "YYYY-MM-DD/YYYY-MM-DD". */
billingPeriod: string;
}

/**
* AWS Cost Explorer collector.
*
* Fetches the previous hour's UnblendedCost from AWS Cost Explorer and returns
* a {@link HourlyCostResult}. The caller is responsible for recording the
* metric; this service only fetches data.
*
* Requirements:
* - `@aws-sdk/client-cost-explorer` installed.
* - AWS credentials with `ce:GetCostAndUsage` permission.
* - `AWS_REGION` env var (defaults to `us-east-1`).
*
* If the SDK is unavailable or credentials are not configured, the service
* marks itself disabled and `collectHourlyCost()` returns `null`.
*/
@Injectable()
export class AwsCostCollectorService implements OnModuleInit {
private readonly logger = new Logger(AwsCostCollectorService.name);
private enabled = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private client: any;

constructor(private readonly costService: CostTrackingService) {}

async onModuleInit() {
// Try to lazily load the AWS Cost Explorer client
try {
const { CostExplorerClient } = await import('@aws-sdk/client-cost-explorer');
const region = process.env.AWS_REGION || 'us-east-1';
const region = process.env.AWS_REGION ?? 'us-east-1';
this.client = new CostExplorerClient({ region });
this.enabled = true;
} catch (_err) {
this.logger.warn('AWS Cost Explorer client not available — AWS cost collection disabled');
this.enabled = false;
}
}

@Cron(CronExpression.EVERY_HOUR)
async collectHourlyCost() {
if (!this.enabled) return;
/**
* Fetches the previous hour's cost from AWS Cost Explorer.
*
* Returns a {@link HourlyCostResult} on success, or `null` when the
* collector is disabled or the API call fails. The caller should treat
* `null` as a transient failure and **not** overwrite the last known metric.
*/
async collectHourlyCost(): Promise<HourlyCostResult | null> {
if (!this.enabled) {
this.logger.debug('Cost collection skipped — collector is disabled');
return null;
}

try {
const now = new Date();
const end = new Date(now.getTime());
const start = new Date(now.getTime() - 1000 * 60 * 60); // last hour
// Cost Explorer date strings are YYYY-MM-DD; end is exclusive so we use
// today's date and start is yesterday to capture the last 24-hour window.
// For hourly granularity the API returns the window that covers "now - 1 h".
const end = now.toISOString().slice(0, 10);
const startDate = new Date(now.getTime() - 1000 * 60 * 60);
const start = startDate.toISOString().slice(0, 10);

const { GetCostAndUsageCommand, Granularity } = await import('@aws-sdk/client-cost-explorer');

const params = {
TimePeriod: {
Start: start.toISOString().slice(0, 10),
End: end.toISOString().slice(0, 10),
},
const cmd = new GetCostAndUsageCommand({
TimePeriod: { Start: start, End: end },
Granularity: Granularity.HOURLY,
Metrics: ['UnblendedCost'],
};
});

const cmd = new GetCostAndUsageCommand(params);
const resp = await this.client.send(cmd);

// Parse response: sum hourly amounts for the last hour (if available)
// The response structure includes ResultsByTime[] with Metrics.UnblendedCost.Amount
// Sum all returned hourly buckets (typically one when start === end).
let amount = 0;
const results = resp.ResultsByTime || [];
const results: unknown[] = resp.ResultsByTime ?? [];
for (const r of results) {
const m = r?.Total?.UnblendedCost?.Amount;
const v = parseFloat(m || '0');
const row = r as Record<string, unknown>;
const total = row?.Total as Record<string, unknown> | undefined;
const raw = (total?.UnblendedCost as Record<string, unknown> | undefined)?.Amount;
const v = parseFloat((raw as string) || '0');
if (!Number.isNaN(v)) amount += v;
}

// If AWS returns zero, still record the metric so dashboards populate
this.costService.recordHourlyCost(amount);
this.logger.log(`Recorded AWS hourly cost: $${amount.toFixed(4)}`);
const billingPeriod = `${start}/${end}`;
this.logger.debug(`Fetched AWS hourly cost: $${amount.toFixed(4)} for ${billingPeriod}`);
return { amount, billingPeriod };
} catch (err) {
this.logger.error('Error collecting AWS cost', err as Error);
this.logger.error('Error fetching AWS cost from Cost Explorer', err as Error);
return null;
}
}
}
126 changes: 126 additions & 0 deletions src/monitoring/cost-scheduler.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Registry } from 'prom-client';
import { AwsCostCollectorService, HourlyCostResult } from './cloud/aws-cost-collector.service';
import { CostSchedulerService } from './cost-scheduler.service';
import { CostTrackingService } from './cost-tracking.service';
import { MetricsCollectionService } from './metrics/metrics-collection.service';

/**
* Minimal MetricsCollectionService stub that provides an isolated Registry so
* prom-client does not pollute the global default registry between tests.
*/
function makeMetricsStub(): MetricsCollectionService {
const registry = new Registry();
return {
getRegistry: () => registry,
} as unknown as MetricsCollectionService;
}

describe('CostSchedulerService', () => {
let scheduler: CostSchedulerService;
let collector: jest.Mocked<Pick<AwsCostCollectorService, 'collectHourlyCost'>>;
let costService: jest.Mocked<Pick<CostTrackingService, 'recordHourlyCost'>>;
let metricsStub: MetricsCollectionService;

beforeEach(() => {
metricsStub = makeMetricsStub();

collector = {
collectHourlyCost: jest.fn(),
};

costService = {
recordHourlyCost: jest.fn().mockResolvedValue(undefined),
};

scheduler = new CostSchedulerService(
collector as unknown as AwsCostCollectorService,
costService as unknown as CostTrackingService,
metricsStub,
);
});

// ── Success path ───────────────────────────────────────────────────────────

describe('when the collector returns a result', () => {
const result: HourlyCostResult = { amount: 3.14, billingPeriod: '2026-07-25/2026-07-26' };

beforeEach(() => {
collector.collectHourlyCost.mockResolvedValue(result);
});

it('delegates to AwsCostCollectorService to fetch the real cost', async () => {
await scheduler.recordHourlyCost();
expect(collector.collectHourlyCost).toHaveBeenCalledTimes(1);
});

it('records the amount returned by the collector', async () => {
await scheduler.recordHourlyCost();
expect(costService.recordHourlyCost).toHaveBeenCalledWith(result.amount, result.billingPeriod);

Check failure on line 58 in src/monitoring/cost-scheduler.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `result.amount,·result.billingPeriod` with `⏎········result.amount,⏎········result.billingPeriod,⏎······`
});

it('labels the metric with the billing period the amount covers', async () => {
await scheduler.recordHourlyCost();
expect(costService.recordHourlyCost).toHaveBeenCalledWith(
expect.any(Number),
'2026-07-25/2026-07-26',
);
});

it('does not increment the failure counter on success', async () => {
await scheduler.recordHourlyCost();

const registry = metricsStub.getRegistry();
const counter = registry.getSingleMetric('cost_collection_failures_total');
expect(counter).toBeDefined();

// Counter should remain at zero after a successful collection.
const metrics = await registry.metrics();
expect(metrics).toMatch(/cost_collection_failures_total 0/);
});
});

// ── Failure path ───────────────────────────────────────────────────────────

describe('when the collector returns null (failure)', () => {
beforeEach(() => {
collector.collectHourlyCost.mockResolvedValue(null);
});

it('does not call recordHourlyCost — leaves the previous metric value intact', async () => {
await scheduler.recordHourlyCost();
expect(costService.recordHourlyCost).not.toHaveBeenCalled();
});

it('increments the cost_collection_failures_total counter', async () => {
await scheduler.recordHourlyCost();

const registry = metricsStub.getRegistry();
const metrics = await registry.metrics();
expect(metrics).toMatch(/cost_collection_failures_total 1/);
});

it('increments the failure counter once per failed cycle', async () => {
await scheduler.recordHourlyCost();
await scheduler.recordHourlyCost();

const registry = metricsStub.getRegistry();
const metrics = await registry.metrics();
expect(metrics).toMatch(/cost_collection_failures_total 2/);
});
});

// ── No placeholder / no TODO ───────────────────────────────────────────────

describe('implementation hygiene', () => {
it('never publishes a hard-coded placeholder amount', async () => {
// Confirm the scheduler never calls recordHourlyCost with the old
// placeholder value of 0 when the collector is not invoked.
collector.collectHourlyCost.mockResolvedValue(null);

await scheduler.recordHourlyCost();

expect(costService.recordHourlyCost).not.toHaveBeenCalledWith(0, expect.anything());
expect(costService.recordHourlyCost).not.toHaveBeenCalled();
});
});
});
55 changes: 45 additions & 10 deletions src/monitoring/cost-scheduler.service.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Counter } from 'prom-client';
import { AwsCostCollectorService } from './cloud/aws-cost-collector.service';
import { CostTrackingService } from './cost-tracking.service';
import { MetricsCollectionService } from './metrics/metrics-collection.service';

/**
* CostSchedulerService
*
* Drives the hourly cost collection cycle:
* 1. Delegates the AWS Cost Explorer fetch to {@link AwsCostCollectorService}.
* 2. On success, records the real amount and billing period via
* {@link CostTrackingService}.
* 3. On failure (collector returns `null`), leaves the previous metric value
* unchanged and increments the `cost_collection_failures_total` counter so
* the outage is visible in dashboards and alerts.
*/
@Injectable()
export class CostSchedulerService {
private readonly logger = new Logger(CostSchedulerService.name);

constructor(private readonly costService: CostTrackingService) {}
/** Counts how many hourly collection cycles have failed since startup. */
private readonly collectionFailures: Counter;

constructor(
private readonly costCollector: AwsCostCollectorService,
private readonly costService: CostTrackingService,
metricsService: MetricsCollectionService,
) {
this.collectionFailures = new Counter({
name: 'cost_collection_failures_total',
help: 'Total number of hourly cost collection cycles that failed to retrieve data from the cloud provider',
registers: [metricsService.getRegistry()],
});
}

// Every hour record a placeholder cost (0) — replace with real cloud billing pull
@Cron(CronExpression.EVERY_HOUR)
async recordHourlyCost() {
try {
// TODO: Replace with real billing amount pulled from cloud provider API
const estimatedHourlyCostUsd = 0;
this.costService.recordHourlyCost(estimatedHourlyCostUsd);
this.logger.debug(`Recorded hourly cost: $${estimatedHourlyCostUsd}`);
} catch (err) {
this.logger.error('Failed to record hourly cost', err as Error);
async recordHourlyCost(): Promise<void> {
const result = await this.costCollector.collectHourlyCost();

if (result === null) {
// The collector encountered an error or is disabled — do not publish a
// fabricated value. Increment the failure counter so monitoring rules
// can alert when collections are consistently missing.
this.collectionFailures.inc();
this.logger.warn(
'Hourly cost collection failed — metric not updated; previous value retained',
);
return;
}

await this.costService.recordHourlyCost(result.amount, result.billingPeriod);
this.logger.log(
`Recorded hourly cost: $${result.amount.toFixed(4)} (billing period: ${result.billingPeriod})`,
);
}
}
Loading
Loading