Skip to content

Commit 7bc03ae

Browse files
committed
Migrate to using Traitlets
This lets us eventually generalize the codebase to both other cloud providers, as well as customize the tags that are looked at
1 parent f49d135 commit 7bc03ae

3 files changed

Lines changed: 378 additions & 371 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"prometheus-client~=0.24",
1414
"requests>=2.32.4",
1515
"yarl>=1.20.1",
16+
"traitlets"
1617
]
1718

1819
[dependency-groups]
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
"""
2+
Query the Prometheus server to get usage of JupyterHub resources.
3+
"""
4+
5+
import os
6+
from collections import defaultdict
7+
from datetime import datetime, timedelta, timezone
8+
9+
import escapism
10+
import requests
11+
from yarl import URL
12+
13+
from .cache import ttl_lru_cache
14+
from .const_usage import USAGE_MAP, USER_GROUP_INFO
15+
from .date_utils import DateRange, get_now_date
16+
from .logs import get_logger
17+
18+
logger = get_logger(__name__)
19+
20+
prometheus_host = os.environ.get("SUPPORT_PROMETHEUS_SERVER_SERVICE_HOST", "localhost")
21+
prometheus_port = int(os.environ.get("SUPPORT_PROMETHEUS_SERVER_SERVICE_PORT", 9090))
22+
prometheus_username = os.environ.get("PROMETHEUS_USERNAME", "")
23+
prometheus_password = os.environ.get("PROMETHEUS_PASSWORD", "")
24+
25+
class Prometheus(LoggingConfigurable):
26+
def query(self, query: str, date_range: DateRange, step: str) -> requests.Response:
27+
"""
28+
Query the Prometheus server with the given query over a date range.
29+
30+
Args:
31+
query: The Prometheus query string
32+
date_range: DateRange object containing the time period for the query
33+
step: The query resolution step duration
34+
35+
Returns:
36+
JSON response from Prometheus API
37+
"""
38+
# Use Prometheus-formatted dates (inclusive date range with ISO timestamps)
39+
from_date, to_date = date_range.prometheus_range
40+
41+
prometheus_api = URL.build(
42+
scheme="http", host=prometheus_host, port=prometheus_port
43+
)
44+
if prometheus_username != "" and prometheus_password != "":
45+
prometheus_auth = requests.auth.HTTPBasicAuth(
46+
prometheus_username, prometheus_password
47+
)
48+
else:
49+
prometheus_auth = None
50+
parameters = {
51+
"query": query,
52+
"start": from_date,
53+
"end": to_date,
54+
"step": step,
55+
}
56+
query_api = URL(prometheus_api.with_path("/api/v1/query_range"))
57+
with requests.get(query_api, params=parameters, auth=prometheus_auth) as response:
58+
logger.info(f"Querying Prometheus: {response.url}")
59+
response.raise_for_status()
60+
result = response.json()
61+
return result
62+
63+
64+
def query_usage(
65+
self,
66+
date_range: DateRange,
67+
hub_name: str | None,
68+
component_name: str | None,
69+
user_name: str | None,
70+
) -> list[dict]:
71+
"""
72+
Query usage cost factors per user from the Prometheus server.
73+
74+
Returns daily usage cost factors (0-1) for each user, where cost factors represent
75+
each user's share of total resource usage and sum to 1 across all users
76+
within each date/hub/component combination.
77+
78+
Args:
79+
date_range: DateRange object containing the time period for the query
80+
hub_name: Optional name of the hub to filter results.
81+
component_name: Optional name of the component to filter results.
82+
user_name: Optional name of the user to filter results.
83+
"""
84+
result = []
85+
if component_name is None:
86+
# Query all components defined in USAGE_MAP
87+
for component, params in USAGE_MAP.items():
88+
try:
89+
response = self.query(
90+
params["query"], date_range, step=params["step"]
91+
)
92+
except requests.exceptions.RequestException:
93+
raise
94+
result.extend(self._process_response(response, component))
95+
else:
96+
# Query specific component only
97+
try:
98+
response = self.query(
99+
USAGE_MAP[component_name]["query"],
100+
date_range,
101+
step=USAGE_MAP[component_name]["step"],
102+
)
103+
except requests.exceptions.RequestException:
104+
raise
105+
result.extend(self._process_response(response, component_name))
106+
# Calculate daily cost factors from absolute usage totals)
107+
result = self._calculate_daily_cost_factors(result, hub_name=hub_name)
108+
# sort the result by date
109+
result.sort(key=lambda x: (x["date"], x["component"], x["hub"], x["user"]))
110+
result = self._filter_json(result, hub=hub_name, user=user_name)
111+
return result
112+
113+
114+
def _process_response(
115+
self,
116+
response: requests.Response,
117+
component_name: str,
118+
) -> dict:
119+
"""
120+
Process the response from the Prometheus server to extract absolute usage data.
121+
122+
Converts the time series data into a list of usage records, then pivots by date
123+
and sums the absolute usage values across time steps within each date.
124+
125+
If the component_name is home storage, then rename the escaped username used for the directory to the unescaped version.
126+
"""
127+
result = []
128+
for data in response["data"]["result"]:
129+
hub = data["metric"]["namespace"]
130+
user = data["metric"]["username"]
131+
date = [
132+
datetime.fromtimestamp(value[0], tz=timezone.utc).strftime("%Y-%m-%d")
133+
for value in data["values"]
134+
]
135+
usage = [float(value[1]) for value in data["values"]]
136+
result.append(
137+
{
138+
"hub": hub,
139+
"component": component_name,
140+
"user": user,
141+
"date": date,
142+
"value": usage,
143+
}
144+
)
145+
pivoted_result = self._pivot_response_dict(result)
146+
processed_result = self._sum_absolute_usage_by_date(pivoted_result)
147+
148+
if component_name == "home storage":
149+
for entry in processed_result:
150+
if "shared" not in entry["user"]:
151+
try:
152+
entry["user"] = escapism.unescape(entry["user"], escape_char="-")
153+
except ValueError:
154+
logger.warning(
155+
f"Could not unescape username {entry['user']} for home storage component."
156+
)
157+
continue
158+
return processed_result
159+
160+
161+
def _filter_json(self, result: list[dict], **filters):
162+
return [
163+
item
164+
for item in result
165+
if all(filters[k] is None or item.get(k) == filters[k] for k in filters)
166+
]
167+
168+
169+
def _pivot_response_dict(self, result: list[dict]) -> list[dict]:
170+
"""
171+
Pivot the response dictionary to have top-level keys as dates.
172+
"""
173+
pivot = []
174+
for entry in result:
175+
for date, value in zip(entry["date"], entry["value"]):
176+
pivot.append(
177+
{
178+
"date": date,
179+
"user": entry["user"],
180+
"hub": entry["hub"],
181+
"component": entry["component"],
182+
"value": value,
183+
}
184+
)
185+
return pivot
186+
187+
188+
def _sum_absolute_usage_by_date(self, result: list[dict]) -> list[dict]:
189+
"""
190+
Sum the absolute usage values by date.
191+
192+
The Prometheus queries can return multiple absolute usage values per day.
193+
We sum across all entries within each date to get the total daily usage for each user.
194+
"""
195+
sums = defaultdict(float)
196+
197+
for entry in result:
198+
key = (
199+
entry["date"],
200+
entry["user"],
201+
entry["hub"],
202+
entry["component"],
203+
)
204+
sums[key] += entry["value"]
205+
206+
return [
207+
{
208+
"date": date,
209+
"user": user,
210+
"hub": hub,
211+
"component": component,
212+
"value": total,
213+
}
214+
for (date, user, hub, component), total in sums.items()
215+
]
216+
217+
218+
def _calculate_daily_cost_factors(
219+
self, result: list[dict], hub_name: str | None = None
220+
) -> list[dict]:
221+
"""
222+
Calculate daily usage cost factors from absolute usage values.
223+
224+
Converts absolute usage values to cost factors by dividing each user's usage
225+
by the total usage for all users within the appropriate grouping.
226+
227+
If hub_name is None: cost factors are calculated across all hubs for each date/component
228+
If hub_name is specified: cost factors are calculated per hub for each date/component
229+
230+
This ensures that cost factors sum to 1 for the appropriate grouping.
231+
"""
232+
# Calculate total usage for the appropriate grouping
233+
totals = defaultdict(float)
234+
for entry in result:
235+
if hub_name is None:
236+
# When no specific hub requested, calculate totals across all hubs
237+
key = (entry["date"], entry["component"])
238+
else:
239+
# When specific hub requested, calculate totals per hub
240+
key = (entry["date"], entry["hub"], entry["component"])
241+
totals[key] += entry["value"]
242+
243+
# Convert absolute values to cost factors
244+
for entry in result:
245+
if hub_name is None:
246+
# When no specific hub requested, use cross-hub totals
247+
key = (entry["date"], entry["component"])
248+
else:
249+
# When specific hub requested, use per-hub totals
250+
key = (entry["date"], entry["hub"], entry["component"])
251+
252+
total = totals[key]
253+
if total > 0:
254+
entry["value"] = entry["value"] / total
255+
else:
256+
entry["value"] = 0.0
257+
return result
258+
259+
260+
@ttl_lru_cache(seconds_to_live=3600)
261+
def query_user_groups(
262+
self,
263+
hub_name: str | None = None,
264+
user_name: str | None = None,
265+
group_name: str | None = None,
266+
) -> list[dict]:
267+
"""
268+
Get user group information from the Prometheus server for the most recent day.
269+
"""
270+
now_date = get_now_date() - timedelta(days=1)
271+
date_range = DateRange(start_date=now_date, end_date=now_date)
272+
try:
273+
response = self.query(USER_GROUP_INFO, date_range, step="1d")
274+
except requests.exceptions.RequestException as e:
275+
logger.exception(f"HTTP request failed: {e}")
276+
raise
277+
result = self._process_user_groups(response, hub_name, user_name, group_name)
278+
return result
279+
280+
281+
def _process_user_groups(
282+
self,
283+
response: requests.Response,
284+
hub_name: str | None = None,
285+
user_name: str | None = None,
286+
group_name: str | None = None,
287+
) -> list[dict]:
288+
"""
289+
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.
290+
"""
291+
result = []
292+
unique_keys = set()
293+
for data in response["data"]["result"]:
294+
hub = data["metric"]["namespace"]
295+
user = data["metric"]["username"]
296+
user_escaped = data["metric"]["username_escaped"]
297+
group = data["metric"]["usergroup"]
298+
key = (hub, user, user_escaped, group)
299+
if key not in unique_keys:
300+
unique_keys.add(key)
301+
result.append(
302+
{
303+
"hub": hub,
304+
"username": user,
305+
"username_escaped": user_escaped,
306+
"usergroup": group,
307+
}
308+
)
309+
return result
310+
311+
312+
@ttl_lru_cache(seconds_to_live=3600)
313+
def query_users_with_multiple_groups(
314+
self,
315+
date_range: DateRange,
316+
hub_name: str | None = None,
317+
user_name: str | None = None,
318+
) -> list[dict]:
319+
try:
320+
response = self.query_user_groups(hub_name=hub_name, user_name=user_name)
321+
except requests.exceptions.RequestException as e:
322+
logger.exception(f"HTTP request failed: {e}")
323+
raise
324+
grouped = defaultdict(
325+
lambda: {"username": None, "hub": None, "usergroups": [], "has_multiple": False}
326+
)
327+
for entry in response:
328+
k = (entry["username"], entry["hub"])
329+
g = grouped[k]
330+
g["username"] = entry["username"]
331+
g["hub"] = entry["hub"]
332+
if entry["usergroup"] == "multiple":
333+
g["has_multiple"] = True
334+
continue
335+
g["usergroups"].append(entry["usergroup"])
336+
result = []
337+
for v in grouped.values():
338+
if v["has_multiple"]:
339+
for group in v["usergroups"]:
340+
result.append(
341+
{"username": v["username"], "hub": v["hub"], "usergroup": group}
342+
)
343+
344+
return result
345+
346+
347+
@ttl_lru_cache(seconds_to_live=3600)
348+
def query_users_with_no_groups(
349+
self,
350+
date_range: DateRange,
351+
hub_name: str | None = None,
352+
user_name: str | None = None,
353+
) -> list[dict]:
354+
try:
355+
response = self.query_user_groups(hub_name=hub_name, user_name=user_name)
356+
except requests.exceptions.RequestException as e:
357+
logger.exception(f"HTTP request failed: {e}")
358+
raise
359+
grouped = defaultdict(lambda: {"username": None, "hub": None})
360+
for entry in response:
361+
key = (entry["username"], entry["hub"])
362+
if grouped[key]["username"] is None:
363+
grouped[key]["username"] = entry["username"]
364+
grouped[key]["hub"] = entry["hub"]
365+
if entry["usergroup"] == "none":
366+
logger.debug(
367+
f"User {entry['username']} in hub {entry['hub']} has no groups."
368+
)
369+
grouped[key]["has_none"] = True
370+
else:
371+
grouped[key]["has_none"] = False
372+
result = [
373+
{"username": v["username"], "hub": v["hub"]}
374+
for v in grouped.values()
375+
if v["has_none"]
376+
]
377+
return result

0 commit comments

Comments
 (0)