From d93b3cc7f6aca600340862d414fc768e0cb9d10a Mon Sep 17 00:00:00 2001 From: sadiqabubakar826-del Date: Sun, 26 Jul 2026 04:26:30 +0000 Subject: [PATCH] feat(monitoring): wire AwsCostCollectorService into cost scheduler - Replace hardcoded placeholder (0) with real Cost Explorer data - CostSchedulerService now delegates to AwsCostCollectorService.collectHourlyCost() - On collector failure, skip metric update and increment cost_collection_failures_total counter - Label infrastructure_hourly_cost_usd gauge with billing_period from Cost Explorer response - Remove duplicate @Cron from AwsCostCollectorService (scheduler drives the cycle) - Add billing_period label to CostTrackingService.recordHourlyCost() - Add 8 tests covering success path, failure path, and no-placeholder hygiene --- .../cloud/aws-cost-collector.service.ts | 91 ++++++++----- src/monitoring/cost-scheduler.service.spec.ts | 126 ++++++++++++++++++ src/monitoring/cost-scheduler.service.ts | 55 ++++++-- src/monitoring/cost-tracking.service.ts | 52 +++++--- 4 files changed, 264 insertions(+), 60 deletions(-) create mode 100644 src/monitoring/cost-scheduler.service.spec.ts diff --git a/src/monitoring/cloud/aws-cost-collector.service.ts b/src/monitoring/cloud/aws-cost-collector.service.ts index be0f6e5f..7c8b058c 100644 --- a/src/monitoring/cloud/aws-cost-collector.service.ts +++ b/src/monitoring/cloud/aws-cost-collector.service.ts @@ -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 { + 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; + const total = row?.Total as Record | undefined; + const raw = (total?.UnblendedCost as Record | 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; } } } diff --git a/src/monitoring/cost-scheduler.service.spec.ts b/src/monitoring/cost-scheduler.service.spec.ts new file mode 100644 index 00000000..971b0611 --- /dev/null +++ b/src/monitoring/cost-scheduler.service.spec.ts @@ -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>; + 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-scheduler.service.ts b/src/monitoring/cost-scheduler.service.ts index f7667396..c5fe0952 100644 --- a/src/monitoring/cost-scheduler.service.ts +++ b/src/monitoring/cost-scheduler.service.ts @@ -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 { + 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})`, + ); } } diff --git a/src/monitoring/cost-tracking.service.ts b/src/monitoring/cost-tracking.service.ts index e730d4a1..76a5fc37 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,53 @@ 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 {