Skip to main content

fizzy_sdk/
observability.rs

1//! What the SDK tells an application about the calls it makes: which operation is running,
2//! which HTTP requests it takes, and what each one answered.
3
4use std::any::Any;
5use std::borrow::Cow;
6use std::sync::Arc;
7use std::time::Duration;
8
9use crate::http::{Method, StatusCode};
10use async_trait::async_trait;
11use url::Url;
12
13use crate::error::Error;
14
15/// The semantic identity of a call: the service and operation as the model names them,
16/// the kind of record they touch, and whether they change anything.
17///
18/// This is what the call *means*, not what goes over the wire. A hand-written wrapper is
19/// free to report itself as something other than the route it sends — a wrapper that
20/// signs in through a magic link says `MagicLinkLogin`.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct OperationInfo {
23    /// The service handle the call came from: `Boards`, `AccessTokens`.
24    pub service: Cow<'static, str>,
25    /// The operation as the model names it: `ListBoards`, `MoveCard`.
26    pub operation: Cow<'static, str>,
27    /// The kind of record the call acts on, snake_cased: `board`, `card`.
28    pub resource_type: Cow<'static, str>,
29    /// The call changes something.
30    pub is_mutation: bool,
31    /// The record the path names, when it names one.
32    pub resource_id: Option<String>,
33}
34
35/// One HTTP request the SDK is about to make, or has just made. `attempt` counts from 1
36/// across the whole operation, the resend after a credential refresh included.
37#[derive(Debug, Clone)]
38pub struct RequestInfo {
39    /// The HTTP method.
40    pub method: Method,
41    /// Where it went, query and all.
42    pub url: Url,
43    /// Which attempt this is, counting from 1.
44    pub attempt: u32,
45}
46
47/// How one HTTP request turned out.
48#[derive(Debug)]
49pub struct RequestResult<'a> {
50    /// What Fizzy answered, or `None` when nothing came back at all.
51    pub status: Option<StatusCode>,
52    /// How long Fizzy took to answer, before the body was read.
53    pub duration: Duration,
54    /// What this request failed with, whether or not the SDK went on to resend it.
55    pub error: Option<&'a Error>,
56    /// The body came out of the response cache: Fizzy answered 304 and the SDK read the
57    /// entry it was holding.
58    pub from_cache: bool,
59    /// The SDK would resend this, given an attempt to spare.
60    pub retryable: bool,
61    /// The seconds `Retry-After` named, on the statuses that carry one.
62    pub retry_after: Option<u64>,
63}
64
65impl<'a> RequestResult<'a> {
66    /// A request that ended in `error`, whether or not it is about to be resent.
67    pub(crate) fn failed(
68        status: Option<StatusCode>,
69        duration: Duration,
70        error: &'a Error,
71        retryable: bool,
72        retry_after: Option<u64>,
73    ) -> RequestResult<'a> {
74        RequestResult {
75            status,
76            duration,
77            error: Some(error),
78            from_cache: false,
79            retryable,
80            retry_after,
81        }
82    }
83}
84
85/// Whatever [`Hooks::on_operation_start`] hands the matching [`Hooks::on_operation_end`].
86pub type OperationState = Option<Box<dyn Any + Send>>;
87
88/// The callbacks the SDK makes as it works. Every one does nothing by default, so an
89/// implementation says only what it cares about.
90///
91/// [`Hooks::on_operation_start`] can hand back a value — a span, a timer, a correlation
92/// id — which the SDK carries through the operation and gives back to
93/// [`Hooks::on_operation_end`]. It arrives boxed as [`Any`], so take it back with
94/// `downcast`:
95///
96/// ```
97/// use std::time::{Duration, Instant};
98///
99/// use fizzy_sdk::Error;
100/// use fizzy_sdk::observability::{Hooks, OperationInfo, OperationState};
101///
102/// struct Timing;
103///
104/// impl Hooks for Timing {
105///     fn on_operation_start(&self, _op: &OperationInfo) -> OperationState {
106///         Some(Box::new(Instant::now()))
107///     }
108///
109///     fn on_operation_end(
110///         &self,
111///         op: &OperationInfo,
112///         state: OperationState,
113///         _outcome: Result<(), &Error>,
114///         _duration: Duration,
115///     ) {
116///         if let Some(started) = state.and_then(|state| state.downcast::<Instant>().ok()) {
117///             println!("{} took {:?}", op.operation, started.elapsed());
118///         }
119///     }
120/// }
121/// ```
122#[async_trait]
123pub trait Hooks: Send + Sync {
124    /// Asked before anything is sent. An `Err` abandons the operation and becomes its
125    /// answer, so a policy can refuse a call before it reaches Fizzy.
126    ///
127    /// It is the one callback that may wait: a policy that admits calls a few at a time can
128    /// hold the caller until there is room rather than turning it away — which is how
129    /// [`BulkheadConfig::max_wait`](crate::resilience::BulkheadConfig::max_wait) is spent.
130    /// The rest are told what happened and are not asked to decide anything, so they stay
131    /// synchronous.
132    async fn on_operation_gate(&self, _op: &OperationInfo) -> Result<(), Error> {
133        Ok(())
134    }
135
136    /// Told an operation is starting. Whatever comes back is handed to
137    /// [`Hooks::on_operation_end`].
138    fn on_operation_start(&self, _op: &OperationInfo) -> OperationState {
139        None
140    }
141
142    /// Told that an operation this implementation admitted through its gate will not start
143    /// after all: a later member of a [`ChainHooks`] refused it. Whatever the gate set aside
144    /// for the operation is given back here.
145    fn on_operation_abandoned(&self, _op: &OperationInfo) {}
146
147    /// Told how the operation ended and how long the whole of it took, requests, waits
148    /// and all.
149    fn on_operation_end(
150        &self,
151        _op: &OperationInfo,
152        _state: OperationState,
153        _outcome: Result<(), &Error>,
154        _duration: Duration,
155    ) {
156    }
157
158    /// Told a request is about to go out.
159    fn on_request_start(&self, _info: &RequestInfo) {}
160
161    /// Told how a request turned out, whether or not it is resent.
162    fn on_request_end(&self, _info: &RequestInfo, _result: &RequestResult<'_>) {}
163
164    /// Told about a resend before it is made: the attempt that failed in `info`, the one
165    /// about to be made as `next_attempt`, and what prompted it.
166    fn on_retry(&self, _info: &RequestInfo, _next_attempt: u32, _cause: &Error) {}
167
168    /// Answers `true` when this implementation does nothing, so [`ChainHooks`] can leave
169    /// it out.
170    fn is_noop(&self) -> bool {
171        false
172    }
173}
174
175#[async_trait]
176impl<H: Hooks + ?Sized> Hooks for Arc<H> {
177    async fn on_operation_gate(&self, op: &OperationInfo) -> Result<(), Error> {
178        (**self).on_operation_gate(op).await
179    }
180
181    fn on_operation_start(&self, op: &OperationInfo) -> OperationState {
182        (**self).on_operation_start(op)
183    }
184
185    fn on_operation_abandoned(&self, op: &OperationInfo) {
186        (**self).on_operation_abandoned(op);
187    }
188
189    fn on_operation_end(
190        &self,
191        op: &OperationInfo,
192        state: OperationState,
193        outcome: Result<(), &Error>,
194        duration: Duration,
195    ) {
196        (**self).on_operation_end(op, state, outcome, duration);
197    }
198
199    fn on_request_start(&self, info: &RequestInfo) {
200        (**self).on_request_start(info);
201    }
202
203    fn on_request_end(&self, info: &RequestInfo, result: &RequestResult<'_>) {
204        (**self).on_request_end(info, result);
205    }
206
207    fn on_retry(&self, info: &RequestInfo, next_attempt: u32, cause: &Error) {
208        (**self).on_retry(info, next_attempt, cause);
209    }
210
211    fn is_noop(&self) -> bool {
212        (**self).is_noop()
213    }
214}
215
216/// Hooks that do nothing. What a client runs with until it is given others, and what a
217/// chain of nothing comes to.
218#[derive(Debug, Clone, Copy, Default)]
219pub struct NoopHooks;
220
221impl Hooks for NoopHooks {
222    fn is_noop(&self) -> bool {
223        true
224    }
225}
226
227/// Several [`Hooks`] as one. Gates, starts and retries run in order; ends run in reverse,
228/// so a hook that wraps the ones after it closes last, the way nested spans do.
229pub struct ChainHooks {
230    hooks: Vec<Arc<dyn Hooks>>,
231}
232
233impl ChainHooks {
234    /// Chains what is left once the no-ops are dropped. Nothing left is a [`NoopHooks`],
235    /// and one left is that hook itself — a chain of one is just the hook, which is why
236    /// this answers some [`Hooks`] rather than always a `ChainHooks`.
237    pub fn of(hooks: Vec<Arc<dyn Hooks>>) -> Arc<dyn Hooks> {
238        let mut installed: Vec<Arc<dyn Hooks>> =
239            hooks.into_iter().filter(|hook| !hook.is_noop()).collect();
240        if installed.is_empty() {
241            Arc::new(NoopHooks)
242        } else if installed.len() == 1 {
243            installed.remove(0)
244        } else {
245            Arc::new(ChainHooks { hooks: installed })
246        }
247    }
248}
249
250#[async_trait]
251impl Hooks for ChainHooks {
252    /// Asks every member in order and answers the first refusal. Go asks only the first
253    /// member that implements its separate gating interface; here gating is part of
254    /// [`Hooks`] itself, so the chain stops at whichever member refuses.
255    async fn on_operation_gate(&self, op: &OperationInfo) -> Result<(), Error> {
256        for (admitted, hook) in self.hooks.iter().enumerate() {
257            if let Err(refusal) = hook.on_operation_gate(op).await {
258                for earlier in self.hooks[..admitted].iter().rev() {
259                    earlier.on_operation_abandoned(op);
260                }
261                return Err(refusal);
262            }
263        }
264        Ok(())
265    }
266
267    fn on_operation_abandoned(&self, op: &OperationInfo) {
268        for hook in self.hooks.iter().rev() {
269            hook.on_operation_abandoned(op);
270        }
271    }
272
273    /// Keeps each member's own state, so [`ChainHooks::on_operation_end`] can hand every
274    /// one of them back what it made.
275    fn on_operation_start(&self, op: &OperationInfo) -> OperationState {
276        let states: Vec<OperationState> = self
277            .hooks
278            .iter()
279            .map(|hook| hook.on_operation_start(op))
280            .collect();
281        Some(Box::new(states))
282    }
283
284    fn on_operation_end(
285        &self,
286        op: &OperationInfo,
287        state: OperationState,
288        outcome: Result<(), &Error>,
289        duration: Duration,
290    ) {
291        let mut states = member_states(state);
292        for hook in self.hooks.iter().rev() {
293            hook.on_operation_end(op, states.pop().flatten(), outcome, duration);
294        }
295    }
296
297    fn on_request_start(&self, info: &RequestInfo) {
298        for hook in &self.hooks {
299            hook.on_request_start(info);
300        }
301    }
302
303    fn on_request_end(&self, info: &RequestInfo, result: &RequestResult<'_>) {
304        for hook in self.hooks.iter().rev() {
305            hook.on_request_end(info, result);
306        }
307    }
308
309    fn on_retry(&self, info: &RequestInfo, next_attempt: u32, cause: &Error) {
310        for hook in &self.hooks {
311            hook.on_retry(info, next_attempt, cause);
312        }
313    }
314}
315
316fn member_states(state: OperationState) -> Vec<OperationState> {
317    match state.and_then(|state| state.downcast::<Vec<OperationState>>().ok()) {
318        Some(states) => *states,
319        None => Vec::new(),
320    }
321}
322
323#[cfg(test)]
324#[allow(clippy::unwrap_used)]
325mod tests {
326    use std::sync::Mutex;
327
328    use super::*;
329    use crate::ErrorCode;
330
331    #[tokio::test]
332    async fn every_noop_callback_is_safe_to_call() {
333        let hooks = NoopHooks;
334        let op = operation_info();
335        let info = request_info();
336
337        hooks.on_operation_gate(&op).await.unwrap();
338        let state = hooks.on_operation_start(&op);
339        assert!(state.is_none());
340        hooks.on_request_start(&info);
341        hooks.on_request_end(&info, &request_result());
342        hooks.on_retry(&info, 2, &Error::usage("nothing"));
343        hooks.on_operation_end(&op, state, Ok(()), Duration::from_secs(1));
344
345        assert!(hooks.is_noop());
346    }
347
348    #[test]
349    fn a_chain_runs_forwards_and_unwinds_backwards() {
350        let log = Log::new();
351        let chain = ChainHooks::of(vec![log.recorder("first"), log.recorder("second")]);
352        let op = operation_info();
353
354        let state = chain.on_operation_start(&op);
355        chain.on_request_start(&request_info());
356        chain.on_request_end(&request_info(), &request_result());
357        chain.on_retry(&request_info(), 2, &Error::usage("nothing"));
358        chain.on_operation_end(&op, state, Ok(()), Duration::from_secs(1));
359
360        assert_eq!(
361            log.entries(),
362            [
363                "first: start Svc.Do",
364                "second: start Svc.Do",
365                "first: request start 1",
366                "second: request start 1",
367                "second: request end 200",
368                "first: request end 200",
369                "first: retry 2",
370                "second: retry 2",
371                "second: end Svc.Do carrying second",
372                "first: end Svc.Do carrying first",
373            ]
374        );
375    }
376
377    #[test]
378    fn a_chain_of_one_is_that_hook() {
379        let log = Log::new();
380        let recorder = log.recorder("only");
381
382        let chain = ChainHooks::of(vec![recorder.clone(), Arc::new(NoopHooks)]);
383
384        assert!(Arc::ptr_eq(&chain, &recorder));
385    }
386
387    #[test]
388    fn a_chain_of_nothing_but_noops_is_a_noop() {
389        assert!(ChainHooks::of(vec![Arc::new(NoopHooks), Arc::new(NoopHooks)]).is_noop());
390        assert!(ChainHooks::of(Vec::new()).is_noop());
391    }
392
393    #[tokio::test]
394    async fn a_chain_answers_the_first_refusal() {
395        let log = Log::new();
396        let chain = ChainHooks::of(vec![
397            log.recorder("first"),
398            Arc::new(Refusing),
399            log.recorder("third"),
400        ]);
401
402        let refused = chain
403            .on_operation_gate(&operation_info())
404            .await
405            .unwrap_err();
406
407        assert_eq!(refused.code(), ErrorCode::Usage);
408        assert_eq!(refused.message(), "blocked");
409        assert_eq!(log.entries(), ["first: gate Svc.Do"]);
410    }
411
412    /// One shared transcript, so a chain's members are seen in the order they were asked.
413    struct Log {
414        entries: Arc<Mutex<Vec<String>>>,
415    }
416
417    impl Log {
418        fn new() -> Log {
419            Log {
420                entries: Arc::new(Mutex::new(Vec::new())),
421            }
422        }
423
424        fn recorder(&self, name: &'static str) -> Arc<dyn Hooks> {
425            Arc::new(Recorder {
426                name,
427                entries: self.entries.clone(),
428            })
429        }
430
431        fn entries(&self) -> Vec<String> {
432            self.entries.lock().unwrap().clone()
433        }
434    }
435
436    struct Recorder {
437        name: &'static str,
438        entries: Arc<Mutex<Vec<String>>>,
439    }
440
441    impl Recorder {
442        fn record(&self, event: &str) {
443            self.entries
444                .lock()
445                .unwrap()
446                .push(format!("{}: {event}", self.name));
447        }
448    }
449
450    #[async_trait]
451    impl Hooks for Recorder {
452        async fn on_operation_gate(&self, op: &OperationInfo) -> Result<(), Error> {
453            self.record(&format!("gate {}.{}", op.service, op.operation));
454            Ok(())
455        }
456
457        fn on_operation_start(&self, op: &OperationInfo) -> OperationState {
458            self.record(&format!("start {}.{}", op.service, op.operation));
459            Some(Box::new(self.name.to_string()))
460        }
461
462        fn on_operation_end(
463            &self,
464            op: &OperationInfo,
465            state: OperationState,
466            _outcome: Result<(), &Error>,
467            _duration: Duration,
468        ) {
469            let carried = match state.and_then(|state| state.downcast::<String>().ok()) {
470                Some(name) => *name,
471                None => "nothing".to_string(),
472            };
473            self.record(&format!(
474                "end {}.{} carrying {carried}",
475                op.service, op.operation
476            ));
477        }
478
479        fn on_request_start(&self, info: &RequestInfo) {
480            self.record(&format!("request start {}", info.attempt));
481        }
482
483        fn on_request_end(&self, _info: &RequestInfo, result: &RequestResult<'_>) {
484            self.record(&format!("request end {}", result.status.unwrap().as_u16()));
485        }
486
487        fn on_retry(&self, _info: &RequestInfo, next_attempt: u32, _cause: &Error) {
488            self.record(&format!("retry {next_attempt}"));
489        }
490    }
491
492    struct Refusing;
493
494    #[async_trait]
495    impl Hooks for Refusing {
496        async fn on_operation_gate(&self, _op: &OperationInfo) -> Result<(), Error> {
497            Err(Error::usage("blocked"))
498        }
499    }
500
501    fn operation_info() -> OperationInfo {
502        OperationInfo {
503            service: Cow::Borrowed("Svc"),
504            operation: Cow::Borrowed("Do"),
505            resource_type: Cow::Borrowed("thing"),
506            is_mutation: false,
507            resource_id: None,
508        }
509    }
510
511    fn request_info() -> RequestInfo {
512        RequestInfo {
513            method: Method::GET,
514            url: Url::parse("https://fizzy.example/999/boards.json").unwrap(),
515            attempt: 1,
516        }
517    }
518
519    fn request_result() -> RequestResult<'static> {
520        RequestResult {
521            status: Some(StatusCode::OK),
522            duration: Duration::from_millis(3),
523            error: None,
524            from_cache: false,
525            retryable: false,
526            retry_after: None,
527        }
528    }
529}