Repository navigation
feat(energy): per-deployment energy consumption and environmental footprint stats #156
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
Open
jaimeib
wants to merge
22
commits into
master
Choose a base branch
from
feat/energy-footprint-stats
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
209a253
chore: ignore local energy-accounting store
jaimeib 9582134
refactor(wattnet): align with Wattnet API and tidy naming
jaimeib 5979dc7
feat(datacenters): add optional TUE column
jaimeib 972a645
feat(energy): per-deployment energy and footprint accounting engine
jaimeib c9f2ec4
feat(energy): run the accounting sweep as a background thread
jaimeib bd95edf
feat(energy): expose the energy block on the deployment and stats end…
jaimeib a32cecb
test(energy): pure-function, sweep-pipeline and read-API checks
jaimeib 520fb9c
docs(energy): architecture and dashboard-integration guides
jaimeib be007d1
fix(energy): correct IFCA TUE to 2.73 and decouple test from real config
jaimeib be235b8
feat(energy): serve real-time energy before the sweep has a doc
jaimeib 330e8af
ci: auto-format from pre-commit.com hooks
pre-commit-ci[bot] a70c7da
fix(energy): avoid log injection via unsanitized deployment/owner ids
jaimeib b965561
fix(energy): sanitize logged identifiers, not just reformat them
jaimeib 2d3050d
refactor(energy): rename accounting to energy and split the sweep by job
jaimeib 737cb96
fix(mimir): shorter request timeouts and a backoff when Mimir is down
jaimeib 302b293
fix(wattnet): time out requests and keep the last good data on failures
jaimeib 2fffd5f
test(energy): run the suite with pytest and cover the new behaviour
jaimeib 7b53213
docs(energy): update the architecture and API docs
jaimeib 66dd691
fix(energy): only accept plain names as namespace and deployment ids …
jaimeib aacef2b
chore(energy): mark the store write as a Sonar false positive
jaimeib 9fc4408
chore(energy): keep the Sonar annotation on the line of the write
jaimeib f13db08
fix(energy): write the store files with open() instead of Path.write_…
jaimeib File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| """ | ||
| Minimal client for the Grafana Mimir (Prometheus-compatible) HTTP API. | ||
|
|
||
| Used by the energy-accounting feature to read per-allocation power time series. | ||
| API reference: https://prometheus.io/docs/prometheus/latest/querying/api/ | ||
| """ | ||
|
|
||
| import datetime | ||
| import os | ||
| import time | ||
|
|
||
| import requests | ||
|
|
||
| MIMIR_URL = "https://mimir.k8s.cloud.ai4eosc.eu/prometheus" | ||
| # Optional: the energy-accounting feature is disabled if these are not set. | ||
| MIMIR_USER = os.environ.get("MIMIR_USER") | ||
| MIMIR_PASSWORD = os.environ.get("MIMIR_PASSWORD") | ||
| if not (MIMIR_USER and MIMIR_PASSWORD): | ||
| print("You should define MIMIR_USER / MIMIR_PASSWORD to enable energy stats") | ||
|
|
||
| # Mimir keeps each series under a point cap per query; stay well below it. | ||
| MAX_POINTS_PER_QUERY = 11_000 | ||
| # Measured against the real Mimir: a 24h range query takes under 1 s and a full | ||
| # 11,000-point chunk about 1.5 s, so these leave a wide margin while a hung Mimir | ||
| # does not hold a request or the sweep thread for a minute. | ||
|
|
||
| session = requests.Session() | ||
|
|
||
| # After a timeout or connection error Mimir is not asked again for this long (the | ||
| # calls fail fast with `MimirError`), so a hung Mimir costs one timeout per minute | ||
| # instead of one per query. | ||
| BACKOFF_SECONDS = 60 | ||
| _down_until = 0.0 | ||
|
|
||
|
|
||
| class MimirError(RuntimeError): | ||
| """Raised when Mimir returns a non-successful response.""" | ||
|
|
||
|
|
||
| def _auth(): | ||
| return (MIMIR_USER, MIMIR_PASSWORD) if MIMIR_USER else None | ||
|
|
||
|
|
||
| def _get(path: str, params: dict, timeout: int) -> list: | ||
| """GET a Prometheus API route and return `data.result`.""" | ||
| global _down_until | ||
| if time.monotonic() < _down_until: | ||
| raise MimirError("Mimir unreachable, retrying shortly") | ||
| try: | ||
| r = session.get( | ||
| f"{MIMIR_URL}{path}", params=params, auth=_auth(), timeout=timeout | ||
| ) | ||
| except (requests.ConnectionError, requests.Timeout): | ||
| _down_until = time.monotonic() + BACKOFF_SECONDS | ||
| raise | ||
| r.raise_for_status() | ||
| body = r.json() | ||
| if body.get("status") != "success": | ||
| raise MimirError(f"Mimir query failed: {body}") | ||
| return body.get("data", {}).get("result", []) | ||
|
|
||
|
|
||
| def query(promql: str, at: datetime.datetime | None = None, timeout: int = 10): | ||
| """ | ||
| Instantaneous query (`/api/v1/query`). Returns the raw `data.result` list | ||
| (vector: one entry per series, each with `metric` and `value` = [ts, str]). | ||
| """ | ||
| params = {"query": promql} | ||
| if at is not None: | ||
| params["time"] = at.timestamp() | ||
| return _get("/api/v1/query", params, timeout) | ||
|
|
||
|
|
||
| def query_range( | ||
| promql: str, | ||
| start: datetime.datetime, | ||
| end: datetime.datetime, | ||
| step_s: int = 30, | ||
| timeout: int = 15, | ||
| ): | ||
| """ | ||
| Range query (`/api/v1/query_range`). Returns the raw `data.result` list | ||
| (matrix: one entry per series, each with `metric` and `values` = | ||
| [[ts, str], ...]). | ||
| """ | ||
| params = { | ||
| "query": promql, | ||
| "start": start.timestamp(), | ||
| "end": end.timestamp(), | ||
| "step": f"{step_s}s", | ||
| } | ||
| return _get("/api/v1/query_range", params, timeout) | ||
|
|
||
|
|
||
| def _split_chunks(start, end, step_s, max_points): | ||
| """ | ||
| Yield non-overlapping (chunk_start, chunk_end) tuples so each range query | ||
| stays under `max_points` samples per series (relevant when backfilling a | ||
| deployment whose pointer is far behind). | ||
| """ | ||
| while start < end: | ||
| remaining_steps = int((end - start).total_seconds() / step_s) | ||
| chunk_steps = min(remaining_steps, max_points) | ||
| if chunk_steps <= 0: | ||
| break | ||
| chunk_end = start + datetime.timedelta(seconds=chunk_steps * step_s) | ||
| yield start, min(chunk_end, end) | ||
| start = chunk_end + datetime.timedelta(seconds=step_s) | ||
|
|
||
|
|
||
| def query_range_chunked( | ||
| promql: str, | ||
| start: datetime.datetime, | ||
| end: datetime.datetime, | ||
| step_s: int = 30, | ||
| max_points: int = MAX_POINTS_PER_QUERY, | ||
| ): | ||
| """ | ||
| Like `query_range` but splits long windows into consecutive chunks and | ||
| merges the results, concatenating each series' `values` sorted by timestamp. | ||
| """ | ||
| merged: dict = {} | ||
| for chunk_start, chunk_end in _split_chunks(start, end, step_s, max_points): | ||
| for series in query_range(promql, chunk_start, chunk_end, step_s): | ||
| key = frozenset(series.get("metric", {}).items()) | ||
| merged.setdefault(key, {"metric": series.get("metric", {}), "values": []}) | ||
| merged[key]["values"].extend(series.get("values", [])) | ||
|
|
||
| result = [] | ||
| for entry in merged.values(): | ||
| entry["values"].sort(key=lambda v: float(v[0])) | ||
| result.append(entry) | ||
| return result | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please move "mimir.py" into a "clients" folder?
This will help keep the code tidy when Saúl merges his litellm client