From f9e9e311ec46fe815001df9f478328afc3c76db2 Mon Sep 17 00:00:00 2001 From: Roddie Kieley Date: Wed, 29 Jul 2026 11:53:56 -0230 Subject: [PATCH] feat: exporter prometheus_client /metrics (JEP-0013 Phase 2) - Add exporter-local `prometheus_client` registry with JEP-named series: `jumpstarter_operations_total`, `jumpstarter_operation_duration_seconds`, `jumpstarter_operation_errors_total`, `jumpstarter_stream_bytes_total`, `jumpstarter_active_sessions`, plus exemplars (`client`, `lease_id`). - Expose HTTP `GET /metrics` on the exporter process for lab/dev scrape (same registry Phase 3 will later reverse-scrape via MetricsStream). - Minimal core-path wiring so series increment under test; full per-driver telemetry architecture remains Phase 4. --- .../jumpstarter-cli/jumpstarter_cli/run.py | 62 +++++- .../jumpstarter/jumpstarter/driver/base.py | 109 ++++++++++ .../jumpstarter/exporter/session.py | 14 +- .../jumpstarter/metrics/__init__.py | 17 ++ .../jumpstarter/metrics/metrics_test.py | 133 ++++++++++++ .../jumpstarter/metrics/registry.py | 144 +++++++++++++ .../jumpstarter/jumpstarter/metrics/server.py | 61 ++++++ .../jumpstarter/jumpstarter/streams/common.py | 50 ++++- python/packages/jumpstarter/pyproject.toml | 1 + python/uv.lock | 191 +++++++++--------- 10 files changed, 681 insertions(+), 101 deletions(-) create mode 100644 python/packages/jumpstarter/jumpstarter/metrics/__init__.py create mode 100644 python/packages/jumpstarter/jumpstarter/metrics/metrics_test.py create mode 100644 python/packages/jumpstarter/jumpstarter/metrics/registry.py create mode 100644 python/packages/jumpstarter/jumpstarter/metrics/server.py diff --git a/python/packages/jumpstarter-cli/jumpstarter_cli/run.py b/python/packages/jumpstarter-cli/jumpstarter_cli/run.py index a9cd88511..5da4ccb0b 100644 --- a/python/packages/jumpstarter-cli/jumpstarter_cli/run.py +++ b/python/packages/jumpstarter-cli/jumpstarter_cli/run.py @@ -70,7 +70,15 @@ def _reap_zombie_processes(capture_child=None): logger.warning(f"PARENT: Error during zombie reaping: {e}") -def _handle_child(config, parsed_bind=None, tls_insecure=False, tls_cert=None, tls_key=None, passphrase=None): # noqa: C901 +def _handle_child( # noqa: C901 + config, + parsed_bind=None, + tls_insecure=False, + tls_cert=None, + tls_key=None, + passphrase=None, + metrics_bind_address=":8080", +): """Handle child process with graceful shutdown.""" async def serve_with_graceful_shutdown(): # noqa: C901 received_signal = 0 @@ -99,6 +107,12 @@ async def signal_handler(): # Start signal handler immediately signal_tg.start_soon(signal_handler) + from jumpstarter.metrics import start_metrics_server + + listen_addr = start_metrics_server(metrics_bind_address) + if listen_addr: + logger.info("Serving metrics server at http://%s/metrics", listen_addr) + if parsed_bind is not None: host, port = parsed_bind tls_credentials = None @@ -204,7 +218,13 @@ def parent_signal_handler(signum, _): def _serve_with_exc_handling( - config, parsed_bind=None, tls_insecure=False, tls_cert=None, tls_key=None, passphrase=None + config, + parsed_bind=None, + tls_insecure=False, + tls_cert=None, + tls_key=None, + passphrase=None, + metrics_bind_address=":8080", ): max_rapid_failures = config.failure_detection.max_rapid_failures rapid_failure_window = config.failure_detection.rapid_failure_window @@ -253,7 +273,15 @@ def _serve_with_exc_handling( rapid_failure_count = 0 else: os.setsid() # Become group leader so all spawned subprocesses are reached by parent's signals - _handle_child(config, parsed_bind, tls_insecure, tls_cert, tls_key, passphrase) + _handle_child( + config, + parsed_bind, + tls_insecure, + tls_cert, + tls_key, + passphrase, + metrics_bind_address, + ) sys.exit(1) # should never happen @@ -294,8 +322,24 @@ def _serve_with_exc_handling( default=False, help="Exit after the current lease ends instead of waiting for a new one.", ) +@click.option( + "--metrics-bind-address", + "metrics_bind_address", + default=":8080", + show_default=True, + help="Address for HTTP GET /metrics (Prometheus/OpenMetrics). Use 0 to disable.", +) @handle_exceptions -def run(config, listener_bind, tls_insecure, tls_cert, tls_key, passphrase, exit_on_lease_end): +def run( + config, + listener_bind, + tls_insecure, + tls_cert, + tls_key, + passphrase, + exit_on_lease_end, + metrics_bind_address, +): """Run an exporter locally.""" if listener_bind is not None and config is None: raise click.UsageError("--exporter-config (or --exporter) is required when using --tls-grpc-listener") @@ -313,4 +357,12 @@ def run(config, listener_bind, tls_insecure, tls_cert, tls_key, passphrase, exit if exit_on_lease_end: config.exit_on_lease_end = True parsed_bind = _parse_listener_bind(listener_bind) if listener_bind is not None else None - return _serve_with_exc_handling(config, parsed_bind, tls_insecure, tls_cert, tls_key, passphrase) + return _serve_with_exc_handling( + config, + parsed_bind, + tls_insecure, + tls_cert, + tls_key, + passphrase, + metrics_bind_address, + ) diff --git a/python/packages/jumpstarter/jumpstarter/driver/base.py b/python/packages/jumpstarter/jumpstarter/driver/base.py index fec600923..769d3ce01 100644 --- a/python/packages/jumpstarter/jumpstarter/driver/base.py +++ b/python/packages/jumpstarter/jumpstarter/driver/base.py @@ -6,6 +6,7 @@ import logging import os +import time from abc import ABCMeta, abstractmethod from contextlib import asynccontextmanager from dataclasses import field @@ -113,11 +114,36 @@ def client(cls) -> str: def extra_labels(self) -> dict[str, str]: return {} + def _record_operation_metrics( + self, + *, + operation: str, + result: str, + duration_seconds: float, + error_type: str | None = None, + ) -> None: + from jumpstarter.metrics.registry import ( + exemplars_from_log_context, + exporter_from_log_context, + get_registry, + ) + + get_registry().record_operation( + exporter=exporter_from_log_context(default=self.name if hasattr(self, "name") else "unknown"), + operation=operation, + result=result, + driver_type=self.driver_type, + duration_seconds=duration_seconds, + exemplars=exemplars_from_log_context(), + error_type=error_type, + ) + async def DriverCall(self, request, context): """ :meta private: """ op = request.method + started = time.perf_counter() self.logger.info( "Operation started", extra={"operation": op, "driver_type": self.driver_type}, @@ -132,6 +158,11 @@ async def DriverCall(self, request, context): else: result = await to_thread.run_sync(method, *args) + self._record_operation_metrics( + operation=op, + result="success", + duration_seconds=time.perf_counter() - started, + ) self.logger.info( "Operation completed", extra={"operation": op, "driver_type": self.driver_type, "result": "success"}, @@ -141,6 +172,12 @@ async def DriverCall(self, request, context): result=encode_value(result), ) except NotImplementedError as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="not_implemented", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -148,6 +185,12 @@ async def DriverCall(self, request, context): ) await context.abort(StatusCode.UNIMPLEMENTED, str(e)) except ValueError as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="validation_error", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -155,6 +198,12 @@ async def DriverCall(self, request, context): ) await context.abort(StatusCode.INVALID_ARGUMENT, str(e)) except TimeoutError as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="timeout", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -162,6 +211,12 @@ async def DriverCall(self, request, context): ) await context.abort(StatusCode.DEADLINE_EXCEEDED, str(e)) except ConnectionError as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="connection_error", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -169,6 +224,12 @@ async def DriverCall(self, request, context): ) await context.abort(StatusCode.UNAVAILABLE, str(e)) except OSError as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="device_error", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -176,6 +237,12 @@ async def DriverCall(self, request, context): ) await context.abort(StatusCode.INTERNAL, str(e)) except Exception as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="internal_error", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -188,6 +255,7 @@ async def StreamingDriverCall(self, request, context): :meta private: """ op = request.method + started = time.perf_counter() self.logger.info( "Operation started", extra={"operation": op, "driver_type": self.driver_type}, @@ -209,11 +277,22 @@ async def StreamingDriverCall(self, request, context): uuid=str(uuid4()), result=encode_value(result), ) + self._record_operation_metrics( + operation=op, + result="success", + duration_seconds=time.perf_counter() - started, + ) self.logger.info( "Operation completed", extra={"operation": op, "driver_type": self.driver_type, "result": "success"}, ) except NotImplementedError as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="not_implemented", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -221,6 +300,12 @@ async def StreamingDriverCall(self, request, context): ) await context.abort(StatusCode.UNIMPLEMENTED, str(e)) except ValueError as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="validation_error", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -228,6 +313,12 @@ async def StreamingDriverCall(self, request, context): ) await context.abort(StatusCode.INVALID_ARGUMENT, str(e)) except TimeoutError as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="timeout", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -235,6 +326,12 @@ async def StreamingDriverCall(self, request, context): ) await context.abort(StatusCode.DEADLINE_EXCEEDED, str(e)) except ConnectionError as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="connection_error", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -242,6 +339,12 @@ async def StreamingDriverCall(self, request, context): ) await context.abort(StatusCode.UNAVAILABLE, str(e)) except OSError as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="device_error", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, @@ -249,6 +352,12 @@ async def StreamingDriverCall(self, request, context): ) await context.abort(StatusCode.INTERNAL, str(e)) except Exception as e: + self._record_operation_metrics( + operation=op, + result="failure", + duration_seconds=time.perf_counter() - started, + error_type="internal_error", + ) self.logger.warning( "Operation failed", extra={"operation": op, "driver_type": self.driver_type, diff --git a/python/packages/jumpstarter/jumpstarter/exporter/session.py b/python/packages/jumpstarter/jumpstarter/exporter/session.py index f19add60e..bde94e05c 100644 --- a/python/packages/jumpstarter/jumpstarter/exporter/session.py +++ b/python/packages/jumpstarter/jumpstarter/exporter/session.py @@ -57,11 +57,20 @@ class Session( @contextmanager def __contextmanager__(self) -> Generator[Self]: + from jumpstarter.logging import set_log_context + from jumpstarter.metrics import get_registry + logging.getLogger().addHandler(self._logging_handler) self.root_device.reset() + set_log_context(exporter=self.name) + get_registry().inc_active_sessions(exporter=self.name, delta=1.0) try: yield self finally: + try: + get_registry().inc_active_sessions(exporter=self.name, delta=-1.0) + except Exception: + pass try: self.root_device.close() except Exception as e: @@ -319,14 +328,15 @@ async def StreamingDriverCall(self, request, context): async def Stream(self, _request_iterator, context): request = StreamRequestMetadata(**dict(list(context.invocation_metadata()))).request logger.debug("Streaming(%s)", request) - async with self[request.uuid].Stream(request, context) as stream: + driver = self[request.uuid] + async with driver.Stream(request, context) as stream: metadata = [] with suppress(TypedAttributeLookupError): metadata.extend(stream.extra(MetadataStreamAttributes.metadata).items()) await context.send_initial_metadata(metadata) async with RouterStream(context=context) as remote: - async with forward_stream(remote, stream): + async with forward_stream(remote, stream, metrics_driver_type=driver.driver_type): event = Event() context.add_done_callback(lambda _: event.set()) await event.wait() diff --git a/python/packages/jumpstarter/jumpstarter/metrics/__init__.py b/python/packages/jumpstarter/jumpstarter/metrics/__init__.py new file mode 100644 index 000000000..bec5b06f3 --- /dev/null +++ b/python/packages/jumpstarter/jumpstarter/metrics/__init__.py @@ -0,0 +1,17 @@ +"""Exporter-local Prometheus metrics (JEP-0013 Phase 2).""" + +from .registry import ( + DEFAULT_EXEMPLAR_KEYS, + MetricsRegistry, + get_registry, + reset_registry_for_tests, +) +from .server import start_metrics_server + +__all__ = [ + "DEFAULT_EXEMPLAR_KEYS", + "MetricsRegistry", + "get_registry", + "reset_registry_for_tests", + "start_metrics_server", +] diff --git a/python/packages/jumpstarter/jumpstarter/metrics/metrics_test.py b/python/packages/jumpstarter/jumpstarter/metrics/metrics_test.py new file mode 100644 index 000000000..c68bd84e2 --- /dev/null +++ b/python/packages/jumpstarter/jumpstarter/metrics/metrics_test.py @@ -0,0 +1,133 @@ +"""JEP-0013 Phase 2 exporter metrics tests.""" + +from __future__ import annotations + +import re +import urllib.error +import urllib.request + +import pytest + +from jumpstarter.metrics import ( + DEFAULT_EXEMPLAR_KEYS, + get_registry, + reset_registry_for_tests, + start_metrics_server, +) + +SERIES = ( + "jumpstarter_operations_total", + "jumpstarter_operation_duration_seconds", + "jumpstarter_operation_errors_total", + "jumpstarter_stream_bytes_total", + "jumpstarter_active_sessions", +) + + +@pytest.fixture(autouse=True) +def _fresh_registry(): + reset_registry_for_tests() + yield + reset_registry_for_tests() + + +def test_default_exemplar_keys(): + assert DEFAULT_EXEMPLAR_KEYS == ("client", "lease_id") + + +def test_generate_latest_contains_named_series_after_increments(): + reg = get_registry() + exemplars = {"client": "ci-bot", "lease_id": "lease-abc"} + reg.record_operation( + exporter="lab-01", + operation="on", + result="success", + driver_type="power", + duration_seconds=0.05, + exemplars=exemplars, + ) + reg.record_operation( + exporter="lab-01", + operation="flash", + result="failure", + driver_type="storage", + duration_seconds=1.2, + exemplars=exemplars, + error_type="timeout", + ) + reg.add_stream_bytes( + exporter="lab-01", + driver_type="serial", + direction="tx", + nbytes=128, + exemplars=exemplars, + ) + reg.set_active_sessions(exporter="lab-01", value=1) + + body = reg.generate_latest().decode() + for name in SERIES: + assert name in body, f"expected series {name} in exposition" + + assert 'jumpstarter_operations_total{' in body or "jumpstarter_operations_total{" in body + assert 'exporter="lab-01"' in body + assert 'operation="on"' in body + assert 'result="success"' in body + assert 'driver_type="power"' in body + assert "jumpstarter_operation_duration_seconds_bucket" in body or ( + "jumpstarter_operation_duration_seconds_count" in body + ) + assert 'error_type="timeout"' in body + assert 'direction="tx"' in body + assert "jumpstarter_active_sessions" in body + + +def test_exemplars_include_client_and_lease_id(): + reg = get_registry() + reg.record_operation( + exporter="lab-01", + operation="on", + result="success", + driver_type="power", + duration_seconds=0.01, + exemplars={"client": "ci-bot", "lease_id": "lease-xyz"}, + ) + body = reg.generate_latest().decode() + # OpenMetrics exemplar form: # {client="...",lease_id="..."} + assert re.search(r'client="ci-bot"', body) + assert re.search(r'lease_id="lease-xyz"', body) + assert "# {" in body or " # {" in body + + +def test_metrics_http_endpoint_serves_prometheus_text(): + reg = get_registry() + reg.set_active_sessions(exporter="lab-01", value=2) + listen = start_metrics_server("127.0.0.1:0", registry=reg) + assert listen, "expected non-empty listen address" + + with urllib.request.urlopen(f"http://{listen}/metrics", timeout=2) as resp: + assert resp.status == 200 + body = resp.read().decode() + ctype = resp.headers.get("Content-Type", "") + assert "text/plain" in ctype or "openmetrics" in ctype + assert "jumpstarter_active_sessions" in body + + +def test_metrics_server_disabled_when_addr_zero(): + assert start_metrics_server("0") == "" + assert start_metrics_server("") == "" + + +def test_driver_call_increments_operations_and_active_sessions(): + """Minimal wiring: Session + DriverCall should bump named series.""" + from jumpstarter_driver_power.driver import MockPower + + from jumpstarter.common.utils import serve + + with serve(MockPower()) as client: + client.on() + body = get_registry().generate_latest().decode() + assert "jumpstarter_active_sessions" in body + assert "jumpstarter_operations_total" in body + assert 'driver_type="power"' in body + assert 'result="success"' in body + assert 'operation="on"' in body or 'operation="On"' in body diff --git a/python/packages/jumpstarter/jumpstarter/metrics/registry.py b/python/packages/jumpstarter/jumpstarter/metrics/registry.py new file mode 100644 index 000000000..85eecce38 --- /dev/null +++ b/python/packages/jumpstarter/jumpstarter/metrics/registry.py @@ -0,0 +1,144 @@ +"""Exporter-local Prometheus metrics registry (JEP-0013 Phase 2).""" + +from __future__ import annotations + +import structlog +from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram +from prometheus_client.openmetrics.exposition import generate_latest as generate_latest_openmetrics + +DEFAULT_EXEMPLAR_KEYS = ("client", "lease_id") + +_REGISTRY: MetricsRegistry | None = None + + +def filter_exemplars(exemplars: dict[str, str] | None) -> dict[str, str] | None: + """Keep only the JEP default exemplar keys with non-empty values.""" + if not exemplars: + return None + filtered = { + key: str(exemplars[key]) + for key in DEFAULT_EXEMPLAR_KEYS + if key in exemplars and exemplars[key] is not None and str(exemplars[key]) != "" + } + return filtered or None + + +def exemplars_from_log_context() -> dict[str, str] | None: + """Build default exemplars from the current structlog contextvars.""" + ctx = structlog.contextvars.get_contextvars() + return filter_exemplars({key: str(ctx[key]) for key in DEFAULT_EXEMPLAR_KEYS if key in ctx}) + + +def exporter_from_log_context(default: str = "unknown") -> str: + ctx = structlog.contextvars.get_contextvars() + value = ctx.get("exporter") + return str(value) if value else default + + +class MetricsRegistry: + """Process-local CollectorRegistry holding JEP-named exporter series.""" + + def __init__(self) -> None: + self._registry = CollectorRegistry() + self._operations = Counter( + "jumpstarter_operations_total", + "Total operations performed.", + ["exporter", "operation", "result", "driver_type"], + registry=self._registry, + ) + self._duration = Histogram( + "jumpstarter_operation_duration_seconds", + "Duration of each operation.", + ["exporter", "operation", "result", "driver_type"], + registry=self._registry, + ) + self._errors = Counter( + "jumpstarter_operation_errors_total", + "Errors by class (timeout, device, …).", + ["exporter", "operation", "driver_type", "error_type"], + registry=self._registry, + ) + self._stream_bytes = Counter( + "jumpstarter_stream_bytes_total", + "Bytes transferred (tx/rx) on streams.", + ["exporter", "driver_type", "direction"], + registry=self._registry, + ) + self._active_sessions = Gauge( + "jumpstarter_active_sessions", + "Currently active lease sessions.", + ["exporter"], + registry=self._registry, + ) + + @property + def collector_registry(self) -> CollectorRegistry: + return self._registry + + def record_operation( + self, + *, + exporter: str, + operation: str, + result: str, + driver_type: str, + duration_seconds: float, + exemplars: dict[str, str] | None = None, + error_type: str | None = None, + ) -> None: + labels = { + "exporter": exporter, + "operation": operation, + "result": result, + "driver_type": driver_type, + } + exemplar = filter_exemplars(exemplars) + self._operations.labels(**labels).inc(exemplar=exemplar) + self._duration.labels(**labels).observe(duration_seconds, exemplar=exemplar) + if result == "failure" and error_type: + self._errors.labels( + exporter=exporter, + operation=operation, + driver_type=driver_type, + error_type=error_type, + ).inc(exemplar=exemplar) + + def add_stream_bytes( + self, + *, + exporter: str, + driver_type: str, + direction: str, + nbytes: int, + exemplars: dict[str, str] | None = None, + ) -> None: + if nbytes <= 0: + return + self._stream_bytes.labels( + exporter=exporter, + driver_type=driver_type, + direction=direction, + ).inc(nbytes, exemplar=filter_exemplars(exemplars)) + + def set_active_sessions(self, *, exporter: str, value: float) -> None: + self._active_sessions.labels(exporter=exporter).set(value) + + def inc_active_sessions(self, *, exporter: str, delta: float = 1.0) -> None: + self._active_sessions.labels(exporter=exporter).inc(delta) + + def generate_latest(self) -> bytes: + return generate_latest_openmetrics(self._registry) + + +def get_registry() -> MetricsRegistry: + global _REGISTRY + if _REGISTRY is None: + _REGISTRY = MetricsRegistry() + return _REGISTRY + + +def reset_registry_for_tests() -> MetricsRegistry: + """Replace the process-wide registry (tests only).""" + global _REGISTRY + _REGISTRY = MetricsRegistry() + return _REGISTRY diff --git a/python/packages/jumpstarter/jumpstarter/metrics/server.py b/python/packages/jumpstarter/jumpstarter/metrics/server.py new file mode 100644 index 000000000..0d9d266f8 --- /dev/null +++ b/python/packages/jumpstarter/jumpstarter/metrics/server.py @@ -0,0 +1,61 @@ +"""HTTP GET /metrics server for exporter-local Prometheus scrape.""" + +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread + +from prometheus_client.openmetrics.exposition import CONTENT_TYPE_LATEST + +from .registry import MetricsRegistry, get_registry + + +def _parse_bind_addr(addr: str) -> tuple[str, int]: + """Parse bind address forms: ':8080', '127.0.0.1:0', '8080'.""" + if ":" in addr: + host, _, port_s = addr.rpartition(":") + if host == "": + host = "0.0.0.0" + else: + host = "0.0.0.0" + port_s = addr + return host, int(port_s) + + +def start_metrics_server(addr: str, registry: MetricsRegistry | None = None) -> str: + """Start an HTTP server exposing GET /metrics. + + addr \"0\" or empty disables the server and returns \"\". + addr ending with \":0\" binds an ephemeral port; the returned listen address + is host:port suitable for urllib/http.Get. + """ + if addr == "" or addr == "0": + return "" + + reg = registry if registry is not None else get_registry() + host, port = _parse_bind_addr(addr) + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + if self.path.split("?", 1)[0] != "/metrics": + self.send_error(404) + return + body = reg.generate_latest() + self.send_response(200) + self.send_header("Content-Type", CONTENT_TYPE_LATEST) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt: str, *args) -> None: + return + + server = ThreadingHTTPServer((host, port), Handler) + thread = Thread(target=server.serve_forever, name="jumpstarter-metrics", daemon=True) + thread.start() + + bound_host, bound_port = server.server_address[:2] + # Prefer loopback-friendly host for ephemeral binds used in tests. + if bound_host in ("0.0.0.0", "::", ""): + bound_host = "127.0.0.1" + return f"{bound_host}:{bound_port}" diff --git a/python/packages/jumpstarter/jumpstarter/streams/common.py b/python/packages/jumpstarter/jumpstarter/streams/common.py index 40e650ca5..4121eccac 100644 --- a/python/packages/jumpstarter/jumpstarter/streams/common.py +++ b/python/packages/jumpstarter/jumpstarter/streams/common.py @@ -14,9 +14,29 @@ logger = logging.getLogger(__name__) -async def copy_stream(dst: AnyByteStream, src: AnyByteStream): +async def copy_stream( + dst: AnyByteStream, + src: AnyByteStream, + *, + metrics_direction: str | None = None, + metrics_driver_type: str = "other", +): + from jumpstarter.metrics.registry import ( + exemplars_from_log_context, + exporter_from_log_context, + get_registry, + ) + try: async for v in src: + if metrics_direction is not None: + get_registry().add_stream_bytes( + exporter=exporter_from_log_context(), + driver_type=metrics_driver_type, + direction=metrics_direction, + nbytes=len(v) if isinstance(v, (bytes, bytearray, memoryview)) else 0, + exemplars=exemplars_from_log_context(), + ) await dst.send(v) with suppress( AttributeError, @@ -37,11 +57,33 @@ async def copy_stream(dst: AnyByteStream, src: AnyByteStream): @asynccontextmanager -async def forward_stream(a, b): +async def forward_stream(a, b, *, metrics_driver_type: str | None = None): + from functools import partial + async with a, b: async with create_task_group() as tg: - tg.start_soon(copy_stream, a, b) - tg.start_soon(copy_stream, b, a) + if metrics_driver_type is None: + tg.start_soon(copy_stream, a, b) + tg.start_soon(copy_stream, b, a) + else: + tg.start_soon( + partial( + copy_stream, + a, + b, + metrics_direction="tx", + metrics_driver_type=metrics_driver_type, + ) + ) + tg.start_soon( + partial( + copy_stream, + b, + a, + metrics_direction="rx", + metrics_driver_type=metrics_driver_type, + ) + ) yield diff --git a/python/packages/jumpstarter/pyproject.toml b/python/packages/jumpstarter/pyproject.toml index d7bf015aa..78ee9d6ff 100644 --- a/python/packages/jumpstarter/pyproject.toml +++ b/python/packages/jumpstarter/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "backports-zstd>=1.1.0 ; python_full_version < '3.14'", "click>=8.1.7.2", "structlog>=24.1.0", + "prometheus-client>=0.21.0", ] [dependency-groups] diff --git a/python/uv.lock b/python/uv.lock index 1fa9a180c..363b9c110 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -268,7 +268,7 @@ version = "25.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argon2-cffi-bindings", version = "21.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, - { name = "argon2-cffi-bindings", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "argon2-cffi-bindings", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } wheels = [ @@ -283,7 +283,7 @@ resolution-markers = [ "python_full_version >= '3.14'", ] dependencies = [ - { name = "cffi", marker = "python_full_version >= '3.14'" }, + { name = "cffi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/e9/184b8ccce6683b0aa2fbb7ba5683ea4b9c5763f1356347f1312c32e3c66e/argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3", size = 1779911, upload-time = "2021-12-01T08:52:55.68Z" } wheels = [ @@ -307,7 +307,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "cffi", marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "cffi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } wheels = [ @@ -1106,7 +1106,7 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "cffi", marker = "python_full_version < '3.12' and platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/d6/1411ab4d6108ab167d06254c5be517681f1e331f90edf1379895bcb87020/cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053", size = 711096, upload-time = "2025-05-02T19:36:04.667Z" } wheels = [ @@ -1151,7 +1151,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "cffi", marker = "python_full_version >= '3.12' and platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload-time = "2025-06-10T00:03:51.297Z" } wheels = [ @@ -1378,11 +1378,11 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "blinker", marker = "python_full_version < '3.12'" }, - { name = "click", marker = "python_full_version < '3.12'" }, - { name = "itsdangerous", marker = "python_full_version < '3.12'" }, - { name = "jinja2", marker = "python_full_version < '3.12'" }, - { name = "werkzeug", marker = "python_full_version < '3.12'" }, + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "werkzeug" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824, upload-time = "2024-11-13T18:24:38.127Z" } wheels = [ @@ -1398,12 +1398,12 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "blinker", marker = "python_full_version >= '3.12'" }, - { name = "click", marker = "python_full_version >= '3.12'" }, - { name = "itsdangerous", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "markupsafe", marker = "python_full_version >= '3.12'" }, - { name = "werkzeug", marker = "python_full_version >= '3.12'" }, + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } wheels = [ @@ -1656,8 +1656,8 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "hpack", marker = "python_full_version < '3.12'" }, - { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "hpack" }, + { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/2a/32/fec683ddd10629ea4ea46d206752a95a2d8a48c22521edd70b142488efe1/h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb", size = 2145593, upload-time = "2021-10-05T18:27:47.18Z" } wheels = [ @@ -1673,8 +1673,8 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "hpack", marker = "python_full_version >= '3.12'" }, - { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "hpack" }, + { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } wheels = [ @@ -1753,8 +1753,8 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "certifi", marker = "python_full_version < '3.12'" }, - { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "certifi" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/45/ad3e1b4d448f22c0cff4f5692f5ed0666658578e358b8d58a19846048059/httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad", size = 85385, upload-time = "2025-04-11T14:42:46.661Z" } wheels = [ @@ -1770,8 +1770,8 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.12'" }, - { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "certifi" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ @@ -1967,6 +1967,7 @@ dependencies = [ { name = "backports-zstd", marker = "python_full_version < '3.14'" }, { name = "click" }, { name = "jumpstarter-protocol" }, + { name = "prometheus-client" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyyaml" }, @@ -1997,6 +1998,7 @@ requires-dist = [ { name = "backports-zstd", marker = "python_full_version < '3.14'", specifier = ">=1.1.0" }, { name = "click", specifier = ">=8.1.7.2" }, { name = "jumpstarter-protocol", editable = "packages/jumpstarter-protocol" }, + { name = "prometheus-client", specifier = ">=0.21.0" }, { name = "pydantic", specifier = ">=2.8.2" }, { name = "pydantic-settings", specifier = ">=2.9.1" }, { name = "pyyaml", specifier = ">=6.0.2" }, @@ -4278,31 +4280,31 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "aioquic", marker = "python_full_version < '3.12'" }, - { name = "asgiref", version = "3.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "brotli", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "certifi", marker = "python_full_version < '3.12'" }, - { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "flask", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "h2", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "kaitaistruct", version = "0.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "ldap3", marker = "python_full_version < '3.12'" }, - { name = "mitmproxy-rs", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "msgpack", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "passlib", marker = "python_full_version < '3.12'" }, - { name = "publicsuffix2", marker = "python_full_version < '3.12'" }, - { name = "pydivert", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, - { name = "pyopenssl", version = "24.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "pyparsing", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "pyperclip", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "ruamel-yaml", version = "0.18.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "sortedcontainers", marker = "python_full_version < '3.12'" }, - { name = "tornado", version = "6.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "urwid", version = "2.6.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "wsproto", marker = "python_full_version < '3.12'" }, - { name = "zstandard", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "aioquic" }, + { name = "asgiref", version = "3.8.1", source = { registry = "https://pypi.org/simple" } }, + { name = "brotli", version = "1.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "certifi" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" } }, + { name = "flask", version = "3.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" } }, + { name = "h2", version = "4.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "hyperframe", version = "6.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "kaitaistruct", version = "0.10", source = { registry = "https://pypi.org/simple" } }, + { name = "ldap3" }, + { name = "mitmproxy-rs", version = "0.10.7", source = { registry = "https://pypi.org/simple" } }, + { name = "msgpack", version = "1.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "passlib" }, + { name = "publicsuffix2" }, + { name = "pydivert", marker = "sys_platform == 'win32'" }, + { name = "pyopenssl", version = "24.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pyparsing", version = "3.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pyperclip", version = "1.9.0", source = { registry = "https://pypi.org/simple" } }, + { name = "ruamel-yaml", version = "0.18.6", source = { registry = "https://pypi.org/simple" } }, + { name = "sortedcontainers" }, + { name = "tornado", version = "6.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "urwid", version = "2.6.16", source = { registry = "https://pypi.org/simple" } }, + { name = "wsproto" }, + { name = "zstandard", version = "0.23.0", source = { registry = "https://pypi.org/simple" } }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/66/88/5f503d5dd63aa8e0e6d788380e8e8b5d172b682eb5770da625bf70a5f0a7/mitmproxy-11.0.2-py3-none-any.whl", hash = "sha256:95db7b57b21320a0c76e59e1d6644daaa431291cdf89419608301424651199b4", size = 1658730, upload-time = "2024-12-05T09:38:10.269Z" }, @@ -4317,33 +4319,33 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "aioquic", marker = "python_full_version >= '3.12'" }, - { name = "argon2-cffi", marker = "python_full_version >= '3.12'" }, - { name = "asgiref", version = "3.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "bcrypt", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "brotli", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "certifi", marker = "python_full_version >= '3.12'" }, - { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "flask", version = "3.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "h2", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "kaitaistruct", version = "0.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "ldap3", marker = "python_full_version >= '3.12'" }, - { name = "mitmproxy-rs", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "msgpack", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "publicsuffix2", marker = "python_full_version >= '3.12'" }, - { name = "pydivert", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "pyopenssl", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pyparsing", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pyperclip", version = "1.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "ruamel-yaml", version = "0.18.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "sortedcontainers", marker = "python_full_version >= '3.12'" }, - { name = "tornado", version = "6.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, - { name = "urwid", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "wsproto", marker = "python_full_version >= '3.12'" }, - { name = "zstandard", version = "0.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "aioquic" }, + { name = "argon2-cffi" }, + { name = "asgiref", version = "3.10.0", source = { registry = "https://pypi.org/simple" } }, + { name = "bcrypt", version = "5.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "brotli", version = "1.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "certifi" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" } }, + { name = "flask", version = "3.1.2", source = { registry = "https://pypi.org/simple" } }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" } }, + { name = "h2", version = "4.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "hyperframe", version = "6.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "kaitaistruct", version = "0.11", source = { registry = "https://pypi.org/simple" } }, + { name = "ldap3" }, + { name = "mitmproxy-rs", version = "0.12.9", source = { registry = "https://pypi.org/simple" } }, + { name = "msgpack", version = "1.1.1", source = { registry = "https://pypi.org/simple" } }, + { name = "publicsuffix2" }, + { name = "pydivert", marker = "sys_platform == 'win32'" }, + { name = "pyopenssl", version = "25.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pyparsing", version = "3.2.5", source = { registry = "https://pypi.org/simple" } }, + { name = "pyperclip", version = "1.11.0", source = { registry = "https://pypi.org/simple" } }, + { name = "ruamel-yaml", version = "0.18.16", source = { registry = "https://pypi.org/simple" } }, + { name = "sortedcontainers" }, + { name = "tornado", version = "6.5.2", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "urwid", version = "3.0.3", source = { registry = "https://pypi.org/simple" } }, + { name = "wsproto" }, + { name = "zstandard", version = "0.25.0", source = { registry = "https://pypi.org/simple" } }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/54/d4/2acc254beec19403269652ead42735c98baf6d56d060ef9dfe34256bda22/mitmproxy-12.2.1-py3-none-any.whl", hash = "sha256:7a508cc9fb906253eb26460d99b3572bf5a7b4a185ab62534379ac1915677dd2", size = 1650400, upload-time = "2025-11-24T19:01:11.712Z" }, @@ -4390,8 +4392,8 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "mitmproxy-macos", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'darwin'" }, - { name = "mitmproxy-windows", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and os_name == 'nt'" }, + { name = "mitmproxy-macos", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, + { name = "mitmproxy-windows", version = "0.10.7", source = { registry = "https://pypi.org/simple" }, marker = "os_name == 'nt'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/64/114311494f8fb689343ce348b7f046bbc67a88247ffc655dc4c3440286fb/mitmproxy_rs-0.10.7.tar.gz", hash = "sha256:0959a540766403222464472b64122ac8ccbca66b5f019154496b98e62482277f", size = 1183834, upload-time = "2024-10-28T11:56:39.622Z" } wheels = [ @@ -4410,9 +4412,9 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "mitmproxy-linux", marker = "python_full_version >= '3.12' and sys_platform == 'linux'" }, - { name = "mitmproxy-macos", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform == 'darwin'" }, - { name = "mitmproxy-windows", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and os_name == 'nt'" }, + { name = "mitmproxy-linux", marker = "sys_platform == 'linux'" }, + { name = "mitmproxy-macos", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" }, + { name = "mitmproxy-windows", version = "0.12.9", source = { registry = "https://pypi.org/simple" }, marker = "os_name == 'nt'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/5c/16a61303da76cd34aa6ddbd7ef6ac66d9ef8514c4d3a5b71831169d63236/mitmproxy_rs-0.12.9.tar.gz", hash = "sha256:c6ffc35c002c675cac534442d92d1cdebd66fafd63754ad33b92ae968ea6e449", size = 1334424, upload-time = "2026-01-30T14:54:15.043Z" } wheels = [ @@ -4964,6 +4966,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + [[package]] name = "propcache" version = "0.3.2" @@ -5353,7 +5364,7 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "cryptography", version = "44.0.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/d4/1067b82c4fc674d6f6e9e8d26b3dff978da46d351ca3bac171544693e085/pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36", size = 178944, upload-time = "2024-11-27T20:43:12.755Z" } wheels = [ @@ -5369,8 +5380,8 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version == '3.12.*'" }, + { name = "cryptography", version = "45.0.4", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937, upload-time = "2025-05-17T16:28:31.31Z" } wheels = [ @@ -6036,7 +6047,7 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "ruamel-yaml-clib", marker = "python_full_version < '3.12' and platform_python_implementation == 'CPython'" }, + { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/81/4dfc17eb6ebb1aac314a3eb863c1325b907863a1b8b1382cdffcb6ac0ed9/ruamel.yaml-0.18.6.tar.gz", hash = "sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b", size = 143362, upload-time = "2024-02-07T06:47:20.283Z" } wheels = [ @@ -6052,7 +6063,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "ruamel-yaml-clib", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/c7/ee630b29e04a672ecfc9b63227c87fd7a37eb67c1bf30fe95376437f897c/ruamel.yaml-0.18.16.tar.gz", hash = "sha256:a6e587512f3c998b2225d68aa1f35111c29fad14aed561a26e73fab729ec5e5a", size = 147269, upload-time = "2025-10-22T17:54:02.346Z" } wheels = [ @@ -6772,8 +6783,8 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, - { name = "wcwidth", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions" }, + { name = "wcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/21/ad23c9e961b2d36d57c63686a6f86768dd945d406323fb58c84f09478530/urwid-2.6.16.tar.gz", hash = "sha256:93ad239939e44c385e64aa00027878b9e5c486d59e855ec8ab5b1e1adcdb32a2", size = 848179, upload-time = "2024-10-15T16:07:24.297Z" } wheels = [ @@ -6789,7 +6800,7 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.14'", ] dependencies = [ - { name = "wcwidth", marker = "python_full_version >= '3.12'" }, + { name = "wcwidth" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/d3/09683323e2290732a39dc92ca5031d5e5ddda56f8d236f885a400535b29a/urwid-3.0.3.tar.gz", hash = "sha256:300804dd568cda5aa1c5b204227bd0cfe7a62cef2d00987c5eb2e4e64294ed9b", size = 855817, upload-time = "2025-09-15T10:26:17.089Z" } wheels = [ @@ -7329,7 +7340,7 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "cffi", marker = "python_full_version < '3.12' and platform_python_implementation == 'PyPy'" }, + { name = "cffi", marker = "platform_python_implementation == 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701, upload-time = "2024-07-15T00:18:06.141Z" } wheels = [