Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Correct cost aggregation logic to resolve diff in monthly cost #21

Merged
merged 3 commits into from
Jun 14, 2024
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
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