Skip to content

Prometheus metrics exporter feature (#60)#82

Merged
joaquinbejar merged 7 commits intomainfrom
issue-60-metrics
Apr 25, 2026
Merged

Prometheus metrics exporter feature (#60)#82
joaquinbejar merged 7 commits intomainfrom
issue-60-metrics

Conversation

@joaquinbejar
Copy link
Copy Markdown
Owner

Summary

  • New optional metrics feature wires Prometheus-style counters and gauges into the matching engine via the metrics crate facade.
  • Surface: orderbook_rejects_total{reason}, orderbook_depth_levels_{bid,ask} gauges, orderbook_trades_total counter.
  • Compile-time no-op when the feature is off — call sites in the matching hot path stay unconditional.
  • Determinism preserved: out-of-band emission, no allocation on the happy path, no influence on matching outcomes, snapshot rehydration deliberately skips counters.
  • Example examples/src/bin/prometheus_export.rs shows installing metrics-exporter-prometheus and dumping the exposition payload.
  • Integration tests under tests/metrics/ cover reject counts, trade counts, depth gauges, and a determinism guard.
  • Includes a clippy fix for 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 warnings
  • cargo fmt --all --check
  • cargo test --all-features — 1093 passed, 0 failed
  • cargo test --features metrics --test metrics_tests — 3 passed
  • cargo test --features alloc-counters --test alloc_budget — 1 passed
  • cargo build --release

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

Copilot AI left a comment

Choose a reason for hiding this comment

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

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::metrics module with feature-gated counter/gauge emission helpers and wires calls into key mutation/matching paths.
  • Adds a dedicated tests/metrics integration 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 OrderStatus transition for order_id. Since order_id is available here, consider using self.track_state(order_id, OrderStatus::Rejected { reason: RejectReason::InsufficientLiquidity }) and letting track_state handle 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.

Comment thread tests/unit/metrics_tests.rs Outdated
Comment on lines +1 to +4
//! 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.
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
//! 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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Removed the orphan note file. Notes on the test layout now live in tests/metrics/mod.rs.

Comment thread src/orderbook/private.rs Outdated
Comment on lines 147 to 152
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);
}
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment thread src/orderbook/book.rs Outdated
Comment on lines +499 to +504
/// 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);
}

Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
/// 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) {}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment on lines 660 to 705
// 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,
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment thread src/orderbook/metrics.rs Outdated

/// 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).
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
/// covered by `tests/unit/metrics_tests.rs` (feature-gated).
/// covered by `tests/metrics/` (feature-gated).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Updated the comment to point at tests/metrics/.

Comment thread src/orderbook/modifications.rs Outdated
);
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;
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
let trades_emitted = match_result.trades().as_vec().len() as u64;
let trades_emitted = match_result.trades().len() as u64;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Switched to match_result.trades().len().

Comment thread src/orderbook/book.rs Outdated
// 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;
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
let trades_emitted = match_result.trades().as_vec().len() as u64;
let trades_emitted = match_result.trades().len() as u64;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Switched to match_result.trades().len().

@joaquinbejar joaquinbejar merged commit ae7a753 into main Apr 25, 2026
13 checks passed
@joaquinbejar joaquinbejar deleted the issue-60-metrics branch April 25, 2026 12:55
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.

Prometheus / OpenMetrics exporter (feature = metrics)

2 participants