Skip to content

Commit 7b4163e

Browse files
authored
Simplify groups (#101)
* Release connection on error * Remove dates from user group information * Ensure uniquely keyed entries when double-counting group costs * Remove date range from user group query in tests
1 parent cd86a01 commit 7b4163e

5 files changed

Lines changed: 57 additions & 52 deletions

File tree

docs/topics/cost-calculations.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ The proportional usage described above does not take into account the underutili
4444

4545
Group-level costs are calculated by aggregating the user-level costs for all users within a group. Each entry in the [`/costs-per-user`](#id-costs-per-user) endpoint includes a `usergroup` key that indicates the group which the user belongs.
4646

47+
:::{note}
48+
Only user group memberships from the most recent dates are considered. Historical user group memberships incur a heavy performance penalty.
49+
:::
50+
4751
There are a few extra endpoints provided by the cost monitoring application to help track user group costs.
4852

4953
### `/total-costs-per-group`

src/jupyterhub_cost_monitoring/app.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,6 @@ def total_costs(
8383

8484
@app.get("/user-groups")
8585
def user_groups(
86-
from_date: str | None = Query(
87-
None, alias="from", description="Start date in YYYY-MM-DDTHH:MMZ format"
88-
),
89-
to_date: str | None = Query(
90-
None, alias="to", description="End date in YYYY-MM-DDTHH:MMZ format"
91-
),
9286
hub: str | None = Query(None, description="Name of the hub to filter results"),
9387
username: str | None = Query(
9488
None, description="Name of the user to filter results"
@@ -98,11 +92,10 @@ def user_groups(
9892
),
9993
):
10094
"""
101-
Endpoint to serve user group memberships.
95+
Endpoint to serve user group memberships. Note that only the most recent date for each user group membership is returned.
10296
"""
103-
date_range = parse_from_to_in_query_params(from_date, to_date)
10497

105-
return query_user_groups(date_range, hub, username, usergroup)
98+
return query_user_groups(hub, username, usergroup)
10699

107100

108101
@app.get("/users-with-multiple-groups")
@@ -269,8 +262,6 @@ def costs_per_user(
269262
if not usergroup or ("all" in [u.lower() for u in usergroup]):
270263
usergroup = [None]
271264

272-
logger.info(f"Limit parameter: {limit}")
273-
274265
# Get per-user costs by combining AWS costs with Prometheus usage data
275266
results = []
276267
for ug in usergroup:

src/jupyterhub_cost_monitoring/query_cost_aws.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -574,21 +574,36 @@ def query_total_costs_per_user(
574574
results.append(entry)
575575
results = [x for x in results if x["hub"] != "binder"] # Exclude binder hubs
576576
user_groups = query_user_groups(date_range, hub, user)
577+
seen = set()
577578
list_groups = []
579+
# Ensure uniquely keyed entries when double-counting group costs
578580
for r in results:
581+
matched = False
579582
for entry in user_groups:
580-
if (r["date"] == entry["date"]) and (
581-
r["hub"] == entry["hub"] and (r["user"] == entry["username"])
582-
):
583-
if "usergroup" not in r.keys():
583+
if r["hub"] == entry["hub"] and r["user"] == entry["username"]:
584+
key = (
585+
r["date"],
586+
r["hub"],
587+
r["user"],
588+
r["component"],
589+
entry["usergroup"],
590+
)
591+
if key in seen:
592+
continue
593+
seen.add(key)
594+
if "usergroup" not in r:
584595
r["usergroup"] = entry["usergroup"]
596+
matched = True
585597
else:
586598
r_copy = copy.deepcopy(r)
587599
r_copy["usergroup"] = entry["usergroup"]
588600
list_groups.append(r_copy)
589-
if r.get("usergroup") is None:
590-
logger.debug(f"No username match for group membership: {r}")
591-
r["usergroup"] = "none"
601+
matched = True
602+
if not matched:
603+
key = (r["date"], r["hub"], r["user"], r["component"], "none")
604+
if key not in seen:
605+
seen.add(key)
606+
r["usergroup"] = "none"
592607
results.extend(list_groups)
593608
if limit:
594609
limit = int(limit)

src/jupyterhub_cost_monitoring/query_usage.py

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44

55
import os
66
from collections import defaultdict
7-
from datetime import datetime, timezone
7+
from datetime import datetime, timedelta, timezone
88

99
import escapism
1010
import requests
1111
from yarl import URL
1212

1313
from .cache import ttl_lru_cache
1414
from .const_usage import USAGE_MAP, USER_GROUP_INFO
15-
from .date_utils import DateRange
15+
from .date_utils import DateRange, get_now_date
1616
from .logs import get_logger
1717

1818
logger = get_logger(__name__)
@@ -46,11 +46,11 @@ def query_prometheus(query: str, date_range: DateRange, step: str) -> requests.R
4646
"step": step,
4747
}
4848
query_api = URL(prometheus_api.with_path("/api/v1/query_range"))
49-
response = requests.get(query_api, params=parameters)
50-
logger.info(f"Querying Prometheus: {response.url}")
51-
response.raise_for_status()
52-
result = response.json()
53-
return result
49+
with requests.get(query_api, params=parameters) as response:
50+
logger.info(f"Querying Prometheus: {response.url}")
51+
response.raise_for_status()
52+
result = response.json()
53+
return result
5454

5555

5656
def query_usage(
@@ -243,11 +243,15 @@ def _calculate_daily_cost_factors(
243243

244244
@ttl_lru_cache(seconds_to_live=3600)
245245
def query_user_groups(
246-
date_range: DateRange,
247246
hub_name: str | None = None,
248247
user_name: str | None = None,
249248
group_name: str | None = None,
250249
) -> list[dict]:
250+
"""
251+
Get user group information from the Prometheus server for the most recent day.
252+
"""
253+
now_date = get_now_date() - timedelta(days=1)
254+
date_range = DateRange(start_date=now_date, end_date=now_date)
251255
response = query_prometheus(USER_GROUP_INFO, date_range, step="1d")
252256
result = _process_user_groups(response, hub_name, user_name, group_name)
253257
return result
@@ -260,33 +264,25 @@ def _process_user_groups(
260264
group_name: str | None = None,
261265
) -> list[dict]:
262266
"""
263-
Process the response from the Prometheus server to extract user group information.
267+
Process the response from the Prometheus server to extract user group information. Note that only the most recent date of user group membership is used.
264268
"""
265269
result = []
266270
unique_keys = set()
267271
for data in response["data"]["result"]:
268-
for value in data["values"]:
269-
date = datetime.fromtimestamp(value[0], tz=timezone.utc).strftime(
270-
"%Y-%m-%d"
271-
)
272-
hub = data["metric"]["namespace"]
273-
user = data["metric"]["username"]
274-
user_escaped = data["metric"]["username_escaped"]
275-
group = data["metric"]["usergroup"]
276-
key = (date, hub, user, user_escaped, group)
277-
if key not in unique_keys:
278-
unique_keys.add(key)
279-
result.append(
280-
{
281-
"date": date,
282-
"hub": hub,
283-
"username": user,
284-
"username_escaped": user_escaped,
285-
"usergroup": group,
286-
}
287-
)
288-
result = _filter_json(
289-
result, hub=hub_name, username=user_name, usergroup=group_name
272+
hub = data["metric"]["namespace"]
273+
user = data["metric"]["username"]
274+
user_escaped = data["metric"]["username_escaped"]
275+
group = data["metric"]["usergroup"]
276+
key = (hub, user, user_escaped, group)
277+
if key not in unique_keys:
278+
unique_keys.add(key)
279+
result.append(
280+
{
281+
"hub": hub,
282+
"username": user,
283+
"username_escaped": user_escaped,
284+
"usergroup": group,
285+
}
290286
)
291287
return result
292288

@@ -297,7 +293,7 @@ def query_users_with_multiple_groups(
297293
hub_name: str | None = None,
298294
user_name: str | None = None,
299295
) -> list[dict]:
300-
response = query_user_groups(date_range, hub_name=hub_name, user_name=user_name)
296+
response = query_user_groups(hub_name=hub_name, user_name=user_name)
301297
grouped = defaultdict(
302298
lambda: {"username": None, "hub": None, "usergroups": [], "has_multiple": False}
303299
)
@@ -327,7 +323,7 @@ def query_users_with_no_groups(
327323
hub_name: str | None = None,
328324
user_name: str | None = None,
329325
) -> list[dict]:
330-
response = query_user_groups(date_range, hub_name=hub_name, user_name=user_name)
326+
response = query_user_groups(hub_name=hub_name, user_name=user_name)
331327
grouped = defaultdict(lambda: {"username": None, "hub": None})
332328
for entry in response:
333329
key = (entry["username"], entry["hub"])

tests/test_integration.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ def test_get_user_group_info(mock_prometheus_user_group_info, env_vars):
4444
from src.jupyterhub_cost_monitoring.query_usage import query_user_groups
4545

4646
response = query_user_groups(
47-
date_range,
4847
hub_name=None,
4948
user_name=None,
5049
group_name=None,

0 commit comments

Comments
 (0)