Skip to content

Commit ab2423d

Browse files
committed
Ensure that telemetry in payjoin-mailroom has an http-client
Following the update for the opentelemetry to 0.32 the default crypto provider in a transitive dependency when enabling `reqwest-rustls` forced the default crypto provider to not align with our default `ring` in the codebase. However, by manually importing opentelemetry-http and only enabling `hyper` we are able to avoid this crypto provider incompatibility.
1 parent b182391 commit ab2423d

6 files changed

Lines changed: 185 additions & 23 deletions

File tree

Cargo-minimal.lock

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2449,7 +2449,11 @@ dependencies = [
24492449
"async-trait",
24502450
"bytes",
24512451
"http",
2452+
"http-body-util",
2453+
"hyper",
2454+
"hyper-util",
24522455
"opentelemetry",
2456+
"tokio",
24532457
]
24542458

24552459
[[package]]
@@ -2655,6 +2659,7 @@ name = "payjoin-mailroom"
26552659
version = "0.1.2"
26562660
dependencies = [
26572661
"anyhow",
2662+
"async-trait",
26582663
"axum",
26592664
"axum-server",
26602665
"bhttp",
@@ -2677,6 +2682,7 @@ dependencies = [
26772682
"maxminddb",
26782683
"mockito",
26792684
"opentelemetry",
2685+
"opentelemetry-http",
26802686
"opentelemetry-otlp",
26812687
"opentelemetry_sdk",
26822688
"payjoin",

Cargo-recent.lock

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2589,7 +2589,11 @@ dependencies = [
25892589
"async-trait",
25902590
"bytes",
25912591
"http",
2592+
"http-body-util",
2593+
"hyper",
2594+
"hyper-util",
25922595
"opentelemetry",
2596+
"tokio",
25932597
]
25942598

25952599
[[package]]
@@ -2786,6 +2790,7 @@ name = "payjoin-mailroom"
27862790
version = "0.1.2"
27872791
dependencies = [
27882792
"anyhow",
2793+
"async-trait",
27892794
"axum",
27902795
"axum-server",
27912796
"bhttp",
@@ -2808,6 +2813,7 @@ dependencies = [
28082813
"maxminddb",
28092814
"mockito",
28102815
"opentelemetry",
2816+
"opentelemetry-http",
28112817
"opentelemetry-otlp",
28122818
"opentelemetry_sdk",
28132819
"payjoin",

payjoin-mailroom/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ telemetry = ["dep:opentelemetry-otlp"]
2525

2626
[dependencies]
2727
anyhow = "1.0.99"
28+
async-trait = "0.1.89"
2829
axum = { version = "0.8", features = ["http2"] }
2930
axum-server = { version = "0.8", features = [
3031
"tls-rustls-no-provider",
@@ -57,6 +58,7 @@ ipnet = { version = "2.9", optional = true }
5758
maxminddb = { version = "0.27", optional = true }
5859
ohttp = { package = "bitcoin-ohttp", version = "0.6.0" }
5960
opentelemetry = "0.32"
61+
opentelemetry-http = { version = "0.32", features = ["hyper"] }
6062
opentelemetry-otlp = { version = "0.32", optional = true, default-features = false, features = [
6163
"http-proto",
6264
] }

payjoin-mailroom/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ pub mod key_config;
2626
pub mod metrics;
2727
pub mod middleware;
2828
pub mod ohttp_relay;
29+
#[cfg(feature = "telemetry")]
30+
pub mod telemetry;
2931

3032
use crate::metrics::MetricsService;
3133
use crate::middleware::{track_connections, track_metrics};

payjoin-mailroom/src/main.rs

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -36,29 +36,11 @@ fn init_tracing() -> Option<SdkMeterProvider> {
3636

3737
#[cfg(feature = "telemetry")]
3838
fn init_tracing_with_telemetry(telemetry: &config::TelemetryConfig) -> SdkMeterProvider {
39-
use opentelemetry::KeyValue;
40-
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
41-
use opentelemetry_sdk::Resource;
42-
43-
let resource = Resource::builder()
44-
.with_service_name("payjoin-mailroom")
45-
.with_attribute(KeyValue::new("operator.domain", telemetry.operator_domain.clone()))
46-
.build();
47-
48-
let headers: std::collections::HashMap<String, String> =
49-
[("Authorization".to_string(), format!("Basic {}", telemetry.auth_token))].into();
50-
51-
// Initialize metric exporter and provider
52-
let metric_exporter = opentelemetry_otlp::MetricExporter::builder()
53-
.with_http()
54-
.with_endpoint(format!("{}/v1/metrics", telemetry.endpoint))
55-
.with_headers(headers)
56-
.build()
57-
.expect("Failed to build OTLP metric exporter");
58-
let meter_provider = SdkMeterProvider::builder()
59-
.with_periodic_exporter(metric_exporter)
60-
.with_resource(resource)
61-
.build();
39+
let meter_provider = payjoin_mailroom::telemetry::build_otlp_meter_provider(
40+
&telemetry.endpoint,
41+
&telemetry.auth_token,
42+
&telemetry.operator_domain,
43+
);
6244

6345
let env_filter =
6446
EnvFilter::builder().with_default_directive(LevelFilter::INFO.into()).from_env_lossy();

payjoin-mailroom/src/telemetry.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! OpenTelemetry OTLP meter-provider construction.
2+
//!
3+
//! See [`build_otlp_meter_provider`] for the public entry point used by both
4+
//! the binary (`main.rs`) and the integration tests.
5+
6+
use std::time::Duration;
7+
8+
use async_trait::async_trait;
9+
use opentelemetry::KeyValue;
10+
use opentelemetry_http::hyper::HyperClient;
11+
use opentelemetry_http::{Bytes, HttpClient, HttpError, Request, Response};
12+
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
13+
use opentelemetry_sdk::metrics::SdkMeterProvider;
14+
use opentelemetry_sdk::Resource;
15+
16+
/// Build an OTLP/HTTP `SdkMeterProvider` pinned to the mailroom's `ring`
17+
/// crypto provider.
18+
///
19+
/// `opentelemetry-otlp`'s `reqwest-rustls` feature pulls in `aws-lc-rs` (plus
20+
/// its native `cmake` build) via `reqwest` 0.13. We avoid that by enabling only
21+
/// the `hyper-client` feature and supplying our own `hyper_rustls`
22+
/// `HttpsConnector`, which reuses the `ring`-backed `rustls` 0.23 already in
23+
/// the dependency graph.
24+
///
25+
/// The caller is responsible for invoking
26+
/// `opentelemetry::global::set_meter_provider` and installing any tracing
27+
/// subscriber; this function deliberately touches no global state so it remains
28+
/// unit-testable.
29+
///
30+
/// # Panics
31+
/// Panics if no Tokio runtime is active on the current thread. The OTLP
32+
/// exporter's `HyperClient` requires a Tokio context, which we capture here
33+
/// (the SDK's `PeriodicReader` otherwise polls exporters from a bare thread
34+
/// with no runtime, panicking on the first export).
35+
pub fn build_otlp_meter_provider(
36+
endpoint: &str,
37+
auth_token: &str,
38+
operator_domain: &str,
39+
) -> SdkMeterProvider {
40+
let resource = Resource::builder()
41+
.with_service_name("payjoin-mailroom")
42+
.with_attribute(KeyValue::new("operator.domain", operator_domain.to_string()))
43+
.build();
44+
45+
let headers: std::collections::HashMap<String, String> =
46+
[("Authorization".to_string(), format!("Basic {auth_token}"))].into();
47+
48+
let connector = hyper_rustls::HttpsConnectorBuilder::new()
49+
.with_webpki_roots()
50+
.https_or_http()
51+
.enable_http1()
52+
.enable_http2()
53+
.build();
54+
55+
// The SDK's `PeriodicReader` drives exports on a plain OS thread via
56+
// `futures_executor::block_on`, with no Tokio context. `HyperClient` calls
57+
// `tokio::time::timeout`, which panics outside a Tokio runtime. Capture the
58+
// ambient handle and dispatch each request onto it.
59+
let handle = tokio::runtime::Handle::try_current()
60+
.expect("build_otlp_meter_provider must be called from a Tokio runtime");
61+
let http_client = TokioDispatchClient {
62+
inner: HyperClient::new(connector, Duration::from_secs(10), None),
63+
handle,
64+
};
65+
66+
let metric_exporter = opentelemetry_otlp::MetricExporter::builder()
67+
.with_http()
68+
.with_endpoint(format!("{endpoint}/v1/metrics"))
69+
.with_headers(headers)
70+
.with_http_client(http_client)
71+
.build()
72+
.expect("Failed to build OTLP metric exporter");
73+
74+
SdkMeterProvider::builder()
75+
.with_periodic_exporter(metric_exporter)
76+
.with_resource(resource)
77+
.build()
78+
}
79+
80+
/// `HttpClient` adapter that runs the inner client on a captured Tokio handle.
81+
///
82+
/// The OpenTelemetry SDK's `PeriodicReader` polls exporters from a standalone
83+
/// thread (via `futures_executor::block_on`), not from the application's Tokio
84+
/// runtime. `HyperClient::send_bytes` uses `tokio::time::timeout`, which panics
85+
/// without a Tokio context. This wrapper spawns each request onto the captured
86+
/// handle and awaits the `JoinHandle`, which is safe to poll from any executor.
87+
#[derive(Debug, Clone)]
88+
struct TokioDispatchClient<C>
89+
where
90+
C: hyper_util::client::legacy::connect::Connect
91+
+ Clone
92+
+ Send
93+
+ Sync
94+
+ std::fmt::Debug
95+
+ 'static,
96+
{
97+
inner: HyperClient<C>,
98+
handle: tokio::runtime::Handle,
99+
}
100+
101+
#[async_trait]
102+
impl<C> HttpClient for TokioDispatchClient<C>
103+
where
104+
C: hyper_util::client::legacy::connect::Connect
105+
+ Clone
106+
+ Send
107+
+ Sync
108+
+ std::fmt::Debug
109+
+ 'static,
110+
{
111+
async fn send_bytes(&self, request: Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
112+
let inner = self.inner.clone();
113+
self.handle.spawn(async move { HttpClient::send_bytes(&inner, request).await }).await?
114+
}
115+
}
116+
117+
#[cfg(test)]
118+
mod tests {
119+
use super::*;
120+
use crate::metrics::MetricsService;
121+
122+
/// Regression test for the OTLP transport swap (opentelemetry 0.32).
123+
///
124+
/// Replaces the manual `mock_otlp.py` + `curl` + 65s-wait smoke test.
125+
/// Confirms the telemetry feature functions end-to-end:
126+
///
127+
/// 1. The exporter builds without panicking (`NoHttpClient` is gone, since
128+
/// we supply a `hyper-client` explicitly instead of the dropped
129+
/// `reqwest-rustls` feature).
130+
/// 2. The HTTP transport works under the SDK's `PeriodicReader` -- the
131+
/// `TokioDispatchClient` bridges the standalone export thread onto the
132+
/// test's Tokio runtime, so no "no reactor running" panic.
133+
/// 3. The wire request is well-formed OTLP/protobuf with the configured
134+
/// Basic-auth header, proving the encoding + auth path survived the
135+
/// version bump.
136+
///
137+
/// If crypto-provider overlap ever returns (e.g. `aws-lc-rs` sneaks back
138+
/// in alongside `ring`), the existing compile-time / link-time tests that
139+
/// guard the rustls provider selection catch that; this test does not.
140+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
141+
async fn otlp_exporter_posts_metrics_over_http() {
142+
let mut server = mockito::Server::new_async().await;
143+
let mock = server
144+
.mock("POST", "/v1/metrics")
145+
.match_header("authorization", "Basic dXNlcjpwYXNz")
146+
.match_header("content-type", "application/x-protobuf")
147+
.with_status(200)
148+
.create_async()
149+
.await;
150+
151+
let provider = build_otlp_meter_provider(&server.url(), "dXNlcjpwYXNz", "test.example.com");
152+
153+
// Drive a real meter through the same MetricsService the app uses so
154+
// the export batch is non-empty.
155+
let metrics = MetricsService::new(Some(provider.clone()));
156+
metrics.record_http_request("/health", "GET", 200);
157+
158+
provider.force_flush().expect("force_flush");
159+
160+
mock.assert_async().await;
161+
162+
let _ = provider.shutdown();
163+
}
164+
}

0 commit comments

Comments
 (0)