|
| 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