harn_vm/agent_events/
sinks.rs1use std::sync::{Arc, Mutex};
2
3use serde::{Deserialize, Serialize};
4
5use crate::event_log::{AnyEventLog, EventLog, LogEvent as EventLogRecord, Topic};
6
7use super::AgentEvent;
8
9fn should_persist_event(event: &AgentEvent) -> bool {
10 match event {
11 AgentEvent::ToolCall { parsing, .. } | AgentEvent::ToolCallUpdate { parsing, .. } => {
14 parsing.is_none()
15 }
16 _ => true,
17 }
18}
19
20pub trait AgentEventSink: Send + Sync {
23 fn handle_event(&self, event: &AgentEvent);
24}
25
26#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct PersistedAgentEvent {
34 pub index: u64,
38 pub emitted_at_ms: i64,
42 pub frame_depth: Option<u32>,
46 #[serde(flatten)]
48 pub event: AgentEvent,
49}
50
51pub struct JsonlEventSink {
56 state: Mutex<JsonlEventSinkState>,
57 base_path: std::path::PathBuf,
58}
59
60struct JsonlEventSinkState {
61 writer: std::io::BufWriter<std::fs::File>,
62 index: u64,
63 bytes_written: u64,
64 rotation: u32,
65}
66
67impl JsonlEventSink {
68 pub const ROTATE_BYTES: u64 = 100 * 1024 * 1024;
72
73 pub fn open(base_path: impl Into<std::path::PathBuf>) -> std::io::Result<Arc<Self>> {
77 let base_path = base_path.into();
78 if let Some(parent) = base_path.parent() {
79 std::fs::create_dir_all(parent)?;
80 }
81 let file = std::fs::OpenOptions::new()
82 .create(true)
83 .truncate(true)
84 .write(true)
85 .open(&base_path)?;
86 Ok(Arc::new(Self {
87 state: Mutex::new(JsonlEventSinkState {
88 writer: std::io::BufWriter::new(file),
89 index: 0,
90 bytes_written: 0,
91 rotation: 0,
92 }),
93 base_path,
94 }))
95 }
96
97 pub fn flush(&self) -> std::io::Result<()> {
100 use std::io::Write as _;
101 self.state
102 .lock()
103 .expect("jsonl sink mutex poisoned")
104 .writer
105 .flush()
106 }
107
108 pub fn event_count(&self) -> u64 {
111 self.state.lock().expect("jsonl sink mutex poisoned").index
112 }
113
114 fn rotate_if_needed(&self, state: &mut JsonlEventSinkState) -> std::io::Result<()> {
115 use std::io::Write as _;
116 if state.bytes_written < Self::ROTATE_BYTES {
117 return Ok(());
118 }
119 state.writer.flush()?;
120 state.rotation += 1;
121 let suffix = format!("-{:06}", state.rotation);
122 let rotated = self.base_path.with_file_name({
123 let stem = self
124 .base_path
125 .file_stem()
126 .and_then(|s| s.to_str())
127 .unwrap_or("event_log");
128 let ext = self
129 .base_path
130 .extension()
131 .and_then(|e| e.to_str())
132 .unwrap_or("jsonl");
133 format!("{stem}{suffix}.{ext}")
134 });
135 let file = std::fs::OpenOptions::new()
136 .create(true)
137 .truncate(true)
138 .write(true)
139 .open(&rotated)?;
140 state.writer = std::io::BufWriter::new(file);
141 state.bytes_written = 0;
142 Ok(())
143 }
144}
145
146pub struct EventLogSink {
151 log: Arc<AnyEventLog>,
152 topic: Topic,
153 session_id: String,
154}
155
156impl EventLogSink {
157 pub fn new(log: Arc<AnyEventLog>, session_id: impl Into<String>) -> Arc<Self> {
158 let session_id = session_id.into();
159 let topic = Topic::new(format!(
160 "observability.agent_events.{}",
161 crate::event_log::sanitize_topic_component(&session_id)
162 ))
163 .expect("session id should sanitize to a valid topic");
164 Arc::new(Self {
165 log,
166 topic,
167 session_id,
168 })
169 }
170}
171
172impl AgentEventSink for JsonlEventSink {
173 fn handle_event(&self, event: &AgentEvent) {
174 if !should_persist_event(event) {
175 return;
176 }
177 use std::io::Write as _;
178 let mut state = self.state.lock().expect("jsonl sink mutex poisoned");
179 let index = state.index;
180 state.index += 1;
181 let emitted_at_ms = std::time::SystemTime::now()
182 .duration_since(std::time::UNIX_EPOCH)
183 .map(|d| d.as_millis() as i64)
184 .unwrap_or(0);
185 let envelope = PersistedAgentEvent {
186 index,
187 emitted_at_ms,
188 frame_depth: None,
189 event: event.clone(),
190 };
191 let Ok(mut envelope_json) = serde_json::to_value(&envelope) else {
192 return;
193 };
194 crate::redact::current_policy().redact_json_in_place(&mut envelope_json);
195 if let Ok(line) = serde_json::to_string(&envelope_json) {
196 if state
201 .writer
202 .write_all(line.as_bytes())
203 .and_then(|_| state.writer.write_all(b"\n"))
204 .is_ok()
205 {
206 state.bytes_written += line.len() as u64 + 1;
207 let _ = state.writer.flush();
208 let _ = self.rotate_if_needed(&mut state);
209 }
210 }
211 }
212}
213
214impl AgentEventSink for EventLogSink {
215 fn handle_event(&self, event: &AgentEvent) {
216 if !should_persist_event(event) {
217 return;
218 }
219 let event_json = match serde_json::to_value(event) {
220 Ok(value) => value,
221 Err(_) => return,
222 };
223 let event_kind = event_json
224 .get("type")
225 .and_then(|value| value.as_str())
226 .unwrap_or("agent_event")
227 .to_string();
228 let payload = serde_json::json!({
229 "index_hint": now_ms(),
230 "session_id": self.session_id,
231 "event": event_json,
232 });
233 let mut headers = std::collections::BTreeMap::new();
234 headers.insert("session_id".to_string(), self.session_id.clone());
235 let log = self.log.clone();
236 let topic = self.topic.clone();
237 let mut record = EventLogRecord::new(event_kind, payload).with_headers(headers);
238 record.redact_in_place(&crate::redact::current_policy());
239 if let Ok(handle) = tokio::runtime::Handle::try_current() {
240 handle.spawn(async move {
241 let _ = log.append(&topic, record).await;
242 });
243 } else {
244 let _ = futures::executor::block_on(log.append(&topic, record));
245 }
246 }
247}
248
249impl Drop for JsonlEventSink {
250 fn drop(&mut self) {
251 if let Ok(mut state) = self.state.lock() {
252 use std::io::Write as _;
253 let _ = state.writer.flush();
254 }
255 }
256}
257
258pub struct MultiSink {
260 sinks: Mutex<Vec<Arc<dyn AgentEventSink>>>,
261}
262
263impl MultiSink {
264 pub fn new() -> Self {
265 Self {
266 sinks: Mutex::new(Vec::new()),
267 }
268 }
269 pub fn push(&self, sink: Arc<dyn AgentEventSink>) {
270 self.sinks.lock().expect("sink mutex poisoned").push(sink);
271 }
272 pub fn len(&self) -> usize {
273 self.sinks.lock().expect("sink mutex poisoned").len()
274 }
275 pub fn is_empty(&self) -> bool {
276 self.len() == 0
277 }
278}
279
280impl Default for MultiSink {
281 fn default() -> Self {
282 Self::new()
283 }
284}
285
286impl AgentEventSink for MultiSink {
287 fn handle_event(&self, event: &AgentEvent) {
288 let sinks = self.sinks.lock().expect("sink mutex poisoned").clone();
294 for sink in sinks {
295 sink.handle_event(event);
296 }
297 }
298}
299
300pub(super) fn now_ms() -> i64 {
301 std::time::SystemTime::now()
302 .duration_since(std::time::UNIX_EPOCH)
303 .map(|duration| duration.as_millis() as i64)
304 .unwrap_or(0)
305}