This is an automated email from the ASF dual-hosted git repository.

leekeiabstraction pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 3187eb47 chore: [metrics] Add client metrics and prometheus example 
(#617)
3187eb47 is described below

commit 3187eb47d7e8e5a5640e4308e810771ce189ca6c
Author: Kaiqi Dong <[email protected]>
AuthorDate: Wed Jun 17 23:56:50 2026 +0200

    chore: [metrics] Add client metrics and prometheus example (#617)
    
    * Add client metrics and prometheus example
    
    * improvements
    
    * address reviews
---
 Cargo.lock                                        |  38 ++++-
 README.md                                         |   4 +-
 crates/examples/Cargo.toml                        |   6 +
 crates/examples/src/example_prometheus_metrics.rs | 138 +++++++++++++++++
 website/docs/user-guide/rust/metrics.md           | 179 ++++++++++++++++++++++
 5 files changed, 356 insertions(+), 9 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index bdcc1046..a8798faa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -70,7 +70,7 @@ version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
 dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -81,7 +81,7 @@ checksum = 
"291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
 dependencies = [
  "anstyle",
  "once_cell_polyfill",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -1014,7 +1014,7 @@ source = 
"registry+https://github.com/rust-lang/crates.io-index";
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -1096,6 +1096,8 @@ version = "0.2.0"
 dependencies = [
  "clap",
  "fluss-rs",
+ "metrics",
+ "metrics-exporter-prometheus",
  "tikv-jemallocator",
  "tokio",
 ]
@@ -1835,7 +1837,7 @@ dependencies = [
  "portable-atomic-util",
  "serde_core",
  "wasm-bindgen",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -2084,6 +2086,26 @@ dependencies = [
  "rapidhash",
 ]
 
+[[package]]
+name = "metrics-exporter-prometheus"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "2b166dea96003ee2531cf14833efedced545751d800f03535801d833313f8c15"
+dependencies = [
+ "base64 0.22.1",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "indexmap 2.13.1",
+ "ipnet",
+ "metrics",
+ "metrics-util",
+ "quanta",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+]
+
 [[package]]
 name = "metrics-util"
 version = "0.20.3"
@@ -3014,7 +3036,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3373,7 +3395,7 @@ source = 
"registry+https://github.com/rust-lang/crates.io-index";
 checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
 dependencies = [
  "libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3567,7 +3589,7 @@ dependencies = [
  "getrandom 0.4.2",
  "once_cell",
  "rustix",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -4269,7 +4291,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
diff --git a/README.md b/README.md
index 63d6a9d3..4f4768d5 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ Key concepts:
 | **Data Types**  | Int, BigInt, String, Float, Double, Boolean, Bytes, 
Decimal, Date, Time, Timestamp, TimestampLTZ, Char, Binary |
 | **Config**      | Batch sizing, buffering, retries, compression, timeouts, 
prefetch, concurrency |
 | **Storage**     | Memory, Filesystem, S3, OSS (via 
[OpenDAL](https://opendal.apache.org/)) |
+| **Observability** | Connection, writer, and scanner 
[metrics](https://clients.fluss.apache.org/docs/user-guide/rust/metrics/) via 
the [`metrics`](https://docs.rs/metrics) facade (Prometheus, StatsD, etc.) |
 | **WASM**        | Compiles for `wasm32` target                               
              |
 
 ### Language Bindings
@@ -83,7 +84,7 @@ fluss-rust/
 │   │   ├── src/row/          #   GenericRow, InternalRow, Arrow integration
 │   │   ├── src/rpc/          #   gRPC transport layer
 │   │   └── src/config.rs     #   Client configuration
-│   ├── examples/             # 5 runnable examples (log, KV, partitioned, 
prefix lookup)
+│   ├── examples/             # runnable examples (log, KV, partitioned, 
prefix lookup, metrics)
 │   └── fluss-test-cluster/   # Test harness for integration tests
 ├── bindings/
 │   ├── python/               # Python binding (PyO3)
@@ -236,6 +237,7 @@ async fn main() -> Result<()> {
 | `example-partitioned-upsert-lookup`      | KV table with partitions          
             |
 | `example-prefix-lookup`                  | Prefix lookup on bucket keys      
             |
 | `example-partitioned-prefix-lookup`      | Prefix lookup on partitioned 
tables            |
+| `example-prometheus-metrics`             | Expose client metrics on a 
Prometheus endpoint |
 
 Build and run any example:
 
diff --git a/crates/examples/Cargo.toml b/crates/examples/Cargo.toml
index 45f029ee..7203095b 100644
--- a/crates/examples/Cargo.toml
+++ b/crates/examples/Cargo.toml
@@ -27,6 +27,8 @@ version = { workspace = true }
 fluss = { workspace = true, features = ["storage-all"] }
 tokio = { workspace = true }
 clap = { workspace = true }
+metrics = { workspace = true }
+metrics-exporter-prometheus = { version = "0.17", default-features = false, 
features = ["http-listener"] }
 
 [target.'cfg(not(target_env = "msvc"))'.dependencies]
 tikv-jemallocator = "0.6"
@@ -50,3 +52,7 @@ path = "src/example_prefix_lookup.rs"
 [[example]]
 name = "example-partitioned-prefix-lookup"
 path = "src/example_partitioned_prefix_lookup.rs"
+
+[[example]]
+name = "example-prometheus-metrics"
+path = "src/example_prometheus_metrics.rs"
diff --git a/crates/examples/src/example_prometheus_metrics.rs 
b/crates/examples/src/example_prometheus_metrics.rs
new file mode 100644
index 00000000..cb47b350
--- /dev/null
+++ b/crates/examples/src/example_prometheus_metrics.rs
@@ -0,0 +1,138 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Exposes Fluss client metrics on a Prometheus scrape endpoint.
+//!
+//! Run a local cluster, then:
+//! ```shell
+//! cargo run -p fluss-examples --example example-prometheus-metrics
+//! curl http://localhost:9000/metrics
+//! ```
+//! The endpoint exposes `fluss_client_writer_*`, `fluss_client_scanner_*`, and
+//! `fluss_client_requests_*` series produced by the workload below. The 
example
+//! runs until interrupted with Ctrl-C so the endpoint stays scrapeable.
+
+#[cfg(not(target_env = "msvc"))]
+#[global_allocator]
+static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
+
+use clap::Parser;
+use fluss::client::FlussConnection;
+use fluss::config::Config;
+use fluss::error::Result;
+use fluss::metadata::{DataTypes, Schema, TableDescriptor, TablePath};
+use fluss::row::GenericRow;
+use metrics_exporter_prometheus::PrometheusBuilder;
+use std::time::Duration;
+
+#[tokio::main]
+pub async fn main() -> Result<()> {
+    // Install the global Prometheus recorder BEFORE creating any connection,
+    // writer, or scanner: the client caches metric handles on first use and
+    // binds them to whichever recorder is installed at that moment.
+    //
+    // `build()` (rather than `install()`) hands back a `PrometheusHandle` so 
the
+    // example can read its own metrics back and self-verify; the returned
+    // exporter future runs the HTTP scrape endpoint.
+    let (recorder, exporter) = PrometheusBuilder::new()
+        .with_http_listener(([0, 0, 0, 0], 9000))
+        .build()
+        .expect("failed to build Prometheus recorder");
+    let metrics_handle = recorder.handle();
+    metrics::set_global_recorder(recorder).expect("failed to install global 
recorder");
+    tokio::spawn(exporter);
+    println!("Metrics exposed on http://localhost:9000/metrics";);
+
+    let mut config = Config::parse();
+    config.bootstrap_servers = "127.0.0.1:9123".to_string();
+
+    let conn = FlussConnection::new(config).await?;
+    let admin = conn.get_admin()?;
+
+    let table_path = TablePath::new("fluss", "rust_prometheus_metrics");
+    let table_descriptor = TableDescriptor::builder()
+        .schema(
+            Schema::builder()
+                .column("id", DataTypes::int())
+                .column("message", DataTypes::string())
+                .build()?,
+        )
+        .build()?;
+    admin
+        .create_table(&table_path, &table_descriptor, true)
+        .await?;
+
+    let table = conn.get_table(&table_path).await?;
+    let append_writer = table.new_append()?.create_writer()?;
+    let log_scanner = table.new_scan().create_log_scanner()?;
+    log_scanner.subscribe(0, 0).await?;
+
+    // The loop runs forever on purpose: a Prometheus exporter is a 
long-running
+    // scrape target, so the process must stay alive -- and keep producing 
fresh
+    // samples -- for `curl /metrics` to return data across repeated scrapes.
+    // Breaking out would shut the HTTP endpoint down. Stop it with Ctrl-C.
+    let rows_per_iter = 100;
+    let mut id = 0i32;
+    let mut verified = false;
+    loop {
+        for _ in 0..rows_per_iter {
+            let mut row = GenericRow::new(2);
+            row.set_field(0, id);
+            row.set_field(1, "metrics demo");
+            append_writer.append(&row)?;
+            id += 1;
+        }
+        append_writer.flush().await?;
+
+        // Calling `poll` is what produces the `fluss_client_scanner_*` series,
+        // so we do it every iteration. The returned records aren't needed for 
a
+        // metrics demo, so we just count them for the log line.
+        let polled = log_scanner.poll(Duration::from_secs(1)).await?.count();
+        println!(
+            "appended {rows_per_iter} rows, polled {polled} rows; scrape 
/metrics to see counters"
+        );
+
+        // One-off sanity check, run only on the first iteration: after the 
first
+        // flush every appended row has been acknowledged, so the writer 
counter
+        // must have advanced by at least the rows we sent (retries can push it
+        // higher). This only proves the recorder is wired up correctly -- it 
is
+        // not a stop condition, so the loop keeps running afterwards.
+        if !verified {
+            let rendered = metrics_handle.render();
+            let sent = counter_value(&rendered, 
"fluss_client_writer_records_send_total");
+            assert!(
+                sent.is_some_and(|v| v >= rows_per_iter as f64),
+                "expected fluss_client_writer_records_send_total >= 
{rows_per_iter}, got {sent:?}\n{rendered}"
+            );
+            println!("self-check OK: records_send_total = {}", sent.unwrap());
+            verified = true;
+        }
+
+        tokio::time::sleep(Duration::from_secs(1)).await;
+    }
+}
+
+/// Parse the value of an unlabeled counter/gauge line from rendered Prometheus
+/// exposition text (lines shaped `metric_name <value>`).
+fn counter_value(rendered: &str, name: &str) -> Option<f64> {
+    let prefix = format!("{name} ");
+    rendered
+        .lines()
+        .find(|line| line.starts_with(&prefix))
+        .and_then(|line| line.rsplit(' ').next())
+        .and_then(|value| value.parse().ok())
+}
diff --git a/website/docs/user-guide/rust/metrics.md 
b/website/docs/user-guide/rust/metrics.md
new file mode 100644
index 00000000..c7fa1af6
--- /dev/null
+++ b/website/docs/user-guide/rust/metrics.md
@@ -0,0 +1,179 @@
+---
+sidebar_position: 5
+---
+# Metrics
+
+The Fluss Rust client is instrumented with client-side metrics for the 
connection
+layer, the write pipeline, and the read (scanner) pipeline. Metrics are emitted
+through the [`metrics`](https://docs.rs/metrics) crate facade, so collecting 
them is
+opt-in and costs nothing until you install a recorder.
+
+## How it works
+
+The client never decides *where* metrics go. It only emits them via the 
`metrics`
+facade. Your application installs a global **recorder** (for example a 
Prometheus
+exporter), and that recorder decides how to store and expose the values.
+
+- **No recorder installed** — every metric call is a zero-cost no-op. This is 
the
+  default, so the client adds no overhead unless you opt in.
+- **Recorder installed** — values flow to whatever backend the recorder 
represents
+  (Prometheus, StatsD, OpenTelemetry, a test recorder, etc.).
+
+This differs from the Fluss Java client, where metric reporters are configured
+server-side in `conf/server.yaml` (`metrics.reporters: jmx,prometheus`) and
+discovered through plugins. The Rust client instead follows the idiomatic Rust
+`metrics` ecosystem: the application owns recorder installation, and rate
+computation is left to the backend (e.g. PromQL `rate()`) instead of a built-in
+background rate thread.
+
+## Installing a recorder
+
+Use any [`metrics`-compatible 
exporter](https://docs.rs/metrics/latest/metrics/#related-crates).
+The example below uses `metrics-exporter-prometheus` to expose a scrape 
endpoint:
+
+```rust
+use metrics_exporter_prometheus::PrometheusBuilder;
+
+PrometheusBuilder::new()
+    .with_http_listener(([0, 0, 0, 0], 9000))
+    .install()
+    .expect("failed to install Prometheus recorder");
+```
+
+A full, runnable program is available as the `example-prometheus-metrics` 
example
+in the `fluss-examples` crate.
+
+:::warning Install the recorder before writing or scanning
+The client caches metric handles the first time a writer or scanner is created,
+binding them to whichever recorder is installed at that moment. Install your 
global
+recorder **before** calling `FlussConnection::new` (ideally as the very first 
thing
+in `main`). If you install it after creating a writer or scanner, those 
metrics will
+be bound to the no-op recorder and never appear.
+:::
+
+## Metric catalog
+
+All metric names use the `fluss.client.` prefix. A Prometheus exporter 
translates
+`.` to `_`, so `fluss.client.writer.send_latency_ms` is scraped as
+`fluss_client_writer_send_latency_ms`.
+
+### Connection / RPC
+
+Recorded per RPC for the four reportable API keys. Labeled with `api_key`
+(`produce_log`, `fetch_log`, `put_kv`, `lookup`).
+
+| Metric | Type | Description |
+| --- | --- | --- |
+| `fluss.client.requests.total` | Counter | Requests sent to a server. |
+| `fluss.client.responses.total` | Counter | Responses received from a server. 
|
+| `fluss.client.bytes_sent.total` | Counter | Request body bytes sent 
(excludes protocol framing). |
+| `fluss.client.bytes_received.total` | Counter | Response body bytes received 
(excludes protocol framing). |
+| `fluss.client.request_latency_ms` | Histogram | Round-trip latency per 
request, in milliseconds. |
+| `fluss.client.requests_in_flight` | Gauge | Requests currently awaiting a 
response. |
+
+### Writer
+
+Recorded in the write pipeline. These metrics are **unlabeled** (one series per
+process), matching Java's `WriterMetricGroup`, which carries no table label.
+
+| Metric | Type | Description |
+| --- | --- | --- |
+| `fluss.client.writer.send_latency_ms` | Histogram | Round-trip latency of 
each write request (ProduceLog / PutKv). |
+| `fluss.client.writer.batch_queue_time_ms` | Histogram | Time a batch spent 
queued in the accumulator before being drained. |
+| `fluss.client.writer.records_send.total` | Counter | Records handed to the 
cluster across all sent batches. |
+| `fluss.client.writer.bytes_send.total` | Counter | Serialized batch bytes 
sent. |
+| `fluss.client.writer.records_retry.total` | Counter | Records re-enqueued 
for retry. |
+| `fluss.client.writer.records_per_batch` | Histogram | Records per sent 
batch. |
+| `fluss.client.writer.bytes_per_batch` | Histogram | Serialized bytes per 
sent batch. |
+| `fluss.client.writer.buffer_total_bytes` | Gauge | Total writer buffer 
memory, in bytes. |
+| `fluss.client.writer.buffer_available_bytes` | Gauge | Currently available 
writer buffer memory, in bytes. |
+| `fluss.client.writer.buffer_waiting_threads` | Gauge | Producer threads 
blocked waiting for buffer memory (backpressure signal). |
+
+### Scanner
+
+Recorded in the read pipeline. Labeled with `database` and `table`, so each 
scanned
+table gets its own series.
+
+| Metric | Type | Description |
+| --- | --- | --- |
+| `fluss.client.scanner.time_between_poll_ms` | Gauge | Milliseconds between 
the start of consecutive `poll()` calls. |
+| `fluss.client.scanner.poll_idle_ratio` | Gauge | Fraction of wall-clock time 
spent inside `poll()` (near 1.0 means starved for data). |
+| `fluss.client.scanner.last_poll_seconds_ago` | Gauge | Seconds since the 
most recent `poll()` started (stuck-consumer signal). |
+| `fluss.client.scanner.fetch_latency_ms` | Histogram | Latency of each 
successful FetchLog RPC, in milliseconds. |
+| `fluss.client.scanner.fetch_requests.total` | Counter | FetchLog RPC 
requests attempted. |
+| `fluss.client.scanner.bytes_per_request` | Histogram | Serialized bytes per 
successful FetchLog response. |
+| `fluss.client.scanner.remote_fetch_requests.total` | Counter | Remote log 
download attempts (includes per-segment retries). |
+| `fluss.client.scanner.remote_fetch_bytes.total` | Counter | Bytes downloaded 
from remote log storage. |
+| `fluss.client.scanner.remote_fetch_errors.total` | Counter | Remote log 
download failures (each retry counts). |
+
+## Differences from the Java client
+
+The Rust client records the same events at the same points as Java, but uses
+metric types better suited to the `metrics` ecosystem:
+
+| Aspect | Java | Rust |
+| --- | --- | --- |
+| Latency metrics (`send_latency_ms`, `batch_queue_time_ms`, 
`fetch_latency_ms`) | Volatile-long gauge (latest value only) | Histogram (full 
p50/p95/p99 distribution) |
+| Throughput / retry metrics | `MeterView` rate computed on a background 
thread | Raw counter; compute the rate with PromQL `rate()` |
+| Writer table label | No table label | No table label (kept identical) |
+
+These are client-internal implementation choices; the values reported to the 
server
+are unaffected.
+
+## Tuning histogram buckets
+
+The default buckets in `metrics-exporter-prometheus` may not give meaningful
+percentiles for sub-millisecond or multi-second latencies. Configure per-metric
+buckets when installing the recorder:
+
+```rust
+use metrics_exporter_prometheus::PrometheusBuilder;
+use metrics::Unit;
+
+PrometheusBuilder::new()
+    .set_buckets_for_metric(
+        metrics_exporter_prometheus::Matcher::Suffix("_ms".to_string()),
+        &[0.5, 1.0, 5.0, 10.0, 50.0, 100.0, 500.0, 1000.0],
+    )
+    .expect("invalid bucket configuration")
+    .with_http_listener(([0, 0, 0, 0], 9000))
+    .install()
+    .expect("failed to install Prometheus recorder");
+
+let _ = Unit::Milliseconds; // optional: register units for richer metadata
+```
+
+## Cardinality
+
+Scanner metrics carry `database` and `table` labels, so the number of scanner
+series scales with the number of tables a single client scans. This is normally
+small, but if your client scans many short-lived tables, the series count can 
grow
+over time. Connection metrics are bounded (four API keys) and writer metrics 
are
+unlabeled, so neither contributes meaningfully to cardinality.
+
+## Grafana / PromQL tips
+
+By default, `metrics-exporter-prometheus` emits histogram metrics as Prometheus
+**summaries** (no `_bucket` series). The `_bucket`-based `histogram_quantile`
+query only works if you enabled bucket mode via `set_buckets(...)` or
+`set_buckets_for_metric(...)` in the previous section.
+
+```promql
+# Write throughput (records/sec), Java-style rate from the raw counter
+rate(fluss_client_writer_records_send_total[1m])
+
+# p99 send latency (default summary mode)
+fluss_client_writer_send_latency_ms{quantile="0.99"}
+
+# p99 send latency (bucket mode enabled)
+histogram_quantile(0.99, 
sum(rate(fluss_client_writer_send_latency_ms_bucket[5m])) by (le))
+
+# Backpressure: producers blocked waiting for buffer memory
+fluss_client_writer_buffer_waiting_threads
+
+# Per-table fetch rate
+sum(rate(fluss_client_scanner_fetch_requests_total[1m])) by (database, table)
+
+# Stuck-consumer alert: no poll in the last 60s
+fluss_client_scanner_last_poll_seconds_ago > 60
+```

Reply via email to