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
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;
}
}
}
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