Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ tmp_reply.json
# Internal documentation (never commit)
.internalDoc/
/.windsurfrules
.full_engine_diff.patch
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and confined to the wrapper module; every `unsafe` block
delegates immediately to the inner allocator.

### Added — Prometheus metrics feature (#60)

- **New optional `metrics` feature flag** (default off). When
enabled, the matching core emits Prometheus-style counters and
gauges through the [`metrics`](https://docs.rs/metrics) crate's
global facade. Any compatible recorder (Prometheus exporter,
OpenTelemetry bridge, custom collector) can scrape them.
- **Surface (stable across `0.7.x`):**
- `orderbook_rejects_total{reason="..."}` — counter,
incremented exactly once per rejection. Label value is the
`RejectReason` `Display` string.
- `orderbook_depth_levels_bid` / `orderbook_depth_levels_ask`
— gauges, current count of distinct price levels per side,
refreshed on every add / cancel / modify / fill.
- `orderbook_trades_total` — counter, monotonic count of every
emitted trade transaction (one increment per `MatchResult`
transaction, summed across all listener-emitted and
internal-only matches).
- **Out-of-band emission.** Allocation-free on the happy path,
no influence on matching outcomes, no recorder dependency on
the core engine. `restore_from_snapshot_package` does **not**
rehydrate counters — operational only, process-lifetime.
- **Compile-time no-op when the feature is disabled.** Every
helper in `orderbook::metrics` compiles down to an empty
function so call-sites in the matching hot path stay
unconditional.
- **`metrics = "0.24"`** is the new optional dependency.
- Integration test `tests/metrics/` (its own test binary so the
global recorder isn't perturbed by the rest of the suite)
covers reject counts, trade counts, depth gauges, and a
determinism guard that proves metrics emission does not alter
byte-identical snapshots.
- Example `examples/src/bin/prometheus_export.rs` demonstrates
installing `metrics-exporter-prometheus` and dumping the
exposition payload.

### Added — HDR-histogram tail-latency bench suite (#56)

- **Six new bench binaries** under `benches/order_book/*_hdr.rs` that
Expand Down
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ bytes = { workspace = true, optional = true }
bincode = { workspace = true, optional = true }
crc32fast = { workspace = true, optional = true }
memmap2 = { workspace = true, optional = true }
metrics = { workspace = true, optional = true }

[features]
default = []
Expand All @@ -56,6 +57,7 @@ nats = ["dep:async-nats", "dep:bytes"]
bincode = ["dep:bincode"]
journal = ["dep:crc32fast", "dep:memmap2"]
alloc-counters = []
metrics = ["dep:metrics"]

[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
Expand Down Expand Up @@ -114,6 +116,11 @@ name = "alloc_budget"
path = "tests/alloc_budget.rs"
required-features = ["alloc-counters"]

[[test]]
name = "metrics_tests"
path = "tests/metrics/mod.rs"
required-features = ["metrics"]


[lib]
name = "orderbook_rs"
Expand Down Expand Up @@ -146,3 +153,4 @@ bytes = "1"
bincode = { version = "2.0", features = ["serde"] }
crc32fast = "1"
memmap2 = "0.9"
metrics = "0.24"
7 changes: 7 additions & 0 deletions examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ edition = "2024"
[features]
default = []
special_orders = ["orderbook-rs/special_orders"]
metrics = ["orderbook-rs/metrics", "dep:metrics", "dep:metrics-exporter-prometheus"]

[dependencies]
orderbook-rs = { workspace = true }
Expand All @@ -15,7 +16,13 @@ uuid = { workspace = true }
pricelevel = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
metrics = { version = "0.24", optional = true }
metrics-exporter-prometheus = { version = "0.18", optional = true, default-features = false }

[[bin]]
name = "special_orders_demo"
required-features = ["special_orders"]

[[bin]]
name = "prometheus_export"
required-features = ["metrics"]
111 changes: 111 additions & 0 deletions examples/src/bin/prometheus_export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// examples/src/bin/prometheus_export.rs
//
// Operator demo of the optional `metrics` feature (issue #60).
//
// Builds an OrderBook, runs a small mix of accepted and rejected
// flow, then dumps the recorded counters / gauges in Prometheus
// text format via `metrics-exporter-prometheus`.
//
// Run with:
//
// cd examples
// cargo run --features metrics --bin prometheus_export
//
// Expected on stdout: a Prometheus exposition payload containing
// * orderbook_rejects_total{reason="..."}
// * orderbook_depth_levels_bid / orderbook_depth_levels_ask
// * orderbook_trades_total

use metrics_exporter_prometheus::PrometheusBuilder;
use orderbook_rs::{OrderBook, OrderBookError};
use pricelevel::{Hash32, Id, Side, TimeInForce, setup_logger};
use tracing::{info, warn};

fn main() {
let _ = setup_logger();
info!("Prometheus export demo");

// Install the Prometheus recorder. `install_recorder` returns a
// handle whose `render()` method emits the current snapshot in
// Prometheus text exposition format. In production you'd serve
// that string over HTTP at /metrics.
let handle = PrometheusBuilder::new()
.install_recorder()
.expect("install Prometheus recorder");

let book = OrderBook::<()>::new("BTC/USD");

// 1. Seed both sides with limit orders.
seed_resting_book(&book);

// 2. Cross a couple of trades to bump `orderbook_trades_total`.
cross_some_trades(&book);

// 3. Trigger a few rejects to populate `orderbook_rejects_total`.
trigger_rejects(&book);

// 4. Render the Prometheus exposition payload.
let scrape = handle.render();
info!("--- Prometheus exposition (current snapshot) ---");
println!("{scrape}");
info!("--- end of exposition ---");
}

fn seed_resting_book(book: &OrderBook<()>) {
let user = Hash32::zero();

let resting: [(u128, u64, Side); 6] = [
(100, 5, Side::Buy),
(99, 8, Side::Buy),
(98, 3, Side::Buy),
(101, 5, Side::Sell),
(102, 6, Side::Sell),
(103, 4, Side::Sell),
];

for (price, qty, side) in resting {
if let Err(err) = book.add_limit_order_with_user(
Id::new_uuid(),
price,
qty,
side,
TimeInForce::Gtc,
user,
None,
) {
warn!("seed add failed: {err}");
}
}
}

fn cross_some_trades(book: &OrderBook<()>) {
// Aggressive buys against the resting asks.
for (limit, qty) in [(102u128, 4u64), (103, 3)] {
match book.add_limit_order(
Id::new_uuid(),
limit,
qty,
Side::Buy,
TimeInForce::Gtc,
None,
) {
Ok(_) => info!("aggressive buy filled at limit {limit} qty {qty}"),
Err(err) => warn!("aggressive buy failed: {err}"),
}
}
}

fn trigger_rejects(book: &OrderBook<()>) {
// Engage the kill switch to force a reject from the canonical
// taxonomy. Releases immediately so the book still serves the
// last metric render correctly.
book.engage_kill_switch();
let result = book.add_limit_order(Id::new_uuid(), 100, 1, Side::Buy, TimeInForce::Gtc, None);
match result {
Err(OrderBookError::KillSwitchActive) => {
info!("expected KillSwitchActive reject recorded as a metric")
}
other => warn!("unexpected reject result: {other:?}"),
}
book.release_kill_switch();
}
34 changes: 34 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,40 @@
//! regression detection.
//! - **`BENCH.md`** gains an "Allocation profile" section.
//!
//! ### v0.7.0 — Metrics and Observability (#60)
//!
//! - **New optional `metrics` feature** wires Prometheus-style
//! counters and gauges into the matching engine. Default `off`;
//! when enabled, every increment goes through the global
//! [`metrics`](https://docs.rs/metrics) facade so any compatible
//! recorder (Prometheus exporter, OpenTelemetry bridge, etc.)
//! can collect them.
//! - **Surface (stable across `0.7.x`):**
//! - `orderbook_rejects_total{reason="..."}` — counter, one
//! increment per rejected order. Label value is the
//! [`RejectReason`] [`Display`](std::fmt::Display) string.
//! - `orderbook_depth_levels_bid` /
//! `orderbook_depth_levels_ask` — gauges, current count of
//! distinct price levels on each side. Updated on every
//! structural mutation (add, cancel, modify, fill).
//! - `orderbook_trades_total` — counter, monotonic count of
//! every emitted trade transaction (one increment per
//! `MatchResult` transaction).
//! - **Determinism preserved.** Metrics emission is out-of-band:
//! no allocation on the happy path, no influence on matching
//! outcomes, and `restore_from_snapshot_package` deliberately
//! does **not** rehydrate counters — they are operational only
//! and live for the process lifetime. The integration test
//! `tests/metrics/` proves byte-identical snapshots between two
//! books with metrics enabled.
//! - **Compile-time no-op.** When the feature is off every helper
//! in [`orderbook::metrics`] compiles to an empty function so
//! call-sites in the matching hot path stay unconditional.
//! - Example: `examples/src/bin/prometheus_export.rs` (run with
//! `cargo run --features metrics --bin prometheus_export`)
//! demonstrates installing the `metrics-exporter-prometheus`
//! recorder and dumping the exposition payload.
//!
//! ### v0.7.0 — HDR-histogram tail-latency bench suite
//!
//! - **Six new `*_hdr` bench binaries** under
Expand Down
75 changes: 53 additions & 22 deletions src/orderbook/book.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,27 @@ where
self.engine_seq.load(Ordering::Acquire)
}

/// Refresh the operational depth gauges with the current count
/// of distinct bid / ask price levels.
///
/// Hooked from every structural mutation site so the published
/// gauge tracks the book's true level count without affecting
/// matching latency on the happy path.
///
/// When the `metrics` feature is disabled this compiles to an
/// empty function so the `bids.len()` / `asks.len()` reads are
/// also elided — every caller is a true zero-cost no-op.
#[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);
}

/// No-op variant when the `metrics` feature is disabled.
#[cfg(not(feature = "metrics"))]
#[inline]
pub(super) fn record_depth_metric(&self) {}

/// Engage the kill switch. While engaged, every public `submit_*`,
/// `add_order`, and non-cancel `update_order` call returns
/// [`OrderBookError::KillSwitchActive`] before any matching, fee,
Expand Down Expand Up @@ -2380,17 +2401,22 @@ where
let match_result =
OrderBook::<T>::match_order_with_user(self, order_id, side, quantity, None, user_id)?;

// Trigger trade listener if there are transactions
if !match_result.trades().as_vec().is_empty()
&& let Some(ref listener) = self.trade_listener
{
let mut trade_result = TradeResult::with_fees(
self.symbol.clone(),
match_result.clone(),
self.fee_schedule,
);
trade_result.engine_seq = self.next_engine_seq();
listener(&trade_result);
// Emit trade-count metric and trigger trade listener if any
// 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().len() as u64;
if trades_emitted > 0 {
super::metrics::record_trades(trades_emitted);
if let Some(ref listener) = self.trade_listener {
let mut trade_result = TradeResult::with_fees(
self.symbol.clone(),
match_result.clone(),
self.fee_schedule,
);
trade_result.engine_seq = self.next_engine_seq();
listener(&trade_result);
}
}

Ok(match_result)
Expand Down Expand Up @@ -2456,17 +2482,22 @@ where
user_id,
)?;

// Trigger trade listener if there are transactions
if !match_result.trades().as_vec().is_empty()
&& let Some(ref listener) = self.trade_listener
{
let mut trade_result = TradeResult::with_fees(
self.symbol.clone(),
match_result.clone(),
self.fee_schedule,
);
trade_result.engine_seq = self.next_engine_seq();
listener(&trade_result);
// Emit trade-count metric and trigger trade listener if any
// 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().len() as u64;
if trades_emitted > 0 {
super::metrics::record_trades(trades_emitted);
if let Some(ref listener) = self.trade_listener {
let mut trade_result = TradeResult::with_fees(
self.symbol.clone(),
match_result.clone(),
self.fee_schedule,
);
trade_result.engine_seq = self.next_engine_seq();
listener(&trade_result);
}
}

Ok(match_result)
Expand Down
2 changes: 2 additions & 0 deletions src/orderbook/mass_cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ where
self.special_order_tracker.clear();

self.cache.invalidate();
// Refresh the depth gauges; both sides are now empty.
self.record_depth_metric();

MassCancelResult {
cancelled_count,
Expand Down
Loading