diff --git a/src/monitoring/cost-scheduler.service.spec.ts b/src/monitoring/cost-scheduler.service.spec.ts new file mode 100644 index 00000000..9491e1a3 --- /dev/null +++ b/src/monitoring/cost-scheduler.service.spec.ts @@ -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>; + let costService: jest.Mocked>; + 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(); + }); + }); +}); diff --git a/src/monitoring/cost-tracking.service.ts b/src/monitoring/cost-tracking.service.ts index e730d4a1..9f9a989a 100644 --- a/src/monitoring/cost-tracking.service.ts +++ b/src/monitoring/cost-tracking.service.ts @@ -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() @@ -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 { - // 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 { + // 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 {