import { BillingRecord } from './billing-client.js';
import { Logger } from '../utils/logger.js';
export interface CostAnalysisResult {
totalCost: number;
currency: string;
period: {
start: Date;
end: Date;
};
breakdown: {
service: string;
cost: number;
percentage: number;
}[];
trends: {
direction: 'increasing' | 'decreasing' | 'stable';
changePercent: number;
confidence: number;
};
}
export interface UsageComparisonResult {
currentPeriod: {
totalCost: number;
startDate: Date;
endDate: Date;
};
previousPeriod: {
totalCost: number;
startDate: Date;
endDate: Date;
};
comparison: {
absoluteChange: number;
percentageChange: number;
trend: 'increasing' | 'decreasing' | 'stable';
};
serviceBreakdown: {
service: string;
currentCost: number;
previousCost: number;
change: number;
changePercent: number;
}[];
}
export interface TrendAnalysisResult {
service: string;
timeSeriesData: {
date: Date;
cost: number;
}[];
trend: {
direction: 'increasing' | 'decreasing' | 'stable';
slope: number;
confidence: number;
};
seasonality: {
detected: boolean;
pattern?: 'weekly' | 'monthly' | 'quarterly';
strength?: number;
};
}
export interface AnomalyDetectionResult {
anomalies: {
date: Date;
service: string;
actualCost: number;
expectedCost: number;
deviation: number;
severity: 'low' | 'medium' | 'high';
}[];
baseline: {
mean: number;
standardDeviation: number;
threshold: number;
};
}
export interface QueryFilters {
accountId?: string;
services?: string[];
regions?: string[];
startDate?: Date;
endDate?: Date;
minCost?: number;
maxCost?: number;
tags?: Record<string, string>;
}
export interface CostRankingResult {
topServices: {
service: string;
totalCost: number;
percentage: number;
rank: number;
}[];
topRegions: {
region: string;
totalCost: number;
percentage: number;
rank: number;
}[];
costDrivers: {
type: 'service' | 'region' | 'usage_type';
name: string;
impact: number;
trend: 'increasing' | 'decreasing' | 'stable';
}[];
}
export class BillingAnalyzer {
private static instance: BillingAnalyzer;
private logger: Logger;
private constructor() {
this.logger = Logger.getInstance();
}
public static getInstance(): BillingAnalyzer {
if (!BillingAnalyzer.instance) {
BillingAnalyzer.instance = new BillingAnalyzer();
}
return BillingAnalyzer.instance;
}
public filterBillingRecords(records: BillingRecord[], filters: QueryFilters): BillingRecord[] {
let filteredRecords = [...records];
if (filters.accountId) {
filteredRecords = filteredRecords.filter(record => record.accountId === filters.accountId);
}
if (filters.services && filters.services.length > 0) {
filteredRecords = filteredRecords.filter(record =>
filters.services!.includes(record.service)
);
}
if (filters.regions && filters.regions.length > 0) {
filteredRecords = filteredRecords.filter(record =>
filters.regions!.includes(record.region)
);
}
if (filters.startDate) {
filteredRecords = filteredRecords.filter(record =>
record.startDate >= filters.startDate!
);
}
if (filters.endDate) {
filteredRecords = filteredRecords.filter(record =>
record.endDate <= filters.endDate!
);
}
if (filters.minCost !== undefined) {
filteredRecords = filteredRecords.filter(record =>
record.cost >= filters.minCost!
);
}
if (filters.maxCost !== undefined) {
filteredRecords = filteredRecords.filter(record =>
record.cost <= filters.maxCost!
);
}
if (filters.tags && Object.keys(filters.tags).length > 0) {
filteredRecords = filteredRecords.filter(record => {
return Object.entries(filters.tags!).every(([key, value]) =>
record.tags[key] === value
);
});
}
this.logger.info('Billing records filtered', {
originalCount: records.length,
filteredCount: filteredRecords.length,
filters
});
return filteredRecords;
}
public analyzeCosts(records: BillingRecord[]): CostAnalysisResult {
if (records.length === 0) {
throw new Error('No billing records provided for analysis');
}
const totalCost = records.reduce((sum, record) => sum + record.cost, 0);
const currency = records[0].currency;
// Calculate service breakdown
const serviceMap = new Map<string, number>();
records.forEach(record => {
const current = serviceMap.get(record.service) || 0;
serviceMap.set(record.service, current + record.cost);
});
const breakdown = Array.from(serviceMap.entries())
.map(([service, cost]) => ({
service,
cost,
percentage: (cost / totalCost) * 100
}))
.sort((a, b) => b.cost - a.cost);
// Calculate trends (simplified linear regression)
const trends = this.calculateTrends(records);
// Determine period
const dates = records.map(r => r.startDate).sort((a, b) => a.getTime() - b.getTime());
const period = {
start: dates[0],
end: dates[dates.length - 1]
};
const result: CostAnalysisResult = {
totalCost,
currency,
period,
breakdown,
trends
};
this.logger.info('Cost analysis completed', {
totalCost,
serviceCount: breakdown.length,
recordCount: records.length
});
return result;
}
public compareUsage(
currentRecords: BillingRecord[],
previousRecords: BillingRecord[]
): UsageComparisonResult {
const currentAnalysis = this.analyzeCosts(currentRecords);
const previousAnalysis = this.analyzeCosts(previousRecords);
const absoluteChange = currentAnalysis.totalCost - previousAnalysis.totalCost;
const percentageChange = previousAnalysis.totalCost > 0
? (absoluteChange / previousAnalysis.totalCost) * 100
: 0;
let trend: 'increasing' | 'decreasing' | 'stable' = 'stable';
if (Math.abs(percentageChange) > 5) {
trend = percentageChange > 0 ? 'increasing' : 'decreasing';
}
// Service-level comparison
const currentServiceMap = new Map(
currentAnalysis.breakdown.map(item => [item.service, item.cost])
);
const previousServiceMap = new Map(
previousAnalysis.breakdown.map(item => [item.service, item.cost])
);
const allServices = new Set([
...currentServiceMap.keys(),
...previousServiceMap.keys()
]);
const serviceBreakdown = Array.from(allServices).map(service => {
const currentCost = currentServiceMap.get(service) || 0;
const previousCost = previousServiceMap.get(service) || 0;
const change = currentCost - previousCost;
const changePercent = previousCost > 0 ? (change / previousCost) * 100 : 0;
return {
service,
currentCost,
previousCost,
change,
changePercent
};
}).sort((a, b) => Math.abs(b.change) - Math.abs(a.change));
const result: UsageComparisonResult = {
currentPeriod: {
totalCost: currentAnalysis.totalCost,
startDate: currentAnalysis.period.start,
endDate: currentAnalysis.period.end
},
previousPeriod: {
totalCost: previousAnalysis.totalCost,
startDate: previousAnalysis.period.start,
endDate: previousAnalysis.period.end
},
comparison: {
absoluteChange,
percentageChange,
trend
},
serviceBreakdown
};
this.logger.info('Usage comparison completed', {
currentTotal: currentAnalysis.totalCost,
previousTotal: previousAnalysis.totalCost,
percentageChange,
trend
});
return result;
}
public analyzeTrends(records: BillingRecord[], service?: string): TrendAnalysisResult[] {
const serviceRecords = service
? records.filter(r => r.service === service)
: records;
const serviceGroups = this.groupByService(serviceRecords);
const results: TrendAnalysisResult[] = [];
for (const [serviceName, serviceData] of serviceGroups.entries()) {
const timeSeriesData = this.aggregateByDate(serviceData);
const trend = this.calculateLinearTrend(timeSeriesData);
const seasonality = this.detectSeasonality(timeSeriesData);
results.push({
service: serviceName,
timeSeriesData,
trend,
seasonality
});
}
this.logger.info('Trend analysis completed', {
serviceCount: results.length,
recordCount: records.length
});
return results;
}
public detectAnomalies(
records: BillingRecord[],
thresholdMultiplier: number = 2.0
): AnomalyDetectionResult {
const timeSeriesData = this.aggregateByDate(records);
if (timeSeriesData.length < 7) {
throw new Error('Insufficient data for anomaly detection (minimum 7 data points required)');
}
// Calculate baseline statistics
const costs = timeSeriesData.map(d => d.cost);
const mean = costs.reduce((sum, cost) => sum + cost, 0) / costs.length;
const variance = costs.reduce((sum, cost) => sum + Math.pow(cost - mean, 2), 0) / costs.length;
const standardDeviation = Math.sqrt(variance);
const threshold = standardDeviation * thresholdMultiplier;
// Detect anomalies
const anomalies = timeSeriesData
.map(dataPoint => {
const deviation = Math.abs(dataPoint.cost - mean);
if (deviation > threshold) {
let severity: 'low' | 'medium' | 'high' = 'low';
if (deviation > threshold * 2) {
severity = 'high';
} else if (deviation > threshold * 1.5) {
severity = 'medium';
}
return {
date: dataPoint.date,
service: 'All Services', // Aggregated view
actualCost: dataPoint.cost,
expectedCost: mean,
deviation,
severity
};
}
return null;
})
.filter(anomaly => anomaly !== null) as AnomalyDetectionResult['anomalies'];
const result: AnomalyDetectionResult = {
anomalies,
baseline: {
mean,
standardDeviation,
threshold
}
};
this.logger.info('Anomaly detection completed', {
anomalyCount: anomalies.length,
threshold,
mean,
standardDeviation
});
return result;
}
public rankCostDrivers(records: BillingRecord[]): CostRankingResult {
const totalCost = records.reduce((sum, record) => sum + record.cost, 0);
// Top services
const serviceMap = new Map<string, number>();
records.forEach(record => {
const current = serviceMap.get(record.service) || 0;
serviceMap.set(record.service, current + record.cost);
});
const topServices = Array.from(serviceMap.entries())
.map(([service, cost], index) => ({
service,
totalCost: cost,
percentage: (cost / totalCost) * 100,
rank: index + 1
}))
.sort((a, b) => b.totalCost - a.totalCost)
.slice(0, 10);
// Top regions
const regionMap = new Map<string, number>();
records.forEach(record => {
const current = regionMap.get(record.region) || 0;
regionMap.set(record.region, current + record.cost);
});
const topRegions = Array.from(regionMap.entries())
.map(([region, cost], index) => ({
region,
totalCost: cost,
percentage: (cost / totalCost) * 100,
rank: index + 1
}))
.sort((a, b) => b.totalCost - a.totalCost)
.slice(0, 10);
// Cost drivers (simplified - based on cost contribution)
const costDrivers = [
...topServices.slice(0, 5).map(service => ({
type: 'service' as const,
name: service.service,
impact: service.percentage,
trend: 'stable' as const // Simplified - would need historical data for real trend
})),
...topRegions.slice(0, 3).map(region => ({
type: 'region' as const,
name: region.region,
impact: region.percentage,
trend: 'stable' as const
}))
].sort((a, b) => b.impact - a.impact);
const result: CostRankingResult = {
topServices,
topRegions,
costDrivers
};
this.logger.info('Cost ranking completed', {
topServiceCount: topServices.length,
topRegionCount: topRegions.length,
costDriverCount: costDrivers.length
});
return result;
}
private calculateTrends(records: BillingRecord[]): CostAnalysisResult['trends'] {
const timeSeriesData = this.aggregateByDate(records);
if (timeSeriesData.length < 2) {
return {
direction: 'stable',
changePercent: 0,
confidence: 0
};
}
const trend = this.calculateLinearTrend(timeSeriesData);
return {
direction: trend.direction,
changePercent: trend.slope * 100, // Convert slope to percentage
confidence: trend.confidence
};
}
private groupByService(records: BillingRecord[]): Map<string, BillingRecord[]> {
const groups = new Map<string, BillingRecord[]>();
records.forEach(record => {
const existing = groups.get(record.service) || [];
existing.push(record);
groups.set(record.service, existing);
});
return groups;
}
private aggregateByDate(records: BillingRecord[]): { date: Date; cost: number }[] {
const dateMap = new Map<string, number>();
records.forEach(record => {
const dateKey = record.startDate.toISOString().split('T')[0];
const current = dateMap.get(dateKey) || 0;
dateMap.set(dateKey, current + record.cost);
});
return Array.from(dateMap.entries())
.map(([dateStr, cost]) => ({
date: new Date(dateStr),
cost
}))
.sort((a, b) => a.date.getTime() - b.date.getTime());
}
private calculateLinearTrend(data: { date: Date; cost: number }[]): TrendAnalysisResult['trend'] {
if (data.length < 2) {
return {
direction: 'stable',
slope: 0,
confidence: 0
};
}
// Convert dates to numeric values (days since first date)
const firstDate = data[0].date.getTime();
const points = data.map(d => ({
x: (d.date.getTime() - firstDate) / (1000 * 60 * 60 * 24), // Days
y: d.cost
}));
// Calculate linear regression
const n = points.length;
const sumX = points.reduce((sum, p) => sum + p.x, 0);
const sumY = points.reduce((sum, p) => sum + p.y, 0);
const sumXY = points.reduce((sum, p) => sum + p.x * p.y, 0);
const sumXX = points.reduce((sum, p) => sum + p.x * p.x, 0);
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
// Calculate R-squared for confidence
const meanY = sumY / n;
const ssTotal = points.reduce((sum, p) => sum + Math.pow(p.y - meanY, 2), 0);
const intercept = (sumY - slope * sumX) / n;
const ssResidual = points.reduce((sum, p) => {
const predicted = slope * p.x + intercept;
return sum + Math.pow(p.y - predicted, 2);
}, 0);
const rSquared = ssTotal > 0 ? 1 - (ssResidual / ssTotal) : 0;
const confidence = Math.max(0, Math.min(1, rSquared));
let direction: 'increasing' | 'decreasing' | 'stable' = 'stable';
if (Math.abs(slope) > 0.01) { // Threshold for significance
direction = slope > 0 ? 'increasing' : 'decreasing';
}
return {
direction,
slope,
confidence
};
}
private detectSeasonality(data: { date: Date; cost: number }[]): TrendAnalysisResult['seasonality'] {
// Simplified seasonality detection
// In a real implementation, you'd use more sophisticated methods like FFT or autocorrelation
if (data.length < 14) {
return { detected: false };
}
// Check for weekly patterns (7-day cycle)
const weeklyPattern = this.checkCyclicalPattern(data, 7);
if (weeklyPattern.strength > 0.3) {
return {
detected: true,
pattern: 'weekly',
strength: weeklyPattern.strength
};
}
// Check for monthly patterns (30-day cycle)
const monthlyPattern = this.checkCyclicalPattern(data, 30);
if (monthlyPattern.strength > 0.3) {
return {
detected: true,
pattern: 'monthly',
strength: monthlyPattern.strength
};
}
return { detected: false };
}
private checkCyclicalPattern(data: { date: Date; cost: number }[], cycle: number): { strength: number } {
if (data.length < cycle * 2) {
return { strength: 0 };
}
// Calculate autocorrelation at the given lag
const values = data.map(d => d.cost);
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
let numerator = 0;
let denominator = 0;
for (let i = 0; i < values.length - cycle; i++) {
numerator += (values[i] - mean) * (values[i + cycle] - mean);
}
for (let i = 0; i < values.length; i++) {
denominator += Math.pow(values[i] - mean, 2);
}
const correlation = denominator > 0 ? numerator / denominator : 0;
return { strength: Math.abs(correlation) };
}
}