use super::payload::PayloadRedactor;
use crate::types::ThreadId;
use async_trait::async_trait;
use std::sync::LazyLock;
static NOOP_REDACTOR: LazyLock<PayloadRedactor> = LazyLock::new(PayloadRedactor::noop);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureKind {
TurnChat,
CompactionChat,
}
impl CaptureKind {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::TurnChat => "turn_chat",
Self::CompactionChat => "compaction_chat",
}
}
}
#[derive(Debug, Clone)]
pub enum CaptureDecision {
Inline,
Reference(String),
Omit,
}
#[derive(Debug, Clone)]
pub struct PayloadBundle {
pub capture_id: String,
pub capture_kind: CaptureKind,
pub thread_id: ThreadId,
pub turn_number: usize,
pub provider_name: String,
pub provider_id: String,
pub span_is_recording: bool,
pub request_model: String,
pub response_model: Option<String>,
pub system_instructions: Option<serde_json::Value>,
pub input_messages: serde_json::Value,
pub output_messages: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct CaptureResult {
pub system_instructions: CaptureDecision,
pub input_messages: CaptureDecision,
pub output_messages: CaptureDecision,
}
#[async_trait]
pub trait ObservabilityStore: Send + Sync {
async fn capture(&self, bundle: &PayloadBundle) -> anyhow::Result<CaptureResult>;
fn redactor(&self) -> &PayloadRedactor {
&NOOP_REDACTOR
}
fn acknowledge_pii_redaction(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::Message;
use agent_sdk_foundation::ChatRequest;
use agent_sdk_foundation::privacy::BaselineDetector;
use std::sync::Arc;
struct NoopStore;
#[async_trait]
impl ObservabilityStore for NoopStore {
async fn capture(&self, _bundle: &PayloadBundle) -> anyhow::Result<CaptureResult> {
Ok(CaptureResult {
system_instructions: CaptureDecision::Omit,
input_messages: CaptureDecision::Omit,
output_messages: CaptureDecision::Omit,
})
}
}
struct PrivacyStore {
redactor: PayloadRedactor,
}
#[async_trait]
impl ObservabilityStore for PrivacyStore {
async fn capture(&self, _bundle: &PayloadBundle) -> anyhow::Result<CaptureResult> {
Ok(CaptureResult {
system_instructions: CaptureDecision::Omit,
input_messages: CaptureDecision::Omit,
output_messages: CaptureDecision::Omit,
})
}
fn redactor(&self) -> &PayloadRedactor {
&self.redactor
}
}
fn sample_request() -> ChatRequest {
ChatRequest {
system: String::new(),
messages: vec![Message::user("CPF 111.444.777-35 please")],
tools: None,
max_tokens: 1024,
max_tokens_explicit: false,
session_id: None,
cached_content: None,
thinking: None,
tool_choice: None,
response_format: None,
}
}
#[test]
fn default_redactor_is_noop() {
let store = NoopStore;
let result = store.redactor().convert_input_messages(&sample_request());
let text = result[0]["content"][0]["text"].as_str().expect("text");
assert_eq!(text, "CPF 111.444.777-35 please");
}
#[test]
fn overridden_redactor_masks_pii() {
let store = PrivacyStore {
redactor: PayloadRedactor::new(Arc::new(
BaselineDetector::new().expect("baseline compiles"),
)),
};
let result = store.redactor().convert_input_messages(&sample_request());
let text = result[0]["content"][0]["text"].as_str().expect("text");
assert!(
text.contains("[REDACTED:cpf]"),
"expected CPF mask via trait, got {text}"
);
assert!(!text.contains("111.444.777-35"));
}
}