Skip to main content

agent_sdk/observability/
types.rs

1//! Core observability types and the `ObservabilityStore` trait.
2
3use super::payload::PayloadRedactor;
4use crate::types::ThreadId;
5use async_trait::async_trait;
6use std::sync::LazyLock;
7
8/// Process-wide noop redactor used by the default
9/// `ObservabilityStore::redactor` implementation — avoids allocating
10/// a fresh `Arc<NoopDetector>` on every call.
11static NOOP_REDACTOR: LazyLock<PayloadRedactor> = LazyLock::new(PayloadRedactor::noop);
12
13/// Identifies the kind of LLM payload capture.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CaptureKind {
16    /// Normal turn chat request/response.
17    TurnChat,
18    /// Compaction summarization request/response.
19    CompactionChat,
20}
21
22impl CaptureKind {
23    /// Low-cardinality string representation.
24    #[must_use]
25    pub const fn as_str(&self) -> &'static str {
26        match self {
27            Self::TurnChat => "turn_chat",
28            Self::CompactionChat => "compaction_chat",
29        }
30    }
31}
32
33/// Decision returned by the `ObservabilityStore` for each payload artifact.
34#[derive(Debug, Clone)]
35pub enum CaptureDecision {
36    /// Serialize the payload as a JSON span attribute inline.
37    Inline,
38    /// Store externally; record only this reference string on the span.
39    Reference(String),
40    /// Do not record this artifact.
41    Omit,
42}
43
44/// Structured payload bundle passed to the observability store.
45///
46/// Contains the `GenAI` semantic-convention-aligned payloads for a single
47/// LLM operation, plus metadata needed for external persistence.
48#[derive(Debug, Clone)]
49pub struct PayloadBundle {
50    /// Opaque SDK-generated identifier, unique per capture attempt.
51    pub capture_id: String,
52    /// Discriminator for the type of LLM operation.
53    pub capture_kind: CaptureKind,
54    /// Thread this operation belongs to.
55    pub thread_id: ThreadId,
56    /// Turn number within the current invocation.
57    pub turn_number: usize,
58    /// Canonical `gen_ai.provider.name` value.
59    pub provider_name: String,
60    /// Raw SDK provider identifier (e.g. `"openai-responses"`).
61    pub provider_id: String,
62    /// Whether the current LLM span is recording.
63    pub span_is_recording: bool,
64    /// Request model string.
65    pub request_model: String,
66    /// Response model string, if available.
67    pub response_model: Option<String>,
68    /// System instructions as semconv JSON value, if present.
69    pub system_instructions: Option<serde_json::Value>,
70    /// Input messages as semconv JSON value.
71    pub input_messages: serde_json::Value,
72    /// Output messages as semconv JSON value.
73    pub output_messages: serde_json::Value,
74}
75
76/// Per-artifact decisions returned by the store.
77#[derive(Debug, Clone)]
78pub struct CaptureResult {
79    /// Decision for system instructions.
80    pub system_instructions: CaptureDecision,
81    /// Decision for input messages.
82    pub input_messages: CaptureDecision,
83    /// Decision for output messages.
84    pub output_messages: CaptureDecision,
85}
86
87/// Async trait for `GenAI` payload capture.
88///
89/// Separate from `MessageStore` / `StateStore`. Called at the LLM
90/// instrumentation boundary to decide whether payloads are inlined,
91/// externalized, or omitted from spans.
92#[async_trait]
93pub trait ObservabilityStore: Send + Sync {
94    /// Capture or inspect the payload bundle for a single LLM operation.
95    ///
96    /// Called even when the current span is non-recording (the bundle
97    /// includes `span_is_recording` so the store can decide whether to
98    /// persist externally).
99    ///
100    /// # Errors
101    ///
102    /// Errors are logged and swallowed — they never fail the agent run.
103    async fn capture(&self, bundle: &PayloadBundle) -> anyhow::Result<CaptureResult>;
104
105    /// PII redactor applied to every payload converted for this store.
106    ///
107    /// The agent loop calls this once per LLM round-trip and uses
108    /// the returned redactor to mask PII in the system prompt, input
109    /// messages, and output messages before building the
110    /// [`PayloadBundle`]. The same masked JSON is then recorded on
111    /// the `OTel` span, so a single redaction pass covers both
112    /// external persistence and local tracing.
113    ///
114    /// The default returns a shared noop redactor — existing stores
115    /// keep their current byte-for-byte output. Stores that need
116    /// PII-aware redaction (recommended for financial / regulated
117    /// workloads) should override this with a
118    /// [`PayloadRedactor`] wrapping a detector such as
119    /// [`agent_sdk_foundation::privacy::BaselineDetector`].
120    fn redactor(&self) -> &PayloadRedactor {
121        &NOOP_REDACTOR
122    }
123
124    /// Affirm that this store has a real PII redactor installed and
125    /// is safe to honour [`CaptureDecision::Inline`].
126    ///
127    /// Returns `false` by default. The SDK gates every `Inline`
128    /// decision behind this method **and** the operator-facing
129    /// `OtelConfig::capture_payloads` flag — both must be true for
130    /// payloads to land on spans inline. Stores that have not
131    /// explicitly verified their redactor MUST leave the default in
132    /// place; otherwise the SDK silently drops payloads to protect
133    /// against PII leakage.
134    ///
135    /// [`CaptureDecision::Reference`] is **not** affected by this
136    /// gate — externalised payloads are always recorded as
137    /// references because the underlying content stays out of the
138    /// span entirely.
139    fn acknowledge_pii_redaction(&self) -> bool {
140        false
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::llm::Message;
148    use agent_sdk_foundation::ChatRequest;
149    use agent_sdk_foundation::privacy::BaselineDetector;
150    use std::sync::Arc;
151
152    struct NoopStore;
153
154    #[async_trait]
155    impl ObservabilityStore for NoopStore {
156        async fn capture(&self, _bundle: &PayloadBundle) -> anyhow::Result<CaptureResult> {
157            Ok(CaptureResult {
158                system_instructions: CaptureDecision::Omit,
159                input_messages: CaptureDecision::Omit,
160                output_messages: CaptureDecision::Omit,
161            })
162        }
163    }
164
165    struct PrivacyStore {
166        redactor: PayloadRedactor,
167    }
168
169    #[async_trait]
170    impl ObservabilityStore for PrivacyStore {
171        async fn capture(&self, _bundle: &PayloadBundle) -> anyhow::Result<CaptureResult> {
172            Ok(CaptureResult {
173                system_instructions: CaptureDecision::Omit,
174                input_messages: CaptureDecision::Omit,
175                output_messages: CaptureDecision::Omit,
176            })
177        }
178
179        fn redactor(&self) -> &PayloadRedactor {
180            &self.redactor
181        }
182    }
183
184    fn sample_request() -> ChatRequest {
185        ChatRequest {
186            system: String::new(),
187            messages: vec![Message::user("CPF 111.444.777-35 please")],
188            tools: None,
189            max_tokens: 1024,
190            max_tokens_explicit: false,
191            session_id: None,
192            cached_content: None,
193            thinking: None,
194            tool_choice: None,
195            response_format: None,
196        }
197    }
198
199    #[test]
200    fn default_redactor_is_noop() {
201        let store = NoopStore;
202        let result = store.redactor().convert_input_messages(&sample_request());
203        let text = result[0]["content"][0]["text"].as_str().expect("text");
204        // Default impl: no redaction — CPF flows through unchanged.
205        assert_eq!(text, "CPF 111.444.777-35 please");
206    }
207
208    #[test]
209    fn overridden_redactor_masks_pii() {
210        let store = PrivacyStore {
211            redactor: PayloadRedactor::new(Arc::new(
212                BaselineDetector::new().expect("baseline compiles"),
213            )),
214        };
215        let result = store.redactor().convert_input_messages(&sample_request());
216        let text = result[0]["content"][0]["text"].as_str().expect("text");
217        assert!(
218            text.contains("[REDACTED:cpf]"),
219            "expected CPF mask via trait, got {text}"
220        );
221        assert!(!text.contains("111.444.777-35"));
222    }
223}