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}