Skip to main content

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