av_harness/content.rs
1//! Content export for external telemetry (the tenant OpenTelemetry
2//! collector).
3//!
4//! The audit worker hands every step it has journaled and published to an
5//! optional [`ContentSink`] as a [`ContentRecord`]: the OCSF event plus the
6//! step's content (the new input message, the model output, tool calls and
7//! results, token usage, finish reason). `agentvisord` installs a sink that
8//! turns records into OpenTelemetry GenAI spans for the tenant collector
9//! only; the operational collector never receives content. The library has
10//! no telemetry dependency: without an installed sink nothing is exported.
11
12use av_events::{AgentIdentity, EventClass};
13use serde_json::Value;
14use std::sync::Arc;
15
16/// One journaled step in content form.
17#[derive(Debug, Clone)]
18pub struct ContentRecord {
19 /// AgentVisor session id (the conversation id).
20 pub session_id: String,
21 /// OCSF event uid of the step.
22 pub event_uid: String,
23 /// Event class of the step.
24 pub class: EventClass,
25 /// True when the step's status is success.
26 pub success: bool,
27 /// Agent identity bound to the step.
28 pub identity: AgentIdentity,
29 /// When the step was journaled, epoch milliseconds.
30 pub time_ms: u64,
31 /// The OCSF event as journaled (already redacted when redaction is on).
32 pub event: Value,
33 /// Who produced the message: `user`, `agent`, or `system`.
34 pub source: Option<String>,
35 /// The step's message (input message for a request, output for a response).
36 pub message: Option<Value>,
37 /// Explicit reasoning text, when the provider returned it.
38 pub reasoning: Option<String>,
39 /// Model that produced a response.
40 pub model: Option<String>,
41 /// Tool calls requested by the model, as JSON.
42 pub tool_calls: Option<Value>,
43 /// Tool results or other environment feedback, as JSON.
44 pub observation: Option<Value>,
45 /// Prompt tokens attributed to the step.
46 pub prompt_tokens: Option<u64>,
47 /// Completion tokens attributed to the step.
48 pub completion_tokens: Option<u64>,
49 /// Provider-native finish reason.
50 pub finish_reason: Option<String>,
51 /// Chat response attempt shared by a request and its response.
52 pub attempt_id: Option<String>,
53 /// True for the terminal (response) record of a chat attempt.
54 pub terminal: bool,
55}
56
57/// Receives journaled steps. Called on a dedicated thread, one record at a
58/// time, in journal order per session.
59pub trait ContentSink: Send + Sync {
60 /// Accept one record.
61 fn record(&self, record: ContentRecord);
62}
63
64/// Records waiting for the sink thread. A full queue drops new records
65/// (counted in `av_content_records_dropped_total`) instead of slowing the
66/// audit worker.
67pub const CONTENT_QUEUE_CAPACITY: usize = 8192;
68
69/// Hands records from the audit worker to a [`ContentSink`] on a dedicated
70/// thread. The worker only does a non-blocking `try_send`; redaction,
71/// serialization, and export all run on the sink thread, and a panicking
72/// sink cannot reach the worker.
73pub struct ContentDispatcher {
74 sender: std::sync::mpsc::SyncSender<ContentRecord>,
75 dropped: Arc<av_core::metrics::Counter>,
76}
77
78impl ContentDispatcher {
79 /// Start the sink thread.
80 pub fn spawn(sink: Arc<dyn ContentSink>, metrics: &av_core::metrics::Registry) -> std::io::Result<Self> {
81 let (sender, receiver) = std::sync::mpsc::sync_channel::<ContentRecord>(CONTENT_QUEUE_CAPACITY);
82 let dropped = metrics.counter(
83 "av_content_records_dropped_total",
84 "Content records dropped because the tenant telemetry queue was full or the sink failed",
85 );
86 let failures = Arc::clone(&dropped);
87 std::thread::Builder::new()
88 .name("agentvisor-content".to_owned())
89 .spawn(move || {
90 for record in receiver {
91 let sink = Arc::clone(&sink);
92 let outcome =
93 std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || sink.record(record)));
94 if outcome.is_err() {
95 failures.inc();
96 tracing::error!("content sink panicked; record dropped");
97 }
98 }
99 })?;
100 Ok(Self { sender, dropped })
101 }
102
103 /// Queue one record without blocking; drop it when the queue is full.
104 pub fn submit(&self, record: ContentRecord) {
105 if self.sender.try_send(record).is_err() {
106 self.dropped.inc();
107 }
108 }
109}
110
111/// A dispatcher slot shared by the application state and the audit worker,
112/// so a sink can be installed after both exist.
113pub type SharedContentSink = Arc<parking_lot::RwLock<Option<Arc<ContentDispatcher>>>>;
114
115/// Everything the audit worker applies to a step on its way out: the
116/// redaction engine (before journal and broker writes) and the content
117/// dispatcher (after the step is durable and published).
118#[derive(Clone, Default)]
119pub struct AuditOutputs {
120 /// Redaction applied to the journaled event and trajectory step.
121 pub redaction: Option<Arc<av_redact::RedactionEngine>>,
122 /// Optional content dispatcher.
123 pub content: SharedContentSink,
124}
125
126impl AuditOutputs {
127 /// Outputs with redaction only.
128 pub fn with_redaction(redaction: Option<Arc<av_redact::RedactionEngine>>) -> Self {
129 Self {
130 redaction,
131 content: SharedContentSink::default(),
132 }
133 }
134
135 /// The installed dispatcher, if any.
136 pub fn dispatcher(&self) -> Option<Arc<ContentDispatcher>> {
137 self.content.read().clone()
138 }
139}