Skip to content

feat: exporter prometheus_client /metrics (JEP-0013 Phase 2) - #934

Open
RoddieKieley wants to merge 2 commits into
jumpstarter-dev:mainfrom
RoddieKieley:jep-0013-phase2-exporter-metrics
Open

feat: exporter prometheus_client /metrics (JEP-0013 Phase 2)#934
RoddieKieley wants to merge 2 commits into
jumpstarter-dev:mainfrom
RoddieKieley:jep-0013-phase2-exporter-metrics

Conversation

@RoddieKieley

Copy link
Copy Markdown
  • 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.

- 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.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The exporter adds a local Prometheus registry, metrics HTTP server, driver and stream instrumentation, active-session tracking, and configurable CLI bind-address propagation.

Changes

Exporter metrics

Layer / File(s) Summary
Metrics registry and HTTP server
python/packages/jumpstarter/jumpstarter/metrics/*, python/packages/jumpstarter/pyproject.toml
Adds typed metrics for operations, errors, durations, streams, sessions, and exemplars. Serves OpenMetrics data at /metrics with configurable bind behavior.
Driver, session, and stream instrumentation
python/packages/jumpstarter/jumpstarter/driver/base.py, python/packages/jumpstarter/jumpstarter/exporter/session.py, python/packages/jumpstarter/jumpstarter/streams/common.py, python/packages/jumpstarter/jumpstarter/metrics/metrics_test.py
Records driver outcomes and durations, classifies failures, tracks active sessions, and records directional stream bytes. Tests cover registry behavior and integration wiring.
Metrics bind configuration
python/packages/jumpstarter-cli/jumpstarter_cli/run.py
Adds --metrics-bind-address, defaults it to :8080, supports 0 to disable metrics, and forwards it to the child process.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI as run command
  participant Child as exporter child
  participant Server as metrics HTTP server
  participant Registry as MetricsRegistry
  participant Driver as driver handler
  CLI->>Child: pass metrics_bind_address
  Child->>Server: start metrics server
  Server->>Registry: expose /metrics
  Driver->>Registry: record operations and stream bytes
  Registry-->>Server: return OpenMetrics data
Loading

Possibly related PRs

Suggested reviewers: mangelajo, bennyz

Poem

A rabbit counts each stream-byte hop,
And marks each driver’s start and stop.
The metrics burrow serves /metrics bright,
With bind-address knobs set just right.
Sessions rise, then gently fall—
Prometheus hears them all.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the addition of the exporter Prometheus metrics endpoint for JEP-0013 Phase 2.
Description check ✅ Passed The description directly explains the new exporter-local metrics registry, endpoint, exemplars, and core-path wiring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@@ -0,0 +1,17 @@
"""Exporter-local Prometheus metrics (JEP-0013 Phase 2)."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see we do use top level comments in some places, but i'd drop the JEP part at least, from the others as well

Comment on lines 239 to 248
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding except grpc.aio.AbortError: raise immediately before except Exception in both methods to let abort errors propagate without distorting metrics or status codes.

self,
*,
exporter: str,
operation: str,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using plain str means a typo at any call site silently creates a new Prometheus time series. Consider using Literal types for all bounded label parameters, e.g. result: Literal["success", "failure"].

Comment on lines 172 to 248
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,
"result": "failure", "error_type": "not_implemented"},
)
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,
"result": "failure", "error_type": "validation_error"},
)
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,
"result": "failure", "error_type": "timeout"},
)
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,
"result": "failure", "error_type": "connection_error"},
)
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,
"result": "failure", "error_type": "device_error"},
)
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding test drivers whose @export methods raise each exception type (e.g., TimeoutError), then assert the correct error_type label appears in the metrics output.

Comment on lines +70 to +73
try:
get_registry().inc_active_sessions(exporter=self.name, delta=-1.0)
except Exception:
pass

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log exception at WARNING or DEBUG level before suppressing it.

Comment on lines 114 to 149
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},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The operation label passed to _record_operation_metrics comes from request.method, a client-controlled gRPC field. When __lookup_drivercall rejects an unknown method, the resulting AbortError falls through to the catch-all handler, which records metrics with the untrusted value as a Prometheus label. Each unique method name creates a new time series, allowing an authenticated client to exhaust exporter memory by sending random method names. Fixing the AbortError handling (catching it before the generic handler) would largely mitigate this.

AI-generated, human reviewed

Comment on lines +14 to +36
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filter_exemplars, exemplars_from_log_context, and exporter_from_log_context contain non-trivial logic (filtering empty values, reading from structlog contextvars, defaulting to "unknown") but have no direct test coverage.

Comment on lines +126 to +133
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider parsing the exposition output or using get_sample_value() to verify jumpstarter_operations_total equals 1.0 for the specific label combination.

Comment on lines +117 to +139
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,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider moving the imports to module level or caching the resolved functions. Capture the exporter name and exemplars once before the stream loop.

)
self._errors = Counter(
"jumpstarter_operation_errors_total",
"Errors by class (timeout, device, …).",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider replacing ellipsis with ...

Comment on lines +114 to +121
) -> None:
if nbytes <= 0:
return
self._stream_bytes.labels(
exporter=exporter,
driver_type=driver_type,
direction=direction,
).inc(nbytes, exemplar=filter_exemplars(exemplars))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens to the metric increment for zero or negative byte counts?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory this should not happen, according to the guided review of the functionality anyway, as ..."the program deliberately does not record zero/negative amounts into the counter."

Re-raise grpc AbortError before generic handlers to avoid client-controlled
operation label cardinality, tighten bounded metric label types, default
metrics bind host to loopback, log session metric decrements on failure, and
expand exporter metrics unit coverage.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/packages/jumpstarter/jumpstarter/driver/base.py (1)

161-173: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Record unary success after response serialization.

encode_value(result) can raise after _record_operation_metrics(..., result="success") records success, then the generic handler records the same RPC as result="failure". Build the DriverCallResponse before recording success.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/packages/jumpstarter/jumpstarter/driver/base.py` around lines 161 -
173, The unary operation handler currently records success before response
serialization can fail. In the surrounding operation flow, construct the
DriverCallResponse and run encode_value(result) before calling
_record_operation_metrics with result="success"; keep the success log and return
after serialization succeeds so encoding failures are handled only as failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@python/packages/jumpstarter/jumpstarter/driver/base.py`:
- Around line 161-173: The unary operation handler currently records success
before response serialization can fail. In the surrounding operation flow,
construct the DriverCallResponse and run encode_value(result) before calling
_record_operation_metrics with result="success"; keep the success log and return
after serialization succeeds so encoding failures are handled only as failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 75605a6c-9f92-4070-b4a6-707a6f3fe110

📥 Commits

Reviewing files that changed from the base of the PR and between 7fb0364 and 1c23b95.

⛔ Files ignored due to path filters (1)
  • python/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • python/packages/jumpstarter-cli/jumpstarter_cli/run.py
  • python/packages/jumpstarter/jumpstarter/driver/base.py
  • python/packages/jumpstarter/jumpstarter/exporter/session.py
  • python/packages/jumpstarter/jumpstarter/metrics/__init__.py
  • python/packages/jumpstarter/jumpstarter/metrics/metrics_test.py
  • python/packages/jumpstarter/jumpstarter/metrics/registry.py
  • python/packages/jumpstarter/jumpstarter/metrics/server.py
  • python/packages/jumpstarter/jumpstarter/streams/common.py
  • python/packages/jumpstarter/pyproject.toml

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants