Skip to content

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

Open
RoddieKieley wants to merge 1 commit 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 1 commit 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

Warning

Review limit reached

@RoddieKieley, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8ad52e96-7928-45ce-adf8-30ec55f287c9

📥 Commits

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

⛔ 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

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?

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