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.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})`, + ); } }