taskfast_client/corr.rs
1// SPDX-License-Identifier: MIT
2//! Server correlation-id (`x-request-id`) capture for the CLI trace (gh#85).
3//!
4//! Every TaskFast response carries an `x-request-id` header that the server's
5//! `mix taskfast.trace <task_id>` joins on. The CLI needs that value to write a
6//! correlated trace line, but ~50 call sites discard the response headers at
7//! `ResponseValue::into_inner()`. Rather than thread the header through each
8//! one, [`record_corr`] is wired as progenitor's **post-hook** (see
9//! `build.rs`), so it fires for every generated request — success or error —
10//! at one place. Hand-rolled reqwest calls capture via [`record_corr_response`].
11//!
12//! Storage is a process-global "last seen" cell. One CLI process runs exactly
13//! one command, so the most recent HTTP call's id is the right `corr` for that
14//! command's trace line.
15// ponytail: process-global "last seen" cell — one process == one command, so
16// no per-task keying needed. Revisit only if the CLI ever runs concurrent ops.
17
18use std::sync::Mutex;
19
20static LAST_CORR: Mutex<Option<String>> = Mutex::new(None);
21
22const HEADER: &str = "x-request-id";
23
24/// Progenitor post-hook: records the correlation id from a completed request.
25///
26/// The generated client invokes `(crate::record_corr)(&result)` after every
27/// `exec`, before the response is unwrapped, so this sees both 2xx and error
28/// (4xx/5xx) responses — those still join, so their id is kept.
29///
30/// A transport failure (`Err`) carries no response, so it *clears* the stored
31/// id: otherwise a later transport error in a multi-call command would leave a
32/// stale `corr` from an earlier request and wrongly join the failure's line.
33pub fn record_corr(result: &Result<reqwest::Response, reqwest::Error>) {
34 match result {
35 Ok(resp) => record_corr_response(resp),
36 Err(_) => {
37 if let Ok(mut slot) = LAST_CORR.lock() {
38 *slot = None;
39 }
40 }
41 }
42}
43
44/// Capture the correlation header from a raw response. Used by the few
45/// hand-rolled reqwest calls that bypass the generated client.
46pub fn record_corr_response(resp: &reqwest::Response) {
47 if let Some(id) = resp.headers().get(HEADER).and_then(|h| h.to_str().ok()) {
48 if let Ok(mut slot) = LAST_CORR.lock() {
49 *slot = Some(id.to_string());
50 }
51 }
52}
53
54/// Take and clear the last captured correlation id.
55pub fn take_last_corr() -> Option<String> {
56 LAST_CORR.lock().ok().and_then(|mut slot| slot.take())
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62
63 fn response_with(id: Option<&str>) -> reqwest::Response {
64 let mut builder = http::Response::builder().status(200);
65 if let Some(id) = id {
66 builder = builder.header(HEADER, id);
67 }
68 reqwest::Response::from(builder.body("{}").unwrap())
69 }
70
71 // One test, not two: `LAST_CORR` is process-global, and the test runner
72 // would race two functions that both touch it.
73 #[test]
74 fn capture_take_missing_header_and_transport_error() {
75 let _ = take_last_corr(); // start clean
76
77 // Captures from an Ok response carrying the header.
78 record_corr(&Ok(response_with(Some("req-123"))));
79 assert_eq!(take_last_corr().as_deref(), Some("req-123"));
80
81 // take() clears: a second take with no new capture sees nothing.
82 assert_eq!(take_last_corr(), None);
83
84 // A response without the header must not overwrite the prior value.
85 record_corr(&Ok(response_with(Some("req-xyz"))));
86 record_corr(&Ok(response_with(None)));
87 assert_eq!(take_last_corr().as_deref(), Some("req-xyz"));
88
89 // A transport failure clears the stored id so a failure trace line
90 // cannot carry a stale `corr`. (error_for_status on a 5xx yields the
91 // Err(reqwest::Error) we need without a live socket.)
92 let err = reqwest::Response::from(http::Response::builder().status(500).body("").unwrap())
93 .error_for_status();
94 assert!(err.is_err());
95 record_corr(&err);
96 assert_eq!(take_last_corr(), None);
97 }
98}