Skip to content

Commit f22f62f

Browse files
chetanybclaude
andauthored
add trace contex, el headers and failure replay (#70)
* feat(server): extract W3C trace context from incoming HTTP requests Replace the default TraceLayer span with a custom MakeSpan that, when the otel feature is enabled, extracts traceparent/tracestate from request headers via the global propagator and sets the result as the request span's parent. The whole existing explicit-parent chain (request span -> handler -> request_proof -> fetch_witness / prove) now joins the caller's distributed trace. The request span is also promoted from the debug-level tower-http default to info level so it survives info-level filters. Without the otel feature the custom MakeSpan only creates the span, with no OpenTelemetry dependency. Verified by a new otel-gated test using an in-memory span exporter that asserts the exported request span keeps the caller's trace id and is parented under the caller's span id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(server): record cluster job_id on the prove span and in logs Declare an empty job_id field on the per-attempt prove span and pass that span explicitly through zkVMInstance::prove into the zisk cluster backend, which records the id on it as soon as the cluster assigns one, so traces correlate a prove span with the cluster-side job. Recording on the explicitly passed span rather than Span::current() means the id cannot silently land elsewhere if an intermediate span is ever introduced. Also emit an info log with the job id at creation so the correlation is available in plain logs without the otel feature. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(server): support custom HTTP headers for EL JSON-RPC requests Add an optional top-level el_headers map (default empty) next to el_endpoint, applied as reqwest default headers when constructing the ElClient, so every EL JSON-RPC request (chain config, blocks, witnesses) carries them, e.g. for authenticated endpoints. Since header values typically carry credentials they are kept out of any Debug output: the parsed HeaderValues are marked sensitive, and Config implements Debug manually so el_headers values format as "<redacted>". Config validation fails at startup on invalid header names or values, and rejects keys that differ only in case (header names are case-insensitive; HashMap iteration order would otherwise decide which duplicate wins). ElClient::new now propagates HTTP-client construction errors instead of panicking, so all startup failures surface as errors. Documented in the README and the example configs; covered by config tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(client): propagate W3C trace context on outbound requests Behind a new otel cargo feature, inject the current tracing span's OpenTelemetry context (traceparent/tracestate) into the headers of every outbound request (request_proof, subscribe_proof_events, get_proof, verify_proof) via the global propagator, using a hand-rolled HeaderInjector mirroring ere_server_client's OtelPropagation middleware and the server crate's HeaderExtractor. For the SSE subscription the context is captured eagerly at call time, not at first poll. Without the feature the helper returns an empty header map, so default builds change no behavior and pull in no OpenTelemetry dependencies. The crate docs call out that the calling application must install a global text-map propagator (the OpenTelemetry default is a no-op), or no traceparent header is emitted even with the feature enabled. This completes the propagation chain for external callers such as proofessoor: caller span -> zkboost-client traceparent -> zkboost server request span -> ere zkVM server. Verified by an otel-gated integration test that serves requests on a local listener and asserts both a unary request and the SSE subscribe arrive with a traceparent carrying the active span's trace id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(server): set otel span kind and HTTP attributes on the request span Set otel.kind=server and basic HTTP semantic-convention attributes (http.request.method, url.path, url.query) on the request span. They are recorded as OTLP-only attributes, not tracing fields, so the log format of default-feature builds is unchanged. Also move the request-span trace-context test into its own integration-test binary (tests/otel_request_span.rs): it installs a process-global tracing subscriber so spans created on the server's tasks are observed, and sharing a process with parallel tests both raced on the global subscriber and poisoned the global callsite interest cache, which previously required a warm-up/retry workaround. The relocated test additionally asserts the span kind and HTTP attributes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(server): replay proof failures to late SSE subscribers Previously only completed proofs were cached and replayed on subscribe; a subscriber that missed a live proof_failure broadcast could never learn the terminal outcome of a request. Terminal failures are now cached in a bounded LRU (same size as the completed-proof cache) and replayed to per-root SSE subscribers exactly like completions, with unchanged event shapes. A later successful proof for the same (root, proof_type) evicts the stale failure entry so both outcomes are never replayed. Event delivery on the subscribe endpoint is at-least-once with latest-wins semantics: a replayed stale failure can still precede the completion of a retry that has already succeeded, and subscribers must treat the most recent terminal event per (root, proof_type) as authoritative. This is documented on the handler and in the README. Tests: subscribe-after-failure receives the replayed failure (handler unit test + end-to-end re-subscribe assertions for proving error and timeout), LRU bound eviction, cached-vs-broadcast event equality, and completion evicting a stale failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(client): simplify unused dependency lint * fix(server): clear stale failure on retry admission --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b5810fe commit f22f62f

21 files changed

Lines changed: 1019 additions & 55 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ port = 3000
5454
# Ethereum execution layer JSON-RPC endpoint (required)
5555
el_endpoint = "http://localhost:8545"
5656

57+
# Optional HTTP headers applied to every EL JSON-RPC request (e.g. authentication).
58+
# Header names are case-insensitive; names differing only in case are rejected.
59+
# [el_headers]
60+
# Authorization = "Bearer <token>"
61+
5762
# Optional local EL chain config JSON file.
5863
# chain_config_path = "path/to/chain_config.json"
5964

@@ -129,6 +134,13 @@ The following endpoints are available:
129134
| `GET` | `/health` | Health check |
130135
| `GET` | `/metrics` | Prometheus metrics |
131136

137+
Proof events on the SSE stream are delivered at-least-once with latest-wins semantics:
138+
terminal results (completions and failures) that happened before subscribing are replayed
139+
from bounded caches. Admitting a retry evicts its previous failure, although a concurrent
140+
subscription that already snapshotted that failure may still deliver it before the retry's
141+
terminal event. Clients should treat the most recent terminal event per
142+
`(new_payload_request_root, proof_type)` as authoritative.
143+
132144
See [openapi.json](openapi.json) for the full API specification ([rendered](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/eth-act/zkboost/master/openapi.json)).
133145

134146
## Observability

crates/client/Cargo.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,40 @@ license.workspace = true
88
[lints]
99
workspace = true
1010

11+
[features]
12+
default = []
13+
otel = [
14+
"dep:opentelemetry",
15+
"dep:tracing",
16+
"dep:tracing-opentelemetry",
17+
]
18+
1119
[dependencies]
1220
async-stream.workspace = true
1321
bytes.workspace = true
1422
futures.workspace = true
23+
opentelemetry = { workspace = true, optional = true }
1524
reqwest = { workspace = true, features = ["json"] }
1625
reqwest-eventsource.workspace = true
1726
serde.workspace = true
1827
serde_json.workspace = true
1928
thiserror.workspace = true
2029
tokio-stream.workspace = true
30+
tracing = { workspace = true, optional = true }
31+
tracing-opentelemetry = { workspace = true, optional = true }
2132
url.workspace = true
2233

2334
zkboost-types.workspace = true
35+
36+
[dev-dependencies]
37+
axum.workspace = true
38+
opentelemetry.workspace = true
39+
opentelemetry_sdk.workspace = true
40+
tokio = { workspace = true, features = ["macros", "net", "rt"] }
41+
tracing.workspace = true
42+
tracing-opentelemetry.workspace = true
43+
tracing-subscriber = { workspace = true, features = ["registry"] }
44+
45+
[[test]]
46+
name = "otel_propagation"
47+
required-features = ["otel"]

crates/client/src/lib.rs

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@
88
//! - [`get_proof`](zkBoostClient::get_proof) - download completed proof bytes
99
//! - [`verify_proof`](zkBoostClient::verify_proof) - verify a proof against the server
1010
//!
11+
//! # Distributed tracing
12+
//!
13+
//! With the `otel` cargo feature enabled, every outbound request carries the current
14+
//! `tracing` span's W3C trace context (`traceparent`/`tracestate`), injected via the global
15+
//! OpenTelemetry propagator, so proofs requested from an instrumented caller join its
16+
//! distributed trace. Without the feature no OpenTelemetry dependency is pulled in and requests
17+
//! are sent unchanged.
18+
//!
19+
//! Note that the global propagator defaults to a no-op: the calling application must install
20+
//! one, e.g.
21+
//!
22+
//! ```ignore
23+
//! opentelemetry::global::set_text_map_propagator(
24+
//! opentelemetry_sdk::propagation::TraceContextPropagator::new(),
25+
//! );
26+
//! ```
27+
//!
28+
//! otherwise no `traceparent` header is emitted even with the feature enabled.
29+
//!
1130
//! # Example
1231
//!
1332
//! ```ignore
@@ -22,7 +41,7 @@
2241
//! # }
2342
//! ```
2443
25-
#![warn(unused_crate_dependencies)]
44+
#![cfg_attr(not(test), warn(unused_crate_dependencies))]
2645

2746
pub mod error;
2847

@@ -47,6 +66,43 @@ pub use {
4766

4867
const APPLICATION_OCTET_STREAM: &str = "application/octet-stream";
4968

69+
/// [`Injector`](opentelemetry::propagation::Injector) over HTTP headers, used to inject W3C trace
70+
/// context (`traceparent`/`tracestate`) into outbound requests.
71+
#[cfg(feature = "otel")]
72+
struct HeaderInjector<'a>(&'a mut reqwest::header::HeaderMap);
73+
74+
#[cfg(feature = "otel")]
75+
impl opentelemetry::propagation::Injector for HeaderInjector<'_> {
76+
fn set(&mut self, key: &str, value: String) {
77+
if let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes())
78+
&& let Ok(value) = reqwest::header::HeaderValue::from_str(&value)
79+
{
80+
self.0.insert(name, value);
81+
}
82+
}
83+
}
84+
85+
/// Returns headers carrying the current `tracing` span's W3C trace context
86+
/// (`traceparent`/`tracestate`), injected via the global OpenTelemetry propagator.
87+
///
88+
/// Without the `otel` feature this returns an empty map, so requests are sent unchanged. With
89+
/// the feature, the map is still empty unless the application has installed a global text-map
90+
/// propagator (the OpenTelemetry default is a no-op).
91+
fn trace_context_headers() -> reqwest::header::HeaderMap {
92+
#[cfg_attr(not(feature = "otel"), expect(unused_mut))]
93+
let mut headers = reqwest::header::HeaderMap::new();
94+
#[cfg(feature = "otel")]
95+
{
96+
use tracing_opentelemetry::OpenTelemetrySpanExt;
97+
98+
let context = tracing::Span::current().context();
99+
opentelemetry::global::get_text_map_propagator(|propagator| {
100+
propagator.inject_context(&context, &mut HeaderInjector(&mut headers));
101+
});
102+
}
103+
headers
104+
}
105+
50106
/// HTTP client for the zkboost Proof Node API.
51107
#[derive(Debug, Clone)]
52108
#[allow(non_camel_case_types)]
@@ -65,6 +121,9 @@ impl zkBoostClient {
65121
}
66122

67123
/// Creates a new client with a custom [`reqwest::Client`].
124+
///
125+
/// Use this to customize transport behavior, e.g. attach extra headers to every request via
126+
/// [`reqwest::ClientBuilder::default_headers`] (for authenticated endpoints).
68127
pub fn with_http_client(endpoint: Url, http_client: reqwest::Client) -> Self {
69128
Self {
70129
endpoint,
@@ -93,6 +152,7 @@ impl zkBoostClient {
93152
let response = self
94153
.http_client
95154
.post(url)
155+
.headers(trace_context_headers())
96156
.header(CONTENT_TYPE, APPLICATION_OCTET_STREAM)
97157
.body(body.to_ssz())
98158
.send()
@@ -111,14 +171,17 @@ impl zkBoostClient {
111171
&self,
112172
filter_root: Option<Hash256>,
113173
) -> impl Stream<Item = Result<ProofEvent, Error>> + Send + '_ {
174+
// Capture the trace context eagerly so the subscription joins the span current at call
175+
// time, not whichever span happens to be current when the stream is first polled.
176+
let trace_headers = trace_context_headers();
114177
async_stream::try_stream! {
115178
let mut url = self.endpoint.join("/v1/execution_proof_requests")?;
116179
if let Some(new_payload_request_root) = filter_root {
117180
url.query_pairs_mut()
118181
.append_pair("new_payload_request_root", &new_payload_request_root.to_string());
119182
}
120183

121-
let builder = self.http_client.get(url);
184+
let builder = self.http_client.get(url).headers(trace_headers);
122185
let mut es = EventSource::new(builder)
123186
.map_err(|e| Error::Sse(format!("failed to create event source: {e}")))?;
124187

@@ -150,7 +213,8 @@ impl zkBoostClient {
150213
"/v1/execution_proofs/{new_payload_request_root}/{proof_type}"
151214
))?;
152215

153-
let response = error_for_status(self.http_client.get(url).send().await?).await?;
216+
let request = self.http_client.get(url).headers(trace_context_headers());
217+
let response = error_for_status(request.send().await?).await?;
154218
Ok(response.bytes().await?)
155219
}
156220

@@ -176,6 +240,7 @@ impl zkBoostClient {
176240
let response = self
177241
.http_client
178242
.post(url)
243+
.headers(trace_context_headers())
179244
.header(CONTENT_TYPE, APPLICATION_OCTET_STREAM)
180245
.body(body.to_ssz())
181246
.send()
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
//! Tests that outbound client requests carry the calling span's W3C trace context
2+
//! (`traceparent`) when the `otel` feature is enabled.
3+
4+
use std::sync::{Arc, Mutex};
5+
6+
use axum::{Router, extract::State, http::HeaderMap};
7+
use opentelemetry::trace::{TraceContextExt, TracerProvider};
8+
use opentelemetry_sdk::{propagation::TraceContextPropagator, trace::SdkTracerProvider};
9+
use tokio_stream::StreamExt;
10+
use tracing::Instrument;
11+
use tracing_opentelemetry::OpenTelemetrySpanExt;
12+
use tracing_subscriber::layer::SubscriberExt;
13+
use zkboost_client::{Hash256, ProofType, zkBoostClient};
14+
15+
/// `traceparent` header values captured by the local server, one per request received.
16+
type CapturedTraceparents = Arc<Mutex<Vec<Option<String>>>>;
17+
18+
async fn capture_traceparent(State(captured): State<CapturedTraceparents>, headers: HeaderMap) {
19+
let traceparent = headers
20+
.get("traceparent")
21+
.and_then(|value| value.to_str().ok())
22+
.map(str::to_owned);
23+
captured.lock().unwrap().push(traceparent);
24+
}
25+
26+
/// Extracts the trace id from a W3C `traceparent` header (`00-<trace_id>-<span_id>-<flags>`).
27+
fn trace_id_of(traceparent: &str) -> &str {
28+
traceparent
29+
.split('-')
30+
.nth(1)
31+
.expect("traceparent should have a trace id field")
32+
}
33+
34+
/// Sends requests from within tracing spans and asserts each request arrives with a
35+
/// `traceparent` header carrying the active span's trace id.
36+
#[tokio::test]
37+
async fn test_outbound_requests_carry_active_span_trace_context() {
38+
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
39+
let provider = SdkTracerProvider::builder().build();
40+
let subscriber = tracing_subscriber::registry().with(
41+
tracing_opentelemetry::OpenTelemetryLayer::new(provider.tracer("test")),
42+
);
43+
let _guard = tracing::subscriber::set_default(subscriber);
44+
45+
// Local server capturing the `traceparent` header of every request.
46+
let captured = CapturedTraceparents::default();
47+
let app = Router::new()
48+
.fallback(capture_traceparent)
49+
.with_state(captured.clone());
50+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
51+
let endpoint = format!("http://{}", listener.local_addr().unwrap());
52+
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
53+
54+
let client = zkBoostClient::new(endpoint.parse().unwrap());
55+
56+
// Unary request: `get_proof` awaited inside a span.
57+
let unary_span = tracing::info_span!("unary_caller");
58+
let unary_trace_id = unary_span
59+
.context()
60+
.span()
61+
.span_context()
62+
.trace_id()
63+
.to_string();
64+
assert_ne!(
65+
unary_trace_id, "00000000000000000000000000000000",
66+
"span should carry a valid OpenTelemetry context"
67+
);
68+
client
69+
.get_proof(Hash256::ZERO, ProofType::RethZisk)
70+
.instrument(unary_span)
71+
.await
72+
.unwrap();
73+
74+
// SSE subscription: the context is captured when `subscribe_proof_events` is called, even
75+
// though the request is only sent once the stream is polled (outside the span, here). The
76+
// plain 200 response fails SSE content-type validation, which is fine: the request has
77+
// already reached the server with its headers by then.
78+
let sse_span = tracing::info_span!("sse_caller");
79+
let sse_trace_id = sse_span
80+
.context()
81+
.span()
82+
.span_context()
83+
.trace_id()
84+
.to_string();
85+
let stream = {
86+
let _entered = sse_span.enter();
87+
client.subscribe_proof_events(None)
88+
};
89+
let mut stream = std::pin::pin!(stream);
90+
let _ = stream.next().await;
91+
92+
let captured = captured.lock().unwrap();
93+
assert_eq!(captured.len(), 2);
94+
let unary_traceparent = captured[0]
95+
.as_deref()
96+
.expect("get_proof should send traceparent");
97+
assert_eq!(trace_id_of(unary_traceparent), unary_trace_id);
98+
let sse_traceparent = captured[1]
99+
.as_deref()
100+
.expect("subscribe should send traceparent");
101+
assert_eq!(trace_id_of(sse_traceparent), sse_trace_id);
102+
}

crates/server/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ zkboost-types.workspace = true
7777

7878
[dev-dependencies]
7979
futures.workspace = true
80+
opentelemetry_sdk = { workspace = true, features = ["testing"] }
8081

8182
# local
8283
zkboost-client.workspace = true
84+
85+
[[test]]
86+
name = "otel_request_span"
87+
required-features = ["otel"]

0 commit comments

Comments
 (0)