Skip to content

Commit

Permalink
Merge pull request #21 from route06/issue-8-fix-cost-aggregation-logic
Browse files Browse the repository at this point in the history
Correct cost aggregation logic to resolve diff in monthly cost
  • Loading branch information
Rindrics authored Jun 14, 2024
2 parents f73e734 + 2d9a375 commit 38dc2f3
Show file tree
Hide file tree
Showing 2 changed files with 211 additions and 6 deletions.
201 changes: 201 additions & 0 deletions src/cost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import AWS from 'aws-sdk';
import { AWSConfig } from './config';
import { getRawCostByService, getTotalCosts } from './cost';
import AWSMock from 'aws-sdk-mock';
import dayjs from 'dayjs';

// Use Apr 2024 (30 days) as the 'last month'
// Thus 'today' is someday in May 2024
const costDataLength = 65;
const fixedToday = '2024-05-11'; // cost of 'this month' will be sum of 10 days from May 1 to May 10 ('today' is omitted because its cost is incomplete)
const fixedFirstDay = dayjs(fixedToday).subtract(costDataLength, 'day');

const generateMockPricingData = () => {
const resultsByTime = [];
for (let i = 0; i < costDataLength; i++) {
const date = dayjs(fixedFirstDay).add(i, 'day').format('YYYY-MM-DD');
const month = dayjs(date).month(); // 0-indexed (0 = January, 1 = February, etc.)
let service1Cost;

switch (month) {
case 2: // March
service1Cost = 0.9;
break;
case 3: // April
service1Cost = 1.0; // Total cost of service1 in April will be 30.00
break;
case 4: // May
service1Cost = 1.1;
break;
default:
service1Cost = 0.0; // Default cost if none of the above
}

resultsByTime.push({
TimePeriod: {
Start: date,
End: dayjs(date).add(1, 'day').format('YYYY-MM-DD'),
},
Groups: [
{
Keys: ['service1'],
Metrics: {
UnblendedCost: {
Amount: String(service1Cost),
Unit: 'USD',
},
},
},
{
Keys: ['service2'],
Metrics: {
UnblendedCost: {
Amount: String(service1Cost * 100),
Unit: 'USD',
},
},
},
],
});
}
return { ResultsByTime: resultsByTime };
};

describe('Cost Functions', () => {
beforeAll(() => {
AWSMock.setSDKInstance(AWS);
});

afterAll(() => {
AWSMock.restore();
});

beforeEach(() => {
jest.useFakeTimers('modern');
jest.setSystemTime(new Date(fixedToday).getTime());
});

afterEach(() => {
jest.useRealTimers();
});

describe('getRawCostByService', () => {
it('should return raw cost by service', async () => {
const awsConfig: AWSConfig = {
credentials: {
accessKeyId: 'testAccessKeyId',
secretAccessKey: 'testSecretAccessKey',
sessionToken: 'testSessionToken',
},
region: 'us-east-1',
};

const mockPricingData = generateMockPricingData();

AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => {
callback(null, mockPricingData);
});

const rawCostByService = await getRawCostByService(awsConfig);

const expectedRawCostByService = {
service1: {},
service2: {},
};
for (let i = 0; i < costDataLength; i++) {
const date = dayjs(fixedFirstDay).add(i, 'day').format('YYYY-MM-DD');
const month = dayjs(date).month();
let service1Cost;

switch (month) {
case 2: // March
service1Cost = 0.9;
break;
case 3: // April
service1Cost = 1.0; // Total cost of service1 in April will be 30.00
break;
case 4: // May
service1Cost = 1.1;
break;
default:
service1Cost = 0.0; // Default cost if none of the above
}

expectedRawCostByService.service1[date] = service1Cost;
expectedRawCostByService.service2[date] = service1Cost * 100;
}

expect(rawCostByService).toEqual(expectedRawCostByService);

AWSMock.restore('CostExplorer');
});
});

describe('getTotalCosts', () => {
it('should return total costs', async () => {
const awsConfig: AWSConfig = {
credentials: {
accessKeyId: 'testAccessKeyId',
secretAccessKey: 'testSecretAccessKey',
sessionToken: 'testSessionToken',
},
region: 'us-east-1',
};

const mockPricingData = generateMockPricingData();

AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => {
callback(null, mockPricingData);
});

const totalCosts = await getTotalCosts(awsConfig);

const expectedTotalCosts = {
totals: {
lastMonth: 30 * (1 + 100), // Apr
thisMonth: 10 * (1.1 + 110), // sum of May 1..May 10
last7Days: 7 * 1.1 + 7 * 110, // sum of May 4..May 10
yesterday: 1.1 + 110, // on May 10
},
totalsByService: {
lastMonth: {
// Apr
service1: 30.0,
service2: 3000.0,
},
thisMonth: {
service1: 11.0, // 10 days of May
service2: 1100.0,
},
last7Days: {
service1: 7.7,
service2: 770.0,
},
yesterday: {
service1: 1.1,
service2: 110.0,
},
},
};

const roundToTwoDecimals = (num: number) => Math.round(num * 100) / 100;

Object.keys(totalCosts.totals).forEach((key) => {
expect(roundToTwoDecimals(totalCosts.totals[key])).toBeCloseTo(
expectedTotalCosts.totals[key],
1,
);
});

Object.keys(totalCosts.totalsByService).forEach((period) => {
Object.keys(totalCosts.totalsByService[period]).forEach((service) => {
expect(
roundToTwoDecimals(totalCosts.totalsByService[period][service]),
).toBeCloseTo(expectedTotalCosts.totalsByService[period][service], 1);
});
});

AWSMock.restore('CostExplorer');
});
});
});
16 changes: 10 additions & 6 deletions src/cost.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import AWS from 'aws-sdk';
import dayjs from 'dayjs';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
import { AWSConfig } from './config';
import { showSpinner } from './logger';

Expand All @@ -15,7 +19,7 @@ export async function getRawCostByService(
showSpinner('Getting pricing data');

const costExplorer = new AWS.CostExplorer(awsConfig);
const endDate = dayjs().subtract(1, 'day');
const endDate = dayjs(); // `endDate` is set to 'today' but its cost be omitted because of API spec (see: https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html#API_GetCostAndUsage_RequestSyntax)
const startDate = endDate.subtract(65, 'day');

const groupByConfig = [
Expand Down Expand Up @@ -74,7 +78,7 @@ export async function getRawCostByService(
const filterKeys = group.Keys;
const serviceName = filterKeys.find((key) => !/^\d{12}$/.test(key)); // AWS service name is non-12-digits string
const cost = group.Metrics.UnblendedCost.Amount;
const costDate = day.TimePeriod.End;
const costDate = day.TimePeriod.Start; // must be set to `Start` not `End` because the end of `Period` parameter will be omitted (see: https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_GetCostAndUsage.html#API_GetCostAndUsage_RequestSyntax)

costByService[serviceName] = costByService[serviceName] || {};
costByService[serviceName][costDate] = parseFloat(cost);
Expand Down Expand Up @@ -118,8 +122,8 @@ function calculateServiceTotals(

const startOfLastMonth = dayjs().subtract(1, 'month').startOf('month');
const startOfThisMonth = dayjs().startOf('month');
const startOfLast7Days = dayjs().subtract(7, 'day');
const startOfYesterday = dayjs().subtract(1, 'day');
const startOfLast7Days = dayjs().subtract(7, 'day').startOf('day');
const startOfYesterday = dayjs().subtract(1, 'day').startOf('day');

for (const service of Object.keys(rawCostByService)) {
const servicePrices = rawCostByService[service];
Expand All @@ -142,8 +146,8 @@ function calculateServiceTotals(
}

if (
dateObj.isSame(startOfLast7Days, 'week') &&
!dateObj.isSame(startOfYesterday, 'day')
dateObj.isSameOrAfter(startOfLast7Days) &&
dateObj.isSameOrBefore(dayjs().startOf('day'))
) {
last7DaysServiceTotal += price;
}
Expand Down

0 comments on commit 38dc2f3

Please sign in to comment.