Prometheus metrics exporter feature (#60)#82
Conversation
Issue #60. Adds an optional `metrics` cargo feature (off by default) that wires Prometheus-style observability into the matching engine without affecting matching latency or determinism. This commit lands the feature scaffolding plus the reject-counter surface; depth and trade counters land in the next commit. Surface - `metrics = "0.24"` is the new optional dependency. - `feature = "metrics"` enables it. - New `orderbook::metrics` module with feature-gated helpers that compile to no-ops when the feature is disabled, so call-sites in the matching hot path stay unconditional. - `orderbook_rejects_total{reason="..."}` counter — incremented exactly once per rejection. Hooked at the single chokepoint `OrderBook::track_state` (every `OrderStatus::Rejected` transition emits) plus the cold rejection sites that bypass `track_state` (lot-size, min/max range, IOC/FOK insufficient-liquidity, market order on empty book, STP cancel-taker). The label value is the `RejectReason::Display` string. Constraints respected - Allocation-free on the happy path. - No influence on matching outcomes. - `restore_from_snapshot_package` does not rehydrate metric counters. - `#[deny(unsafe_code)]` still on; no new unsafe.
Issue #60. Adds the remaining two operational metrics on top of the reject counter from the previous commit, plus the integration test binary that exercises all three. Surface - `orderbook_depth_levels_bid` / `orderbook_depth_levels_ask` — gauges, current count of distinct bid / ask price levels. Hooked via a new `OrderBook::record_depth_metric` helper that reads `self.bids.len()` / `self.asks.len()` (`SkipMap::len` is O(1)) and forwards to `metrics::record_depth`. Refreshed on every structural mutation: `place_order_in_book`, `add_order` resting insertion, cancel-driven level removal, modify-path level removal, matching empty-level cleanup, and `cancel_all_orders` bulk drain. - `orderbook_trades_total` — counter, incremented exactly once per emitted `MatchResult` transaction. Hooked at all three trade emission sites (`match_market_order_with_user`, `match_limit_order_with_user`, `add_order` matching path). The counter increments independently of whether a `TradeListener` is configured. Testing - New dedicated test binary `tests/metrics/` (gated on `required-features = ["metrics"]`) with three integration tests: reject + trade counters, depth gauges across add / cancel, and a determinism guard that confirms metrics emission produces byte-identical snapshots. - The metrics tests live in their own binary so the broader integration suite under `tests/unit/` doesn't perturb the global recorder via routine `OrderBook` mutations.
Issue #60. Adds a runnable example that demonstrates the optional `metrics` feature end-to-end: 1. Installs the `metrics-exporter-prometheus` recorder. 2. Builds an `OrderBook<()>`, seeds resting depth, crosses a few limit orders to print trades, then engages the kill switch and submits one order to force a reject. 3. Calls `PrometheusHandle::render()` and prints the exposition payload to stdout, showing all four metric series: `orderbook_rejects_total{reason="..."}`, `orderbook_trades_total`, `orderbook_depth_levels_bid`, `orderbook_depth_levels_ask`. The example is gated behind `required-features = ["metrics"]` and the examples crate gains a feature pass-through: metrics = ["orderbook-rs/metrics", "dep:metrics", "dep:metrics-exporter-prometheus"] `metrics = "0.24"` and `metrics-exporter-prometheus = "0.18"` are the new optional dev-dependencies on the examples crate. They do not affect the library crate. Run with: cd examples cargo run --features metrics --bin prometheus_export
Issue #60. Closes the documentation surface for the new optional `metrics` feature. - `CHANGELOG.md` gets a new "Added — Prometheus metrics feature (#60)" subsection under the unreleased `0.7.0` umbrella with the full surface (counters, gauges, label semantics) and the determinism / no-op-when-disabled guarantees. - `src/lib.rs` gains a "v0.7.0 — Metrics and Observability (#60)" block in the crate-level "What's New" section. Cross-references the helper module, the example binary, and the test binary.
- module_inception: rename inner mod to `inner` - explicit_counter_loop: use `enumerate()` instead of manual seq counter - unused_doc_comment: replace `///` with `//` above proptest! macro - rustfmt: fold add_limit_order args onto one line in example
There was a problem hiding this comment.
Pull request overview
Adds a feature-gated Prometheus/OpenMetrics-style instrumentation layer to the order book so consumers can observe rejects, depth, and trades via the metrics facade without threading recorder logic through the matching engine.
Changes:
- Introduces
orderbook::metricsmodule with feature-gated counter/gauge emission helpers and wires calls into key mutation/matching paths. - Adds a dedicated
tests/metricsintegration test binary (feature-gated) to validate metrics behavior and determinism without global-recorder interference. - Adds a Prometheus exporter demo binary under
examples/, plus workspace feature/dependency wiring and docs/changelog updates.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
src/orderbook/metrics.rs |
Adds feature-gated helpers for rejects/depth/trades metrics. |
src/orderbook/mod.rs |
Exposes the new metrics module from orderbook. |
src/orderbook/private.rs |
Hooks reject metric emission into track_state and updates docs. |
src/orderbook/book.rs |
Adds record_depth_metric helper and emits trade-count metric before listener. |
src/orderbook/modifications.rs |
Updates depth gauges on structural changes; emits reject/trade metrics on relevant paths. |
src/orderbook/matching.rs |
Emits reject metric on insufficient-liquidity and STP paths; refreshes depth gauges after level removals. |
src/orderbook/mass_cancel.rs |
Refreshes depth gauges after mass-cancel clears the book. |
tests/metrics/mod.rs |
New feature-gated test entry point to isolate global metrics recorder. |
tests/metrics/metrics_tests.rs |
Implements capturing recorder + assertions for counters/gauges and determinism guard. |
tests/unit/replay_determinism.rs |
Clippy-driven refactor (module naming, loop style, comment style). |
tests/unit/metrics_tests.rs |
Adds a note about metrics tests moving (currently not compiled by the unit test harness). |
examples/src/bin/prometheus_export.rs |
Demo binary installing metrics-exporter-prometheus and dumping exposition output. |
examples/Cargo.toml |
Adds metrics feature and optional deps for the exporter demo binary. |
Cargo.toml |
Adds optional metrics dependency/feature and registers metrics_tests integration test binary. |
src/lib.rs |
Documents the new metrics feature in crate-level changelog/docs. |
CHANGELOG.md |
Adds an entry describing the new optional metrics feature and surface. |
Comments suppressed due to low confidence (1)
src/orderbook/matching.rs:91
- This market-order insufficient-liquidity early return emits the reject metric but does not record any
OrderStatustransition fororder_id. Sinceorder_idis available here, consider usingself.track_state(order_id, OrderStatus::Rejected { reason: RejectReason::InsufficientLiquidity })and lettingtrack_statehandle metric emission, so order-state tracking and metrics stay consistent.
// Early exit if the opposite side is empty
if match_side.is_empty() {
if limit_price.is_none() {
crate::orderbook::metrics::record_reject(
crate::orderbook::reject_reason::RejectReason::InsufficientLiquidity,
);
return Err(OrderBookError::InsufficientLiquidity {
side,
requested: quantity,
available: 0,
});
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| //! Metrics integration tests have moved to `tests/metrics/` so they | ||
| //! run in their own test binary, isolated from the broader integration | ||
| //! suite that perturbs the global `metrics` recorder as a side effect | ||
| //! of every `OrderBook` mutation. |
There was a problem hiding this comment.
This file lives under tests/unit/, but tests/unit/mod.rs does not declare a metrics_tests module, so this note won't be compiled or shown by the test harness. Consider removing this file or moving the note somewhere that’s actually built (e.g., tests/metrics/mod.rs or a README/doc).
| //! Metrics integration tests have moved to `tests/metrics/` so they | |
| //! run in their own test binary, isolated from the broader integration | |
| //! suite that perturbs the global `metrics` recorder as a side effect | |
| //! of every `OrderBook` mutation. |
There was a problem hiding this comment.
Removed the orphan note file. Notes on the test layout now live in tests/metrics/mod.rs.
| if let super::order_state::OrderStatus::Rejected { reason } = status { | ||
| super::metrics::record_reject(reason); | ||
| } | ||
| if let Some(ref tracker) = self.order_state_tracker { | ||
| tracker.transition(order_id, status); | ||
| } |
There was a problem hiding this comment.
track_state pattern-matches on status by value, which moves status (and potentially its fields) and then tries to pass status to tracker.transition afterward. OrderStatus is not Copy, so this should not compile. Match on &status (and copy reason) or otherwise avoid moving status before forwarding it to the tracker.
There was a problem hiding this comment.
Good catch on the readability — switched to &status and copy reason. It happened to compile because RejectReason is Copy, but the &status form is unambiguous.
| /// the happy path. | ||
| #[inline] | ||
| pub(super) fn record_depth_metric(&self) { | ||
| super::metrics::record_depth(self.bids.len() as u64, self.asks.len() as u64); | ||
| } | ||
|
|
There was a problem hiding this comment.
record_depth_metric always evaluates self.bids.len()/self.asks.len() even when metrics is disabled. If the goal is a compile-time no-op with zero overhead when the feature is off, consider providing #[cfg(not(feature="metrics"))] empty implementation of record_depth_metric (or gating the body) so the len computations are also compiled out.
| /// the happy path. | |
| #[inline] | |
| pub(super) fn record_depth_metric(&self) { | |
| super::metrics::record_depth(self.bids.len() as u64, self.asks.len() as u64); | |
| } | |
| /// the happy path. | |
| #[cfg(feature = "metrics")] | |
| #[inline] | |
| pub(super) fn record_depth_metric(&self) { | |
| super::metrics::record_depth(self.bids.len() as u64, self.asks.len() as u64); | |
| } | |
| /// Refresh the operational depth gauges with the current count | |
| /// of distinct bid / ask price levels. | |
| /// | |
| /// When the `metrics` feature is disabled, compile this hook to an | |
| /// empty function so all caller-side metric argument evaluation is | |
| /// removed as well. | |
| #[cfg(not(feature = "metrics"))] | |
| #[inline] | |
| pub(super) fn record_depth_metric(&self) {} |
There was a problem hiding this comment.
Applied the suggestion — split the helper with #[cfg(feature = "metrics")] so the bids.len() / asks.len() reads compile out as well when the feature is off.
| // Lot size validation: reject orders whose quantity is not a multiple of lot_size. | ||
| // For iceberg orders, validate visible and hidden quantities individually. | ||
| if let Some(lot) = self.lot_size | ||
| && lot > 0 | ||
| { | ||
| match &order { | ||
| OrderType::IcebergOrder { | ||
| visible_quantity, | ||
| hidden_quantity, | ||
| .. | ||
| } => { | ||
| if visible_quantity.as_u64() % lot != 0 { | ||
| crate::orderbook::metrics::record_reject(RejectReason::InvalidQuantity); | ||
| return Err(OrderBookError::InvalidLotSize { | ||
| quantity: visible_quantity.as_u64(), | ||
| lot_size: lot, | ||
| }); | ||
| } | ||
| if hidden_quantity.as_u64() % lot != 0 { | ||
| crate::orderbook::metrics::record_reject(RejectReason::InvalidQuantity); | ||
| return Err(OrderBookError::InvalidLotSize { | ||
| quantity: hidden_quantity.as_u64(), | ||
| lot_size: lot, | ||
| }); | ||
| } | ||
| } | ||
| _ => { | ||
| if order.total_quantity() % lot != 0 { | ||
| crate::orderbook::metrics::record_reject(RejectReason::InvalidQuantity); | ||
| return Err(OrderBookError::InvalidLotSize { | ||
| quantity: order.total_quantity(), | ||
| lot_size: lot, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Min/max order size validation | ||
| let qty = order.total_quantity(); | ||
| if let Some(min) = self.min_order_size | ||
| && qty < min | ||
| { | ||
| crate::orderbook::metrics::record_reject(RejectReason::OrderSizeOutOfRange); | ||
| return Err(OrderBookError::OrderSizeOutOfRange { | ||
| quantity: qty, |
There was a problem hiding this comment.
These validation rejects return early without recording an OrderStatus::Rejected transition (unlike tick-size, missing user_id, post-only, etc.). That contradicts the earlier comment that cold reject paths record OrderStatus::Rejected, and it also bypasses the central track_state-based reject metric emission. Consider calling self.track_state(order.id(), OrderStatus::Rejected { reason: ... }) before returning Err, and then you can drop the direct metrics::record_reject(...) calls here.
There was a problem hiding this comment.
Routed the lot-size and min/max-size rejects through track_state(Rejected{...}) so the order-state tracker and the reject metric stay in sync, and dropped the direct metrics::record_reject calls.
|
|
||
| /// All four call-sites must compile and run without panicking | ||
| /// regardless of feature state. The actual counter behaviour is | ||
| /// covered by `tests/unit/metrics_tests.rs` (feature-gated). |
There was a problem hiding this comment.
The comment in this test says counter behavior is covered by tests/unit/metrics_tests.rs, but those tests have moved to tests/metrics/. Update the reference so it points to the current location to avoid stale guidance.
| /// covered by `tests/unit/metrics_tests.rs` (feature-gated). | |
| /// covered by `tests/metrics/` (feature-gated). |
There was a problem hiding this comment.
Updated the comment to point at tests/metrics/.
| ); | ||
| trade_result.engine_seq = self.next_engine_seq(); | ||
| listener(&trade_result) // emit trade events to listener | ||
| let trades_emitted = match_result.trades().as_vec().len() as u64; |
There was a problem hiding this comment.
match_result.trades().len() is already used elsewhere in the repo; using it here avoids going through .as_vec() just to take the length (and avoids any risk that .as_vec() has extra work). Consider switching to match_result.trades().len() as u64 for the trade-count metric.
| let trades_emitted = match_result.trades().as_vec().len() as u64; | |
| let trades_emitted = match_result.trades().len() as u64; |
There was a problem hiding this comment.
Switched to match_result.trades().len().
| // transactions printed. The metric is independent of whether | ||
| // a listener is configured; the listener emission still gates | ||
| // on `Some(ref listener)`. | ||
| let trades_emitted = match_result.trades().as_vec().len() as u64; |
There was a problem hiding this comment.
match_result.trades().len() is already used elsewhere in the repo; using it here avoids going through .as_vec() just to take the length (and avoids any risk that .as_vec() has extra work). Consider switching to match_result.trades().len() as u64 for the trade-count metric.
| let trades_emitted = match_result.trades().as_vec().len() as u64; | |
| let trades_emitted = match_result.trades().len() as u64; |
There was a problem hiding this comment.
Switched to match_result.trades().len().
… route validation rejects through track_state
Summary
metricsfeature wires Prometheus-style counters and gauges into the matching engine via themetricscrate facade.orderbook_rejects_total{reason},orderbook_depth_levels_{bid,ask}gauges,orderbook_trades_totalcounter.examples/src/bin/prometheus_export.rsshows installingmetrics-exporter-prometheusand dumping the exposition payload.tests/metrics/cover reject counts, trade counts, depth gauges, and a determinism guard.tests/unit/replay_determinism.rs(module_inception, explicit_counter_loop, unused_doc_comment) inherited from main.Closes #60.
Test plan
cargo clippy --all-targets --all-features -- -D warningscargo fmt --all --checkcargo test --all-features— 1093 passed, 0 failedcargo test --features metrics --test metrics_tests— 3 passedcargo test --features alloc-counters --test alloc_budget— 1 passedcargo build --release