Skip to main content

vgi_rpc/
hooks.rs

1//! Dispatch hook interface used by observability integrations.
2//!
3//! Each call dispatches through `on_dispatch_start` before the handler runs
4//! and `on_dispatch_end` after completion (success or error). The hook
5//! receives `CallStatistics` tallied by the framework and may record
6//! spans / metrics / sentry events.
7
8use std::sync::{Arc, Mutex};
9
10use crate::errors::RpcError;
11use crate::wire::Metadata;
12
13/// A record that cannot be written yet because it is still missing a figure
14/// only the transport knows. Called with the final on-wire response size, or
15/// `None` when that size is unknowable (a streamed body with no length).
16pub type DeferredRecord = Box<dyn FnOnce(Option<u64>) + Send>;
17
18/// Holds records back until the response they describe actually exists.
19///
20/// A handler knows what it produced; it does not know what was sent. Response
21/// compression runs after the handler returns, so a record emitted there can
22/// only ever report the uncompressed body — which is the wrong number for
23/// anything that costs money. A transport that can measure the final body
24/// installs a sink here, hooks defer into it, and the transport drains it once
25/// the body is final. A transport that installs no sink gets inline emission,
26/// so the immediate-vs-deferred choice is made in exactly one place.
27#[derive(Clone, Default)]
28pub struct AccessSink {
29    inner: Arc<SinkInner>,
30}
31
32#[derive(Default)]
33struct SinkInner {
34    pending: Mutex<Vec<DeferredRecord>>,
35}
36
37impl SinkInner {
38    fn drain(&self, response_bytes: Option<u64>) {
39        let pending = match self.pending.lock() {
40            Ok(mut p) => std::mem::take(&mut *p),
41            Err(_) => return,
42        };
43        for record in pending {
44            record(response_bytes);
45        }
46    }
47}
48
49impl Drop for SinkInner {
50    /// A sink nobody drained still emits, minus the size it was waiting on.
51    /// Losing a record because a transport forgot to drain would be
52    /// indistinguishable, to a log reader, from a call that never happened.
53    fn drop(&mut self) {
54        self.drain(None);
55    }
56}
57
58impl AccessSink {
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Queue a record for emission once the response size is known.
64    pub fn defer(&self, record: DeferredRecord) {
65        if let Ok(mut pending) = self.inner.pending.lock() {
66            pending.push(record);
67        }
68    }
69
70    /// Emit every deferred record, stamping `response_bytes` when known.
71    pub fn emit(&self, response_bytes: Option<u64>) {
72        self.inner.drain(response_bytes);
73    }
74
75    /// True when no record is waiting — the transport can skip attaching it.
76    pub fn is_empty(&self) -> bool {
77        self.inner
78            .pending
79            .lock()
80            .map(|p| p.is_empty())
81            .unwrap_or(true)
82    }
83}
84
85impl std::fmt::Debug for AccessSink {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("AccessSink")
88            .field("pending", &!self.is_empty())
89            .finish()
90    }
91}
92
93/// Per-call statistics accumulated during dispatch.
94///
95/// All fields start at zero and are incremented by the server as batches
96/// are read/written. Values are a best-effort snapshot at the moment the
97/// `on_dispatch_end` hook fires.
98#[derive(Clone, Debug, Default)]
99pub struct CallStatistics {
100    pub input_batches: u64,
101    pub output_batches: u64,
102    pub input_rows: u64,
103    pub output_rows: u64,
104    pub input_bytes: u64,
105    pub output_bytes: u64,
106}
107
108/// Information passed to a dispatch hook at start and end of each call.
109///
110/// `Default` exists so a hook test (or a transport that only fills a few
111/// fields) can use struct-update syntax and not be broken by a field added
112/// later; the defaults are inert, not meaningful.
113#[derive(Clone, Debug, Default)]
114pub struct DispatchInfo {
115    pub method: String,
116    pub method_type: &'static str,
117    pub server_id: String,
118    /// Logical service / protocol name.
119    pub protocol: String,
120    /// SHA-256 hex of the canonical __describe__ payload (always required in access log).
121    pub protocol_hash: String,
122    /// Operator-supplied free-form protocol-contract version label (optional).
123    pub protocol_version: String,
124    pub request_id: String,
125    /// Transport-level metadata (HTTP peer addr / pipe contextvar payload).
126    pub transport_metadata: Arc<Metadata>,
127    /// Authenticated principal name, empty when anonymous.
128    pub principal: String,
129    /// Authentication domain identifier, empty when anonymous.
130    pub auth_domain: String,
131    /// True when the call was authenticated.
132    pub authenticated: bool,
133    /// HTTP transport: remote IP:port. Empty otherwise.
134    pub remote_addr: String,
135    /// HTTP transport: response status; 0 when not applicable.
136    pub http_status: u16,
137    /// Self-contained Arrow IPC stream of the request batch (unary + stream init only).
138    pub request_data: Vec<u8>,
139    /// Stream lifecycle identifier (32-char lowercase hex); empty on unary.
140    pub stream_id: String,
141    /// True when a stream was cancelled by the client.
142    pub cancelled: bool,
143    /// Authentication claims — e.g. decoded JWT claims, X.509 cert
144    /// extensions, OAuth introspection fields. Cloned from
145    /// [`AuthContext::claims`](crate::auth::AuthContext::claims) at
146    /// dispatch start. Used by the Sentry hook to enrich user / tag
147    /// fields per Python `2d93987`.
148    pub claims: std::collections::BTreeMap<String, String>,
149    /// On-wire size of the request body as received, **before**
150    /// decompression — what the peer actually sent. `None` on transports
151    /// with no discrete request body (pipe / unix / tcp), where the framing
152    /// is a continuous IPC stream rather than a message with a length.
153    ///
154    /// Distinct from [`CallStatistics::input_bytes`], which counts logical
155    /// Arrow buffers after decoding and is routinely orders of magnitude
156    /// larger. One figure is what egress is billed on, the other what the
157    /// worker had to process.
158    pub request_bytes: Option<u64>,
159    /// Bytes uploaded to external storage during this call. Externalised
160    /// payloads leave only a pointer batch on the wire, so transport-level
161    /// accounting cannot see them at all — and they are frequently the
162    /// largest of the three byte figures.
163    pub externalized_bytes: u64,
164    /// Where a hook parks a record it cannot finish yet. `None` means emit
165    /// inline. See [`AccessSink`].
166    pub access_sink: Option<AccessSink>,
167}
168
169impl DispatchInfo {
170    /// Build a `DispatchInfo` from the serving server + request + resolved
171    /// auth context. `method_type` is either `"unary"` or `"stream"`.
172    pub fn from_request(
173        server: &crate::server::RpcServer,
174        req: &crate::server::Request,
175        method_type: &'static str,
176        auth: &crate::auth::AuthContext,
177    ) -> Self {
178        Self {
179            method: req.method.clone(),
180            method_type,
181            server_id: server.server_id.clone(),
182            protocol: server.protocol_name().to_string(),
183            protocol_hash: server.protocol_hash().to_string(),
184            protocol_version: server.protocol_version().to_string(),
185            request_id: req.request_id.clone(),
186            transport_metadata: req.metadata.clone(),
187            principal: auth.principal.clone(),
188            auth_domain: auth.domain.clone(),
189            authenticated: auth.authenticated,
190            remote_addr: String::new(),
191            http_status: 0,
192            request_data: Vec::new(),
193            stream_id: String::new(),
194            cancelled: false,
195            claims: auth.claims.clone(),
196            request_bytes: None,
197            externalized_bytes: 0,
198            access_sink: None,
199        }
200    }
201}
202
203/// Token returned by a hook's start callback and passed back to `on_end`.
204pub type HookToken = u64;
205
206/// Trait implemented by dispatch observability hooks.
207pub trait DispatchHook: Send + Sync {
208    /// Invoked just before the handler runs. Return a token that will be
209    /// passed to `on_dispatch_end`.
210    fn on_dispatch_start(&self, info: &DispatchInfo) -> HookToken;
211
212    /// Invoked once the handler has returned and all logs/batches have been
213    /// written to the transport.
214    fn on_dispatch_end(
215        &self,
216        token: HookToken,
217        info: &DispatchInfo,
218        error: Option<&RpcError>,
219        stats: &CallStatistics,
220    );
221}
222
223/// A shared reference to a boxed hook.
224pub type SharedHook = Arc<dyn DispatchHook>;
225
226/// A hook that delegates to two hooks in sequence.
227pub struct ChainHook {
228    inner: Vec<SharedHook>,
229}
230
231impl ChainHook {
232    pub fn new(hooks: Vec<SharedHook>) -> Self {
233        Self { inner: hooks }
234    }
235}
236
237impl DispatchHook for ChainHook {
238    fn on_dispatch_start(&self, info: &DispatchInfo) -> HookToken {
239        // Tokens aren't individually recoverable here; each inner hook gets
240        // a best-effort fresh token. Callers that need per-hook tokens can
241        // wrap them individually.
242        for h in &self.inner {
243            let _ = h.on_dispatch_start(info);
244        }
245        0
246    }
247
248    fn on_dispatch_end(
249        &self,
250        token: HookToken,
251        info: &DispatchInfo,
252        error: Option<&RpcError>,
253        stats: &CallStatistics,
254    ) {
255        for h in &self.inner {
256            h.on_dispatch_end(token, info, error, stats);
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::sync::atomic::{AtomicU64, Ordering};
265
266    struct CountingHook {
267        starts: AtomicU64,
268        ends: AtomicU64,
269    }
270
271    impl DispatchHook for CountingHook {
272        fn on_dispatch_start(&self, _info: &DispatchInfo) -> HookToken {
273            self.starts.fetch_add(1, Ordering::Relaxed) + 1
274        }
275        fn on_dispatch_end(
276            &self,
277            _token: HookToken,
278            _info: &DispatchInfo,
279            _error: Option<&RpcError>,
280            _stats: &CallStatistics,
281        ) {
282            self.ends.fetch_add(1, Ordering::Relaxed);
283        }
284    }
285
286    #[test]
287    fn chain_hook_fans_out() {
288        let a = Arc::new(CountingHook {
289            starts: AtomicU64::new(0),
290            ends: AtomicU64::new(0),
291        });
292        let b = Arc::new(CountingHook {
293            starts: AtomicU64::new(0),
294            ends: AtomicU64::new(0),
295        });
296        let chain = ChainHook::new(vec![a.clone(), b.clone()]);
297        let info = DispatchInfo {
298            method: "echo".into(),
299            method_type: "unary",
300            server_id: "test".into(),
301            ..Default::default()
302        };
303        let token = chain.on_dispatch_start(&info);
304        chain.on_dispatch_end(token, &info, None, &CallStatistics::default());
305        assert_eq!(a.starts.load(Ordering::Relaxed), 1);
306        assert_eq!(b.starts.load(Ordering::Relaxed), 1);
307        assert_eq!(a.ends.load(Ordering::Relaxed), 1);
308        assert_eq!(b.ends.load(Ordering::Relaxed), 1);
309    }
310}