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}