Skip to content
Merged
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
129 changes: 129 additions & 0 deletions src/monitoring/cost-scheduler.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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,
);
});

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();
});
});
});
58 changes: 39 additions & 19 deletions src/monitoring/cost-tracking.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { Gauge } from 'prom-client';
import { MetricsCollectionService } from './metrics/metrics-collection.service';

/**
* CostTrackingService
* - Records estimated cost metrics (e.g., AWS spend) into Prometheus metrics via MetricsCollectionService.
* - Records real cloud-provider billing data into Prometheus metrics via MetricsCollectionService.
* - Provides a simple in-memory rolling window and ability to evaluate budgets.
*/
@Injectable()
Expand All @@ -12,40 +13,59 @@ export class CostTrackingService {
private windowHours = 24;
private hourlyCosts: number[] = [];

/** Lazily created gauge so we can add the billing_period label on first use. */
private hourlyCostGauge: Gauge | null = null;

constructor(private readonly metrics: MetricsCollectionService) {}

async recordHourlyCost(amountUsd: number): Promise<void> {
// maintain a rolling window of last `windowHours` hourly costs
/**
* Records the hourly cost figure into the rolling window and updates the
* Prometheus gauge `infrastructure_hourly_cost_usd`.
*
* @param amountUsd Cost in USD for the billing period.
* @param billingPeriod ISO-8601 date range the amount covers, e.g.
* "2026-07-25/2026-07-26". Omit when the billing
* period is unknown.
*/
async recordHourlyCost(amountUsd: number, billingPeriod?: string): Promise<void> {
// Maintain a rolling window of the last `windowHours` hourly costs.
this.hourlyCosts.push(amountUsd);
if (this.hourlyCosts.length > this.windowHours) {
this.hourlyCosts.shift();
}

// Expose as a Gauge on the metrics registry using a generic name
try {
// Create or update a simple gauge on the registry
const gaugeName = 'infrastructure_hourly_cost_usd';
// Use prom-client directly to set the gauge if not present
const gauge = this.getOrCreateGauge();
const labels = billingPeriod
? {
billing_period: billingPeriod,
}
: {
billing_period: 'unknown',
};
gauge.set(labels, amountUsd);
} catch (err) {
this.logger.error('Failed to record cost metric', err as Error);
}
}

private getOrCreateGauge(): Gauge {
if (!this.hourlyCostGauge) {
const registry = this.metrics.getRegistry();
const gaugeName = 'infrastructure_hourly_cost_usd';
const existing = registry.getSingleMetric(gaugeName);
const latest = amountUsd;
if (existing) {
// @ts-expect-error - prom-client Metric has set method but types are incomplete
existing.set(latest);
this.hourlyCostGauge = existing as Gauge;
} else {
// Create a new gauge
// Lazy import to avoid import ordering issues
const prom = await import('prom-client');
const Gauge = prom.Gauge;
new Gauge({
this.hourlyCostGauge = new Gauge({
name: gaugeName,
help: 'Hourly infrastructure cost in USD',
help: 'Hourly infrastructure cost in USD, labelled with the billing period it describes',
labelNames: ['billing_period'],
registers: [registry],
}).set(latest);
});
}
} catch (err) {
this.logger.error('Failed to record cost metric', err as Error);
}
return this.hourlyCostGauge;
}

getLast24hCost(): number {
Expand Down
Loading