Skip to main content

sc_observability_types/
diagnostic.rs

1use std::backtrace::Backtrace;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6
7use crate::{ErrorCode, Timestamp, sealed};
8
9/// Ordered recovery steps for a recoverable diagnostic.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct RecoverableSteps {
12    steps: Vec<String>,
13}
14
15impl RecoverableSteps {
16    /// Creates a recoverable step list containing exactly one first action.
17    #[must_use]
18    pub fn first(step: impl Into<String>) -> Self {
19        Self {
20            steps: vec![step.into()],
21        }
22    }
23
24    /// Creates a recoverable step list from a full ordered set of actions.
25    #[must_use]
26    pub fn all(steps: impl IntoIterator<Item = impl Into<String>>) -> Self {
27        Self {
28            steps: steps.into_iter().map(Into::into).collect(),
29        }
30    }
31
32    /// Returns the first recommended recovery step, if present.
33    #[must_use]
34    pub fn first_step(&self) -> Option<&str> {
35        self.steps.first().map(String::as_str)
36    }
37
38    /// Returns all ordered recovery steps.
39    #[must_use]
40    pub fn steps(&self) -> &[String] {
41        &self.steps
42    }
43}
44
45/// Required remediation metadata attached to every diagnostic.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum Remediation {
49    /// The caller can recover by following the ordered steps.
50    Recoverable {
51        /// Ordered recovery steps the caller can take.
52        steps: RecoverableSteps,
53    },
54    /// The caller cannot recover automatically and must accept the justification.
55    NotRecoverable {
56        /// Reason the failure cannot be recovered automatically.
57        justification: String,
58    },
59}
60
61impl Remediation {
62    /// Builds a recoverable remediation with one required first step and any remaining ordered steps.
63    #[must_use]
64    pub fn recoverable(
65        first: impl Into<String>,
66        rest: impl IntoIterator<Item = impl Into<String>>,
67    ) -> Self {
68        let mut steps = vec![first.into()];
69        steps.extend(rest.into_iter().map(Into::into));
70        Self::Recoverable {
71            steps: RecoverableSteps::all(steps),
72        }
73    }
74
75    /// Builds a non-recoverable remediation with the required justification.
76    #[must_use]
77    pub fn not_recoverable(justification: impl Into<String>) -> Self {
78        Self::NotRecoverable {
79            justification: justification.into(),
80        }
81    }
82}
83
84/// Structured diagnostic payload reusable across CLI, logging, and telemetry.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct Diagnostic {
87    /// UTC timestamp when the diagnostic was created.
88    pub timestamp: Timestamp,
89    /// Stable machine-readable error code.
90    pub code: ErrorCode,
91    /// Human-readable summary message.
92    pub message: String,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    /// Optional human-readable cause string.
95    pub cause: Option<String>,
96    /// Required remediation guidance.
97    pub remediation: Remediation,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    /// Optional documentation reference or URL.
100    pub docs: Option<String>,
101    #[serde(default, skip_serializing_if = "Map::is_empty")]
102    /// Structured machine-readable details.
103    pub details: Map<String, Value>,
104}
105
106/// Trait for public error surfaces that can expose an attached diagnostic.
107pub trait DiagnosticInfo: sealed::Sealed {
108    /// Returns the structured diagnostic attached to this error surface.
109    fn diagnostic(&self) -> &Diagnostic;
110}
111
112/// Small diagnostic summary used in health and last-error reporting.
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct DiagnosticSummary {
115    /// Optional stable error code for the last reported failure.
116    pub code: Option<ErrorCode>,
117    /// Human-readable summary message.
118    pub message: String,
119    /// UTC timestamp when the summarized diagnostic occurred.
120    pub at: Timestamp,
121}
122
123impl From<&Diagnostic> for DiagnosticSummary {
124    fn from(value: &Diagnostic) -> Self {
125        Self {
126            code: Some(value.code.clone()),
127            message: value.message.clone(),
128            at: value.timestamp,
129        }
130    }
131}
132
133/// Builder-style context wrapper used by public crate error types.
134#[derive(Debug, Serialize, Deserialize)]
135pub struct ErrorContext {
136    diagnostic: Diagnostic,
137    #[serde(skip, default = "capture_backtrace")]
138    backtrace: Backtrace,
139    #[serde(skip)]
140    source: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
141}
142
143impl PartialEq for ErrorContext {
144    fn eq(&self, other: &Self) -> bool {
145        self.diagnostic == other.diagnostic
146            && self.source.as_ref().map(ToString::to_string)
147                == other.source.as_ref().map(ToString::to_string)
148    }
149}
150
151impl ErrorContext {
152    /// Creates a new error context with the required code, message, and remediation.
153    #[must_use]
154    pub fn new(code: ErrorCode, message: impl Into<String>, remediation: Remediation) -> Self {
155        Self {
156            diagnostic: Diagnostic {
157                timestamp: Timestamp::now_utc(),
158                code,
159                message: message.into(),
160                cause: None,
161                remediation,
162                docs: None,
163                details: Map::new(),
164            },
165            backtrace: capture_backtrace(),
166            source: None,
167        }
168    }
169
170    /// Adds a human-readable cause string to the error context.
171    #[must_use]
172    pub fn cause(mut self, cause: impl Into<String>) -> Self {
173        self.diagnostic.cause = Some(cause.into());
174        self
175    }
176
177    /// Adds a documentation reference string to the error context.
178    #[must_use]
179    pub fn docs(mut self, docs: impl Into<String>) -> Self {
180        self.diagnostic.docs = Some(docs.into());
181        self
182    }
183
184    /// Adds one structured detail field to the error context.
185    #[must_use]
186    pub fn detail(mut self, key: impl Into<String>, value: Value) -> Self {
187        self.diagnostic.details.insert(key.into(), value);
188        self
189    }
190
191    /// Captures the real source error for display and error chaining.
192    #[must_use]
193    pub fn source(mut self, source: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
194        self.source = Some(Arc::from(source));
195        self
196    }
197
198    /// Returns the structured diagnostic carried by this error context.
199    #[must_use]
200    pub fn diagnostic(&self) -> &Diagnostic {
201        &self.diagnostic
202    }
203
204    /// Returns the captured construction backtrace.
205    pub fn backtrace(&self) -> &Backtrace {
206        &self.backtrace
207    }
208}
209
210impl std::fmt::Display for ErrorContext {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        write!(f, "{}", self.diagnostic.message)?;
213        if let Some(cause) = &self.diagnostic.cause {
214            write!(f, ": {cause}")?;
215        }
216        if let Some(source) = std::error::Error::source(self) {
217            write!(f, "; caused by: {source}")?;
218        }
219        Ok(())
220    }
221}
222
223impl std::error::Error for ErrorContext {
224    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
225        self.source
226            .as_deref()
227            .map(|source| source as &(dyn std::error::Error + 'static))
228    }
229}
230
231fn capture_backtrace() -> Backtrace {
232    Backtrace::capture()
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use serde_json::json;
239
240    use crate::{IdentityError, error_codes};
241
242    #[test]
243    fn remediation_construction_helpers_cover_both_variants() {
244        let recoverable = Remediation::recoverable("fix the input", ["retry"]);
245        let not_recoverable = Remediation::not_recoverable("manual intervention required");
246        let first_only = RecoverableSteps::first("first");
247        let all_steps = RecoverableSteps::all(["first", "second"]);
248
249        match recoverable {
250            Remediation::Recoverable { steps } => {
251                assert_eq!(steps.first_step(), Some("fix the input"));
252                assert_eq!(
253                    steps.steps(),
254                    ["fix the input".to_string(), "retry".to_string()]
255                );
256            }
257            Remediation::NotRecoverable { .. } => panic!("expected recoverable remediation"),
258        }
259
260        match not_recoverable {
261            Remediation::NotRecoverable { justification } => {
262                assert_eq!(justification, "manual intervention required");
263            }
264            Remediation::Recoverable { .. } => panic!("expected non-recoverable remediation"),
265        }
266
267        assert_eq!(first_only.first_step(), Some("first"));
268        assert_eq!(first_only.steps(), ["first".to_string()]);
269        assert_eq!(
270            all_steps.steps(),
271            ["first".to_string(), "second".to_string()]
272        );
273    }
274
275    #[test]
276    fn error_context_display_includes_cause_when_present() {
277        let error = ErrorContext::new(
278            error_codes::DIAGNOSTIC_INVALID,
279            "operation failed",
280            Remediation::recoverable("fix the config", ["retry"]),
281        )
282        .cause("missing field");
283
284        assert_eq!(error.to_string(), "operation failed: missing field");
285    }
286
287    #[test]
288    fn error_context_display_includes_source_chain_when_present() {
289        let error = ErrorContext::new(
290            error_codes::DIAGNOSTIC_INVALID,
291            "operation failed",
292            Remediation::not_recoverable("investigate"),
293        )
294        .cause("missing field")
295        .source(Box::new(std::io::Error::other("disk full")));
296
297        assert_eq!(
298            error.to_string(),
299            "operation failed: missing field; caused by: disk full"
300        );
301    }
302
303    #[test]
304    fn error_context_builder_sets_docs_details_and_source() {
305        let error = ErrorContext::new(
306            error_codes::DIAGNOSTIC_INVALID,
307            "operation failed",
308            Remediation::not_recoverable("investigate manually"),
309        )
310        .docs("https://example.test/failure")
311        .detail("attempt", json!(3))
312        .source(Box::new(std::io::Error::other("disk full")));
313
314        assert_eq!(
315            error.diagnostic().docs.as_deref(),
316            Some("https://example.test/failure")
317        );
318        assert_eq!(error.diagnostic().details.get("attempt"), Some(&json!(3)));
319        assert_eq!(
320            error.source.as_ref().map(ToString::to_string).as_deref(),
321            Some("disk full")
322        );
323        assert_eq!(
324            std::error::Error::source(&error)
325                .map(ToString::to_string)
326                .as_deref(),
327            Some("disk full")
328        );
329        assert!(!matches!(
330            error.backtrace().status(),
331            std::backtrace::BacktraceStatus::Unsupported
332        ));
333    }
334
335    #[test]
336    fn diagnostic_round_trips_through_serde() {
337        let original = Diagnostic {
338            timestamp: Timestamp::UNIX_EPOCH,
339            code: error_codes::DIAGNOSTIC_INVALID,
340            message: "diagnostic invalid".to_string(),
341            cause: Some("invalid example".to_string()),
342            remediation: Remediation::recoverable(
343                "fix the input",
344                ["rerun the command", "review the docs"],
345            ),
346            docs: Some("https://example.test/docs".to_string()),
347            details: Map::from_iter([("key".to_string(), json!("value"))]),
348        };
349        let encoded = serde_json::to_string(&original).expect("serialize diagnostic");
350        let decoded: Diagnostic = serde_json::from_str(&encoded).expect("deserialize diagnostic");
351        assert_eq!(decoded, original);
352    }
353
354    #[test]
355    fn diagnostic_summary_captures_code_and_message() {
356        let diagnostic = Diagnostic {
357            timestamp: Timestamp::UNIX_EPOCH,
358            code: error_codes::DIAGNOSTIC_INVALID,
359            message: "diagnostic invalid".to_string(),
360            cause: None,
361            remediation: Remediation::recoverable("fix the input", ["retry"]),
362            docs: None,
363            details: Map::new(),
364        };
365        let summary = DiagnosticSummary::from(&diagnostic);
366
367        assert_eq!(summary.code, Some(error_codes::DIAGNOSTIC_INVALID));
368        assert_eq!(summary.message, "diagnostic invalid");
369        assert_eq!(summary.at, Timestamp::UNIX_EPOCH);
370    }
371
372    #[test]
373    fn identity_error_exposes_inner_diagnostic() {
374        let context = ErrorContext::new(
375            error_codes::IDENTITY_RESOLUTION_FAILED,
376            "failed to resolve process identity",
377            Remediation::not_recoverable("configure a valid identity source"),
378        )
379        .detail("source", json!("test"));
380        let error = IdentityError(Box::new(context));
381
382        assert_eq!(
383            error.diagnostic().code,
384            error_codes::IDENTITY_RESOLUTION_FAILED
385        );
386        assert_eq!(
387            error.diagnostic().message,
388            "failed to resolve process identity"
389        );
390        assert_eq!(
391            error.diagnostic().details.get("source"),
392            Some(&json!("test"))
393        );
394    }
395}