Skip to content

Commit c4a9660

Browse files
committed
Mock the AWS CostExplorer API + split some methods
- The methods on the AWSCostExplorer class should be doing one thing at a time, and respond with data specific to it, rather than anything to do with the infinity data source. This allows us to test them better, as well as re-use this code in other ways such as 2i2c-org/initiatives#55 - Mock the HTTP server that's responding to CE requests, and check-in a mock response that we can use to validate our code - a true integration test. - Allow configuring the AWS CE client we are using via traitlets. We use this here for tests, but we can also use this in other places where we want to configure other params. - Split the AWS Integration tests into a file by themselves
1 parent 7e6e207 commit c4a9660

5 files changed

Lines changed: 250 additions & 62 deletions

File tree

src/jupyterhub_cost_monitoring/app.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,15 @@ def total_costs(
8080
date_range = parse_from_to_in_query_params(from_date, to_date)
8181

8282
try:
83-
return aws_ce.query_total_costs(date_range)
83+
account_costs = aws_ce.query_account_costs(date_range)
84+
attributable_costs = aws_ce.query_attributable_costs(date_range)
85+
86+
# the infinity plugin appears needs us to sort by date, otherwise it fails
87+
# to distinguish time series by the name field for some reason
88+
sorted_response = sorted(
89+
account_costs + attributable_costs, key=lambda x: x["date"]
90+
)
91+
return sorted_response
8492
except Exception as e:
8593
raise HTTPException(status_code=500, detail=f"{e}")
8694

src/jupyterhub_cost_monitoring/aws.py

Lines changed: 38 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import boto3
1010
import requests
11-
from traitlets import Instance
11+
from traitlets import Dict, Instance, Unicode
1212
from traitlets.config import LoggingConfigurable
1313

1414
from .cache import ttl_lru_cache
@@ -43,15 +43,25 @@ def _get_component_name(self, service_name):
4343
klass=Prometheus,
4444
)
4545

46+
aws_client_extra_kwargs = Dict(
47+
Unicode(),
48+
help="""
49+
Extra arguments to be passed to the AWS Client that talks to the Cost Explorer
50+
""",
51+
config=True,
52+
)
53+
4654
def __init__(self, *args, **kwargs):
4755
super().__init__(*args, **kwargs)
48-
self.aws_ce_client = boto3.client("ce")
56+
self.aws_ce_client = boto3.client("ce", **self.aws_client_extra_kwargs)
4957

50-
def query(self, metrics, granularity, from_date, to_date, filter, group_by):
58+
def query(self, metrics, granularity, date_range: DateRange, filter, group_by):
5159
"""
5260
Function meant to be responsible for making the API call and handling
5361
pagination etc. Currently pagination isn't handled.
5462
"""
63+
from_date, to_date = date_range.aws_range
64+
5565
# ref: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ce/client/get_cost_and_usage.html#get-cost-and-usage
5666
response = self.aws_ce_client.get_cost_and_usage(
5767
Metrics=metrics,
@@ -93,84 +103,51 @@ def query_hub_names(self, date_range: DateRange):
93103
hub_names = [t or "support" for t in response["Tags"]]
94104
return hub_names
95105

96-
@ttl_lru_cache(seconds_to_live=3600)
97-
def query_total_costs(self, date_range: DateRange):
98-
"""
99-
Query total costs from AWS Cost Explorer for the given date range.
100-
101-
Reports both the total AWS account cost and the total attributable cost.
102-
Not all costs will be successfully attributed, such as the cost of accessing
103-
the AWS Cost Explorer API - its not something that can be attributed based
104-
on a tag.
105-
106-
Args:
107-
date_range: DateRange object containing the time period for the query
106+
def query_account_costs(self, date_range: DateRange):
107+
from_date, to_date = date_range.aws_range
108108

109-
Returns:
110-
List of cost entries with 'date', 'cost', and 'name' fields, sorted by date
111-
"""
112-
total_account_costs = self._query_total_costs(
113-
date_range, add_attributable_costs_filter=False
114-
)
115-
total_attributable_costs = self._query_total_costs(
116-
date_range, add_attributable_costs_filter=True
109+
response = self.query(
110+
metrics=[METRICS_UNBLENDED_COST],
111+
granularity=GRANULARITY_DAILY,
112+
date_range=date_range,
113+
filter=FILTER_USAGE_COSTS,
114+
group_by=[],
117115
)
118116

119-
processed_response = total_account_costs + total_attributable_costs
120-
121-
# the infinity plugin appears needs us to sort by date, otherwise it fails
122-
# to distinguish time series by the name field for some reason
123-
processed_response = sorted(processed_response, key=lambda x: x["date"])
117+
processed_response = [
118+
{
119+
"date": e["TimePeriod"]["Start"],
120+
"cost": f"{float(e['Total']['UnblendedCost']['Amount']):.2f}",
121+
"name": "account",
122+
}
123+
for e in response["ResultsByTime"]
124+
]
124125

125126
return processed_response
126127

127-
@ttl_lru_cache(seconds_to_live=3600)
128-
def _query_total_costs(self, date_range: DateRange, add_attributable_costs_filter):
129-
"""
130-
Internal function to query total costs from AWS Cost Explorer.
131-
132-
Can query either the total account costs or only the attributable costs
133-
based on the add_attributable_costs_filter parameter.
134-
135-
Args:
136-
date_range: DateRange object containing the time period for the query
137-
add_attributable_costs_filter: If True, only include attributable costs
138-
139-
Returns:
140-
List of cost entries with 'date', 'cost', and 'name' fields
141-
"""
142-
if add_attributable_costs_filter:
143-
name = "attributable"
144-
filter = {
128+
def query_attributable_costs(self, date_range: DateRange):
129+
response = self.query(
130+
metrics=[METRICS_UNBLENDED_COST],
131+
granularity=GRANULARITY_DAILY,
132+
date_range=date_range,
133+
filter={
145134
"And": [
146135
FILTER_USAGE_COSTS,
147136
FILTER_ATTRIBUTABLE_COSTS,
148137
]
149-
}
150-
else:
151-
name = "account"
152-
filter = FILTER_USAGE_COSTS
153-
154-
# Use AWS-formatted dates (exclusive end date) for Cost Explorer API
155-
from_date, to_date = date_range.aws_range
156-
157-
response = self.query(
158-
metrics=[METRICS_UNBLENDED_COST],
159-
granularity=GRANULARITY_DAILY,
160-
from_date=from_date,
161-
to_date=to_date,
162-
filter=filter,
138+
},
163139
group_by=[],
164140
)
165141

166142
processed_response = [
167143
{
168144
"date": e["TimePeriod"]["Start"],
169145
"cost": f"{float(e['Total']['UnblendedCost']['Amount']):.2f}",
170-
"name": name,
146+
"name": "account",
171147
}
172148
for e in response["ResultsByTime"]
173149
]
150+
174151
return processed_response
175152

176153
@ttl_lru_cache(seconds_to_live=3600)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
{
2+
"GroupDefinitions": [],
3+
"ResultsByTime": [
4+
{
5+
"TimePeriod": {
6+
"Start": "2026-06-20",
7+
"End": "2026-06-21"
8+
},
9+
"Total": {
10+
"UnblendedCost": {
11+
"Amount": "52.0646447944",
12+
"Unit": "USD"
13+
}
14+
},
15+
"Groups": [],
16+
"Estimated": false
17+
},
18+
{
19+
"TimePeriod": {
20+
"Start": "2026-06-21",
21+
"End": "2026-06-22"
22+
},
23+
"Total": {
24+
"UnblendedCost": {
25+
"Amount": "54.0740726258",
26+
"Unit": "USD"
27+
}
28+
},
29+
"Groups": [],
30+
"Estimated": false
31+
},
32+
{
33+
"TimePeriod": {
34+
"Start": "2026-06-22",
35+
"End": "2026-06-23"
36+
},
37+
"Total": {
38+
"UnblendedCost": {
39+
"Amount": "51.8434995458",
40+
"Unit": "USD"
41+
}
42+
},
43+
"Groups": [],
44+
"Estimated": false
45+
},
46+
{
47+
"TimePeriod": {
48+
"Start": "2026-06-23",
49+
"End": "2026-06-24"
50+
},
51+
"Total": {
52+
"UnblendedCost": {
53+
"Amount": "46.3563414046",
54+
"Unit": "USD"
55+
}
56+
},
57+
"Groups": [],
58+
"Estimated": false
59+
},
60+
{
61+
"TimePeriod": {
62+
"Start": "2026-06-24",
63+
"End": "2026-06-25"
64+
},
65+
"Total": {
66+
"UnblendedCost": {
67+
"Amount": "53.5013421334",
68+
"Unit": "USD"
69+
}
70+
},
71+
"Groups": [],
72+
"Estimated": false
73+
},
74+
{
75+
"TimePeriod": {
76+
"Start": "2026-06-25",
77+
"End": "2026-06-26"
78+
},
79+
"Total": {
80+
"UnblendedCost": {
81+
"Amount": "50.7870556936",
82+
"Unit": "USD"
83+
}
84+
},
85+
"Groups": [],
86+
"Estimated": false
87+
},
88+
{
89+
"TimePeriod": {
90+
"Start": "2026-06-26",
91+
"End": "2026-06-27"
92+
},
93+
"Total": {
94+
"UnblendedCost": {
95+
"Amount": "44.5775389922",
96+
"Unit": "USD"
97+
}
98+
},
99+
"Groups": [],
100+
"Estimated": false
101+
},
102+
{
103+
"TimePeriod": {
104+
"Start": "2026-06-27",
105+
"End": "2026-06-28"
106+
},
107+
"Total": {
108+
"UnblendedCost": {
109+
"Amount": "40.0196681343",
110+
"Unit": "USD"
111+
}
112+
},
113+
"Groups": [],
114+
"Estimated": false
115+
}
116+
],
117+
"DimensionValueAttributes": [],
118+
"ResponseMetadata": {
119+
"RequestId": "cfc0ac51-43b3-409d-a367-ed9cb4f42740",
120+
"HTTPStatusCode": 200,
121+
"HTTPHeaders": {
122+
"date": "Wed, 29 Jul 2026 22:24:32 GMT",
123+
"content-type": "application/x-amz-json-1.1",
124+
"content-length": "1295",
125+
"connection": "keep-alive",
126+
"x-amzn-requestid": "cfc0ac51-43b3-409d-a367-ed9cb4f42740",
127+
"cache-control": "no-cache"
128+
},
129+
"RetryAttempts": 0
130+
}
131+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[
2+
{
3+
"date": "2026-06-20",
4+
"cost": "52.06",
5+
"name": "account"
6+
},
7+
{
8+
"date": "2026-06-21",
9+
"cost": "54.07",
10+
"name": "account"
11+
},
12+
{
13+
"date": "2026-06-22",
14+
"cost": "51.84",
15+
"name": "account"
16+
},
17+
{
18+
"date": "2026-06-23",
19+
"cost": "46.36",
20+
"name": "account"
21+
},
22+
{
23+
"date": "2026-06-24",
24+
"cost": "53.50",
25+
"name": "account"
26+
},
27+
{
28+
"date": "2026-06-25",
29+
"cost": "50.79",
30+
"name": "account"
31+
},
32+
{
33+
"date": "2026-06-26",
34+
"cost": "44.58",
35+
"name": "account"
36+
},
37+
{
38+
"date": "2026-06-27",
39+
"cost": "40.02",
40+
"name": "account"
41+
}
42+
]

tests/test_aws.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import json
2+
from datetime import datetime
3+
4+
import pytest
5+
from pytest_httpserver import HTTPServer
6+
7+
from jupyterhub_cost_monitoring.aws import AWSCostExplorer
8+
from jupyterhub_cost_monitoring.date_utils import DateRange
9+
10+
11+
@pytest.fixture
12+
def aws_date_range() -> DateRange:
13+
return DateRange(datetime(2026, 6, 20), datetime(2026, 6, 27))
14+
15+
16+
def test_query_account_cost(httpserver: HTTPServer, aws_date_range: DateRange):
17+
aws_endpoint_url = f"http://{httpserver.host}:{httpserver.port}/"
18+
ce = AWSCostExplorer(
19+
aws_client_extra_kwargs={
20+
"region_name": "test",
21+
"endpoint_url": aws_endpoint_url,
22+
}
23+
)
24+
25+
with open("tests/data/fixtures/aws-ce/test_query_account_cost-input.json") as f:
26+
httpserver.expect_request("/", method="POST").respond_with_data(f.read())
27+
28+
account_costs = ce.query_account_costs(aws_date_range)
29+
with open("tests/data/fixtures/aws-ce/test_query_account_cost-output.json") as f:
30+
assert account_costs == json.load(f)

0 commit comments

Comments
 (0)