Skip to main content

mysten_network/request_log/
mod.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Opt-in capture of gRPC request messages on the `grpc_request` tracing target.
5//!
6//! [`GrpcRequestLogLayer`] tees each request body flowing through it, parses the gRPC message
7//! framing, and emits one `trace`-level event per message with the payload as the raw protobuf
8//! message bytes, base64-encoded. Capture is scoped to methods resolvable in the same
9//! `FileDescriptorSet`s the server registers for gRPC reflection: they gate which paths are
10//! captured and label the `service`/`method` span fields, but the payload itself is never decoded
11//! against them. Emitted at `trace` level, so it is a no-op unless an operator opts in by enabling
12//! the target:
13//!
14//! - `RUST_LOG="info,grpc_request=trace"` — capture every method.
15//! - `RUST_LOG='info,grpc_request[{service=sui.rpc.v2.LedgerService}]=trace'` — capture a single
16//!   service (or `[{method=GetObject}]` for a single method). The service and method are recorded
17//!   on the enclosing span so `EnvFilter` span-field directives can subset them. Under
18//!   `telemetry-subscribers` this additionally requires `TOKIO_SPAN_LEVEL=trace`, because its
19//!   global level filter otherwise rejects the trace-level `capture` span the directive matches
20//!   against. Use the bare-braces form shown here: directives that also name the span
21//!   (`grpc_request[capture{...}]`) are not recognized by the capture gate. Note that under
22//!   `telemetry-subscribers` a field-scoped directive subsets the *output*, not the capture
23//!   overhead — non-matching requests are still parsed before their events are dropped.
24//! - Combine with `RUST_LOG_TAILS="grpc_request=/var/log/sui/grpc_request.jsonl"` (see
25//!   `telemetry-subscribers`) to additionally write the events to a dedicated newline-delimited
26//!   JSON file, e.g. as a replay corpus.
27//!
28//! Payloads may contain sensitive arguments.
29
30mod body;
31mod layer;
32mod service;
33
34pub use self::body::RequestLogBody;
35pub use self::layer::GrpcRequestLogLayer;
36pub use self::service::GrpcRequestLog;
37
38/// The tracing target captured requests are emitted on.
39pub const TARGET: &str = "grpc_request";
40
41#[cfg(test)]
42mod tests {
43    use std::collections::BTreeMap;
44    use std::convert::Infallible;
45    use std::sync::Arc;
46    use std::sync::Mutex;
47
48    use base64::Engine as _;
49    use bytes::Bytes;
50    use http_body::Frame;
51    use http_body_util::BodyExt;
52    use http_body_util::Empty;
53    use http_body_util::Full;
54    use http_body_util::StreamBody;
55    use prost::Message;
56    use tower::Layer;
57    use tower::ServiceExt;
58    use tracing::field::Field;
59    use tracing::field::Visit;
60    use tracing_subscriber::EnvFilter;
61    use tracing_subscriber::Layer as SubscriberLayer;
62    use tracing_subscriber::layer::Context;
63    use tracing_subscriber::layer::SubscriberExt;
64
65    use super::*;
66
67    /// One recorded event: every field (including the implicit `message`) rendered to a string.
68    type Event = BTreeMap<String, String>;
69
70    /// A `tracing` layer that records the fields of every event it receives, so tests can assert
71    /// which captures survived an `EnvFilter` and what they carried.
72    #[derive(Clone, Default)]
73    struct CaptureLayer {
74        events: Arc<Mutex<Vec<Event>>>,
75    }
76
77    impl<S: tracing::Subscriber> SubscriberLayer<S> for CaptureLayer {
78        fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
79            let mut visitor = FieldVisitor(Event::new());
80            event.record(&mut visitor);
81            self.events.lock().unwrap().push(visitor.0);
82        }
83    }
84
85    struct FieldVisitor(Event);
86
87    impl Visit for FieldVisitor {
88        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
89            self.0.insert(field.name().to_owned(), format!("{value:?}"));
90        }
91
92        fn record_str(&mut self, field: &Field, value: &str) {
93            self.0.insert(field.name().to_owned(), value.to_owned());
94        }
95    }
96
97    /// gRPC-frame `message` with the given compressed `flag`.
98    fn frame(flag: u8, message: &[u8]) -> Vec<u8> {
99        let mut framed = vec![flag];
100        framed.extend_from_slice(&(message.len() as u32).to_be_bytes());
101        framed.extend_from_slice(message);
102        framed
103    }
104
105    /// Raw (unframed) protobuf bytes of a `HealthCheckRequest` for `service`.
106    fn health_check_bytes(service: &str) -> Vec<u8> {
107        tonic_health::pb::HealthCheckRequest {
108            service: service.to_owned(),
109        }
110        .encode_to_vec()
111    }
112
113    /// gRPC-frame a `HealthCheckRequest` for `service`.
114    fn framed_health_check(service: &str) -> Vec<u8> {
115        frame(0, &health_check_bytes(service))
116    }
117
118    /// The base64 payload a captured `HealthCheckRequest` for `service` is expected to log.
119    fn base64_health_check(service: &str) -> String {
120        base64::engine::general_purpose::STANDARD.encode(health_check_bytes(service))
121    }
122
123    fn health_layer() -> GrpcRequestLogLayer {
124        GrpcRequestLogLayer::from_encoded_file_descriptor_sets([
125            tonic_health::pb::FILE_DESCRIPTOR_SET,
126        ])
127        .unwrap()
128    }
129
130    /// Mimics `telemetry-subscribers`' global level filter, which rejects every *span* callsite
131    /// above INFO at registration time (the `TOKIO_SPAN_LEVEL` default) while letting events
132    /// through to the per-layer filters.
133    struct SpanLevelCap;
134
135    impl<S: tracing::Subscriber> SubscriberLayer<S> for SpanLevelCap {
136        fn register_callsite(
137            &self,
138            metadata: &'static tracing::Metadata<'static>,
139        ) -> tracing::subscriber::Interest {
140            use tracing::level_filters::LevelFilter;
141            if metadata.is_span() && LevelFilter::from_level(*metadata.level()) > LevelFilter::INFO
142            {
143                tracing::subscriber::Interest::never()
144            } else {
145                tracing::subscriber::Interest::sometimes()
146            }
147        }
148    }
149
150    /// Send one request with `body` through `layer` while `subscriber` is installed.
151    async fn send_body<S, B>(
152        subscriber: S,
153        layer: GrpcRequestLogLayer,
154        path: &str,
155        content_type: &str,
156        body: B,
157    ) where
158        S: tracing::Subscriber + Send + Sync + 'static,
159        B: http_body::Body<Data = Bytes> + Send + 'static,
160        B::Error: std::fmt::Debug,
161    {
162        let _guard = tracing::subscriber::set_default(subscriber);
163
164        let service = layer.layer(tower::service_fn(
165            |request: http::Request<RequestLogBody<B>>| async move {
166                // Drive the request body to completion, as a real gRPC server would.
167                request.into_body().collect().await.unwrap();
168                Ok::<_, Infallible>(http::Response::new(Empty::<Bytes>::new()))
169            },
170        ));
171
172        let request = http::Request::builder()
173            .method(http::Method::POST)
174            .uri(path)
175            .header(http::header::CONTENT_TYPE, content_type)
176            .body(body)
177            .unwrap();
178
179        service.oneshot(request).await.unwrap();
180    }
181
182    /// Send one request with `body` through `layer` under a subscriber configured with
183    /// `directive`, and return the events that were actually logged (i.e. passed the filter).
184    async fn capture_events<B>(
185        directive: &str,
186        layer: GrpcRequestLogLayer,
187        path: &str,
188        content_type: &str,
189        body: B,
190    ) -> Vec<Event>
191    where
192        B: http_body::Body<Data = Bytes> + Send + 'static,
193        B::Error: std::fmt::Debug,
194    {
195        let capture_layer = CaptureLayer::default();
196        let subscriber = tracing_subscriber::registry()
197            .with(EnvFilter::new(directive))
198            .with(capture_layer.clone());
199        send_body(subscriber, layer, path, content_type, body).await;
200
201        let events = capture_layer.events.lock().unwrap();
202        events.clone()
203    }
204
205    /// Send one framed `HealthCheckRequest` under `directive`, returning the logged payloads.
206    async fn capture_with_filter(directive: &str, path: &str, content_type: &str) -> Vec<String> {
207        capture_events(
208            directive,
209            health_layer(),
210            path,
211            content_type,
212            Full::new(Bytes::from(framed_health_check("x"))),
213        )
214        .await
215        .into_iter()
216        .filter_map(|mut event| event.remove("payload"))
217        .collect()
218    }
219
220    #[tokio::test]
221    async fn target_directive_captures_canonical_json() {
222        let payloads = capture_with_filter(
223            "grpc_request=trace",
224            "/grpc.health.v1.Health/Check",
225            "application/grpc",
226        )
227        .await;
228
229        assert_eq!(payloads, vec![base64_health_check("x")]);
230    }
231
232    /// Regression test for running under `telemetry-subscribers`: its global level filter kills
233    /// the trace-level `capture` span callsite, so the capture gate must not depend on the span
234    /// being enabled when a plain target directive is set.
235    #[tokio::test]
236    async fn target_directive_captures_under_global_span_level_cap() {
237        let capture_layer = CaptureLayer::default();
238        let subscriber = tracing_subscriber::registry()
239            .with(EnvFilter::new("grpc_request=trace"))
240            .with(capture_layer.clone())
241            .with(SpanLevelCap);
242        send_body(
243            subscriber,
244            health_layer(),
245            "/grpc.health.v1.Health/Check",
246            "application/grpc",
247            Full::new(Bytes::from(framed_health_check("x"))),
248        )
249        .await;
250
251        let events = capture_layer.events.lock().unwrap();
252        assert_eq!(events.len(), 1);
253        assert_eq!(events[0]["payload"], base64_health_check("x"));
254    }
255
256    #[tokio::test]
257    async fn disabled_target_captures_nothing() {
258        let payloads =
259            capture_with_filter("info", "/grpc.health.v1.Health/Check", "application/grpc").await;
260
261        assert_eq!(payloads, Vec::<String>::new());
262    }
263
264    #[tokio::test]
265    async fn service_span_field_directive_captures_matching_service() {
266        let payloads = capture_with_filter(
267            "grpc_request[{service=grpc.health.v1.Health}]=trace",
268            "/grpc.health.v1.Health/Check",
269            "application/grpc",
270        )
271        .await;
272
273        assert_eq!(payloads, vec![base64_health_check("x")]);
274    }
275
276    #[tokio::test]
277    async fn service_span_field_directive_skips_other_services() {
278        let payloads = capture_with_filter(
279            "grpc_request[{service=some.other.Service}]=trace",
280            "/grpc.health.v1.Health/Check",
281            "application/grpc",
282        )
283        .await;
284
285        assert_eq!(payloads, Vec::<String>::new());
286    }
287
288    #[tokio::test]
289    async fn method_span_field_directive_captures_matching_method() {
290        let payloads = capture_with_filter(
291            "grpc_request[{method=Check}]=trace",
292            "/grpc.health.v1.Health/Check",
293            "application/grpc",
294        )
295        .await;
296
297        assert_eq!(payloads, vec![base64_health_check("x")]);
298    }
299
300    #[tokio::test]
301    async fn unknown_path_captures_nothing() {
302        let payloads = capture_with_filter(
303            "grpc_request=trace",
304            "/unknown.Service/Method",
305            "application/grpc",
306        )
307        .await;
308
309        assert_eq!(payloads, Vec::<String>::new());
310    }
311
312    #[tokio::test]
313    async fn non_grpc_content_type_captures_nothing() {
314        let payloads = capture_with_filter(
315            "grpc_request=trace",
316            "/grpc.health.v1.Health/Check",
317            "application/json",
318        )
319        .await;
320
321        assert_eq!(payloads, Vec::<String>::new());
322    }
323
324    /// A message delivered in chunks that straddle its frame boundary is still captured
325    /// correctly, and a second message in the same body hits the per-request cap instead of
326    /// being captured.
327    #[tokio::test]
328    async fn chunked_body_captures_first_message_and_caps_the_rest() {
329        let mut bytes = framed_health_check("a");
330        bytes.extend_from_slice(&framed_health_check("b"));
331        let chunks: Vec<Result<Frame<Bytes>, Infallible>> = bytes
332            .chunks(3)
333            .map(|chunk| Ok(Frame::data(Bytes::copy_from_slice(chunk))))
334            .collect();
335
336        let events = capture_events(
337            "grpc_request=trace",
338            health_layer(),
339            "/grpc.health.v1.Health/Check",
340            "application/grpc",
341            StreamBody::new(futures::stream::iter(chunks)),
342        )
343        .await;
344
345        assert_eq!(events.len(), 2);
346        assert_eq!(events[0]["payload"], base64_health_check("a"));
347        assert_eq!(events[0]["message_index"], "0");
348        assert!(events[1]["message"].contains("too many messages"));
349    }
350
351    /// An oversized message emits a payload-less event instead of being captured. One message per
352    /// body, since the per-request cap stops capture after the first.
353    #[tokio::test]
354    async fn oversized_message_emits_payloadless_event() {
355        let bytes = frame(0, &[0x20; 32]); // over the 16-byte cap below
356
357        let events = capture_events(
358            "grpc_request=trace",
359            health_layer().with_max_captured_message_size(16),
360            "/grpc.health.v1.Health/Check",
361            "application/grpc",
362            Full::new(Bytes::from(bytes)),
363        )
364        .await;
365
366        assert_eq!(events.len(), 1);
367        assert_eq!(events[0]["skipped"], "message too large");
368        assert_eq!(events[0]["message_len"], "32");
369        assert!(!events[0].contains_key("payload"));
370    }
371
372    /// A compressed message emits a payload-less event instead of being captured (this layer
373    /// doesn't decompress).
374    #[tokio::test]
375    async fn compressed_message_emits_payloadless_event() {
376        let bytes = frame(1, b"zz");
377
378        let events = capture_events(
379            "grpc_request=trace",
380            health_layer(),
381            "/grpc.health.v1.Health/Check",
382            "application/grpc",
383            Full::new(Bytes::from(bytes)),
384        )
385        .await;
386
387        assert_eq!(events.len(), 1);
388        assert_eq!(events[0]["skipped"], "compressed message");
389    }
390
391    /// A message need not be valid protobuf against the resolved method to be captured — the raw
392    /// bytes are logged as-is, without ever being decoded.
393    #[tokio::test]
394    async fn invalid_protobuf_message_is_still_captured() {
395        let garbage = [0xFF]; // invalid wire type: would fail to decode
396        let bytes = frame(0, &garbage);
397
398        let events = capture_events(
399            "grpc_request=trace",
400            health_layer(),
401            "/grpc.health.v1.Health/Check",
402            "application/grpc",
403            Full::new(Bytes::from(bytes)),
404        )
405        .await;
406
407        assert_eq!(events.len(), 1);
408        assert_eq!(
409            events[0]["payload"],
410            base64::engine::general_purpose::STANDARD.encode(garbage)
411        );
412    }
413
414    /// A body packed with tiny frames stops being captured after the per-request cap, with one
415    /// event recording the truncation.
416    #[tokio::test]
417    async fn capture_stops_after_max_messages() {
418        // 70 empty messages: each frame is just the 5-byte prefix.
419        let events = capture_events(
420            "grpc_request=trace",
421            health_layer(),
422            "/grpc.health.v1.Health/Check",
423            "application/grpc",
424            Full::new(Bytes::from(vec![0u8; 5 * 70])),
425        )
426        .await;
427
428        assert_eq!(events.len(), 2);
429        assert_eq!(events[0]["payload"], "");
430        assert!(events[1]["message"].contains("too many messages"));
431        assert_eq!(events[1]["message_count"], "1");
432    }
433}