Skip to content

Commit f85d52b

Browse files
authored
feat(tonic-xds): implement OTEL metrics recorder (#2704)
## Motivation Ref: #2444 gRFC A78 defines the XdsClient OpenTelemetry metrics, and #2669 added the framework-agnostic `MetricsRecorder` extension point in `xds-client` — but shipped no bundled backend. Every consumer therefore had to implement the trait by hand just to get the metrics into OpenTelemetry. This PR provides that backend and exposes it through `tonic-xds`. ## Solution Now an `OtelMetricsRecorder` is bundled when `otel` feature flag is enabled. User can provide their own OTEL meter and the five A78 XdsClient metrics (`connected`, `server_failure`, `resource_update_valid/invalid`, `resources`) are emitted through that meter. This PR marks the completion of metrics support in `tonic-xds` based on the currently supported xDS features. ## Out of Scope A few gaps exist as compared to A78, all because of other unsupported gRFCs: 1. `nached_but_cached` and `cache_state` are not emitted, pending A88 data-error caching. 2.`grpc.xds.authority` uses `#old` sentinel for now as xDS Federation is not yet supported.
1 parent c1de8ac commit f85d52b

9 files changed

Lines changed: 554 additions & 144 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ members = [
1818
"grpc-protobuf-build",
1919
"protoc-gen-rust-grpc",
2020
"xds-client",
21+
"xds-client-opentelemetry",
2122
"tonic-xds",
2223
"interop",
2324
"tests/disable_comments",

tonic-xds/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ tonic-prost = { version = "0.14", optional = true }
5555
rustls = { version = "0.23", default-features = false, features = ["std", "tls12"], optional = true }
5656
rustls-pemfile = { version = "2", optional = true }
5757
x509-parser = { version = "0.17", optional = true }
58+
opentelemetry = { version = "0.32", optional = true, default-features = false, features = ["metrics"] }
59+
xds-client-opentelemetry = { version = "0.1.0-alpha.1", path = "../xds-client-opentelemetry", optional = true }
5860

5961
[lints]
6062
workspace = true
@@ -73,6 +75,11 @@ google-cloud-auth = { version = "1.9", default-features = false }
7375
[features]
7476
testutil = ["dep:tonic-prost"]
7577

78+
# Bundled OpenTelemetry metrics recorder for the gRFC A78 xDS client metrics.
79+
# The recorder lives in the companion `xds-client-opentelemetry` crate so its
80+
# OpenTelemetry version is decoupled from the core crates.
81+
otel = ["dep:opentelemetry", "dep:xds-client-opentelemetry"]
82+
7683
# TLS crypto backend — pick exactly one.
7784
_tls-any = ["dep:rustls", "dep:rustls-pemfile", "dep:x509-parser"]
7885
tls-ring = ["_tls-any", "tonic/tls-ring", "xds-client/tonic-tls-ring"]

tonic-xds/src/client/channel.rs

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ use std::sync::Arc;
1616
use std::task::{Context, Poll};
1717
use tonic::{body::Body as TonicBody, client::GrpcService, transport::channel::Channel};
1818
use tower::{BoxError, Service, ServiceBuilder, util::BoxCloneSyncService};
19-
use xds_client::{ClientConfig, Node, ProstCodec, TokioRuntime, TonicTransportBuilder, XdsClient};
19+
use xds_client::{
20+
ClientConfig, MetricsRecorder, Node, ProstCodec, TokioRuntime, TonicTransportBuilder, XdsClient,
21+
};
2022

2123
use crate::client::retry::{GrpcRetryPolicy, GrpcRetryPolicyConfig, RetryLayer};
2224

@@ -162,9 +164,25 @@ const _: fn() = || {
162164
};
163165

164166
/// Builder for creating an [`XdsChannel`] or [`XdsChannelGrpc`].
165-
#[derive(Clone, Debug)]
167+
#[derive(Clone)]
166168
pub struct XdsChannelBuilder {
167169
config: Arc<XdsChannelConfig>,
170+
recorder: Option<Arc<dyn MetricsRecorder>>,
171+
}
172+
173+
impl Debug for XdsChannelBuilder {
174+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175+
f.debug_struct("XdsChannelBuilder")
176+
.field("config", &self.config)
177+
.field(
178+
"recorder",
179+
&self
180+
.recorder
181+
.as_deref()
182+
.map_or("None", |r| std::any::type_name_of_val(r)),
183+
)
184+
.finish()
185+
}
168186
}
169187

170188
impl XdsChannelBuilder {
@@ -173,9 +191,36 @@ impl XdsChannelBuilder {
173191
pub fn new(config: XdsChannelConfig) -> Self {
174192
Self {
175193
config: Arc::new(config),
194+
recorder: None,
176195
}
177196
}
178197

198+
/// Sets the [`MetricsRecorder`] backend that receives the gRFC A78 xDS
199+
/// client metrics emitted by the underlying [`XdsClient`].
200+
///
201+
/// By default no recorder is configured and metric emission is skipped.
202+
/// With the `otel` feature, `with_otel_metrics` provides a one-call
203+
/// OpenTelemetry setup.
204+
#[must_use]
205+
pub fn with_metrics_recorder(mut self, recorder: Arc<dyn MetricsRecorder>) -> Self {
206+
self.recorder = Some(recorder);
207+
self
208+
}
209+
210+
/// Emits the gRFC A78 xDS client metrics through an OpenTelemetry `Meter`.
211+
///
212+
/// Convenience wrapper over
213+
/// [`with_metrics_recorder`](Self::with_metrics_recorder) that installs an
214+
/// [`OtelMetricsRecorder`](xds_client_opentelemetry::OtelMetricsRecorder) from
215+
/// the companion `xds-client-opentelemetry` crate. Requires the `otel` feature.
216+
#[cfg(feature = "otel")]
217+
#[must_use]
218+
pub fn with_otel_metrics(self, meter: opentelemetry::metrics::Meter) -> Self {
219+
self.with_metrics_recorder(Arc::new(
220+
xds_client_opentelemetry::OtelMetricsRecorder::new(meter),
221+
))
222+
}
223+
179224
fn build_tonic_grpc_channel(&self) -> Result<XdsChannelGrpc, BuildError> {
180225
let bootstrap = match self.config.bootstrap.clone() {
181226
Some(b) => b,
@@ -214,8 +259,12 @@ impl XdsChannelBuilder {
214259
let node = Node::try_from(bootstrap.node)?;
215260
let client_config =
216261
ClientConfig::new(node, &server_uri).with_target(self.config.target_uri.to_string());
217-
let xds_client =
218-
XdsClient::builder(client_config, transport_builder, ProstCodec, TokioRuntime).build();
262+
let mut client_builder =
263+
XdsClient::builder(client_config, transport_builder, ProstCodec, TokioRuntime);
264+
if let Some(recorder) = self.recorder.clone() {
265+
client_builder = client_builder.with_metrics_recorder(recorder);
266+
}
267+
let xds_client = client_builder.build();
219268

220269
let cache = Arc::new(XdsCache::new());
221270
let resource_manager =

tonic-xds/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,5 +151,7 @@ pub use xds::bootstrap::{BootstrapConfig, BootstrapError};
151151
pub use xds::uri::{XdsUri, XdsUriError};
152152
pub use xds_client::TonicCallCredentials;
153153

154+
pub use xds_client::{Instrument, InstrumentKind, KeyValue, MetricsRecorder, StringValue, Value};
155+
154156
#[cfg(any(test, feature = "testutil"))]
155157
pub mod testutil;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[package]
2+
name = "xds-client-opentelemetry"
3+
description = "OpenTelemetry metrics recorder for xds-client (gRFC A78 XdsClient metrics)"
4+
version = "0.1.0-alpha.1"
5+
edition = "2024"
6+
homepage = "https://github.com/grpc/grpc-rust"
7+
repository = "https://github.com/grpc/grpc-rust"
8+
license = "MIT"
9+
rust-version = { workspace = true }
10+
publish = false
11+
12+
[lints]
13+
workspace = true
14+
15+
[dependencies]
16+
xds-client = { version = "0.1.0-alpha.1", path = "../xds-client" }
17+
opentelemetry = { version = "0.32", default-features = false, features = ["metrics"] }
18+
19+
[dev-dependencies]
20+
opentelemetry_sdk = { version = "0.32", default-features = false, features = ["metrics", "testing"] }
21+
22+
[package.metadata.cargo_check_external_types]
23+
allowed_external_types = [
24+
# The recorder accepts an OpenTelemetry `Meter` and implements the
25+
# `xds-client` `MetricsRecorder` trait, so both appear in its public API.
26+
"opentelemetry::*",
27+
"xds_client::*",
28+
]

0 commit comments

Comments
 (0)