1use std::sync::Arc;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{Duration, Instant};
10
11static NEXT_OPERATION_ID: AtomicU64 = AtomicU64::new(1);
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub struct OperationId(u64);
16
17impl OperationId {
18 fn next() -> Self {
19 Self(NEXT_OPERATION_ID.fetch_add(1, Ordering::Relaxed))
20 }
21
22 pub fn get(self) -> u64 {
23 self.0
24 }
25}
26
27#[derive(Clone, Debug, Default, PartialEq, Eq)]
31pub struct ObservationContext {
32 pub session_id: Option<String>,
33 pub run_id: Option<String>,
34 pub turn_id: Option<u32>,
35 pub job_id: Option<String>,
36 pub node_id: Option<String>,
37}
38
39impl ObservationContext {
40 pub fn with_turn(&self, turn_id: u32) -> Self {
41 Self {
42 turn_id: Some(turn_id),
43 ..self.clone()
44 }
45 }
46
47 pub fn with_graph(
48 &self,
49 run_id: Option<String>,
50 node_id: Option<String>,
51 job_id: Option<String>,
52 ) -> Self {
53 Self {
54 run_id,
55 node_id,
56 job_id,
57 ..self.clone()
58 }
59 }
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
63#[non_exhaustive]
64pub enum OperationKind {
65 AgentRun,
66 Turn,
67 LlmRequest,
68 ToolExecution,
69 Compaction,
70 SubagentJob,
71 DagRun,
72 DagNode,
73}
74
75impl OperationKind {
76 pub fn as_str(self) -> &'static str {
77 match self {
78 Self::AgentRun => "agent.run",
79 Self::Turn => "agent.turn",
80 Self::LlmRequest => "llm.request",
81 Self::ToolExecution => "tool.execute",
82 Self::Compaction => "session.compaction",
83 Self::SubagentJob => "multiagent.job",
84 Self::DagRun => "dag.run",
85 Self::DagNode => "dag.node",
86 }
87 }
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
91#[non_exhaustive]
92pub enum OperationOutcome {
93 Succeeded,
94 Failed,
95 Cancelled,
96 Interrupted,
97 TimedOut,
98 Skipped,
99 Abandoned,
100}
101
102impl OperationOutcome {
103 pub fn as_str(self) -> &'static str {
104 match self {
105 Self::Succeeded => "succeeded",
106 Self::Failed => "failed",
107 Self::Cancelled => "cancelled",
108 Self::Interrupted => "interrupted",
109 Self::TimedOut => "timed_out",
110 Self::Skipped => "skipped",
111 Self::Abandoned => "abandoned",
112 }
113 }
114}
115
116#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
117#[non_exhaustive]
118pub enum ErrorCategory {
119 Provider,
120 Tool,
121 Permission,
122 Persistence,
123 Validation,
124 Timeout,
125 Cancellation,
126 Runtime,
127}
128
129impl ErrorCategory {
130 pub fn as_str(self) -> &'static str {
131 match self {
132 Self::Provider => "provider",
133 Self::Tool => "tool",
134 Self::Permission => "permission",
135 Self::Persistence => "persistence",
136 Self::Validation => "validation",
137 Self::Timeout => "timeout",
138 Self::Cancellation => "cancellation",
139 Self::Runtime => "runtime",
140 }
141 }
142}
143
144#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
146pub struct RuntimeMeasurements {
147 pub input_tokens: u64,
148 pub output_tokens: u64,
149 pub cache_read_tokens: u64,
150 pub cache_write_tokens: u64,
151 pub characters: u64,
152 pub turns: u64,
153 pub tool_calls: u64,
154}
155
156#[derive(Clone, Debug, PartialEq, Eq)]
157#[non_exhaustive]
158pub enum OperationDetail {
159 AgentRun,
160 Turn {
161 index: u32,
162 },
163 LlmRequest {
164 provider: String,
165 model: String,
166 },
167 ToolExecution {
168 tool_name: String,
169 },
170 Compaction {
171 algorithm: String,
172 provider: String,
173 model: String,
174 },
175 SubagentJob {
176 agent: String,
177 source: String,
178 },
179 DagRun,
180 DagNode,
181}
182
183impl OperationDetail {
184 pub fn kind(&self) -> OperationKind {
185 match self {
186 Self::AgentRun => OperationKind::AgentRun,
187 Self::Turn { .. } => OperationKind::Turn,
188 Self::LlmRequest { .. } => OperationKind::LlmRequest,
189 Self::ToolExecution { .. } => OperationKind::ToolExecution,
190 Self::Compaction { .. } => OperationKind::Compaction,
191 Self::SubagentJob { .. } => OperationKind::SubagentJob,
192 Self::DagRun => OperationKind::DagRun,
193 Self::DagNode => OperationKind::DagNode,
194 }
195 }
196}
197
198#[derive(Clone, Debug, PartialEq, Eq)]
199pub struct OperationStarted {
200 pub id: OperationId,
201 pub parent_id: Option<OperationId>,
202 pub context: ObservationContext,
203 pub detail: OperationDetail,
204}
205
206#[derive(Clone, Debug, Default, PartialEq)]
213pub struct ObservationContent {
214 pub input: Option<serde_json::Value>,
215 pub output: Option<serde_json::Value>,
216}
217
218#[derive(Clone, Debug, PartialEq)]
219pub struct OperationFinished {
220 pub id: OperationId,
221 pub kind: OperationKind,
222 pub context: ObservationContext,
223 pub outcome: OperationOutcome,
224 pub error_category: Option<ErrorCategory>,
225 pub duration: Duration,
226 pub measurements: RuntimeMeasurements,
227 pub content: Option<ObservationContent>,
230}
231
232#[derive(Clone, Debug, PartialEq)]
233#[non_exhaustive]
234pub enum RuntimeObservation {
235 OperationStarted(OperationStarted),
236 OperationFinished(OperationFinished),
237}
238
239pub trait RuntimeObserver: Send + Sync {
241 fn observe(&self, observation: RuntimeObservation);
242
243 fn include_content(&self) -> bool {
247 false
248 }
249}
250
251#[derive(Debug, Default)]
252pub struct NoopRuntimeObserver;
253
254impl RuntimeObserver for NoopRuntimeObserver {
255 fn observe(&self, _observation: RuntimeObservation) {}
256}
257
258pub fn noop_runtime_observer() -> Arc<dyn RuntimeObserver> {
259 Arc::new(NoopRuntimeObserver)
260}
261
262pub fn dispatch(observer: &Arc<dyn RuntimeObserver>, observation: RuntimeObservation) {
264 let observer = Arc::clone(observer);
265 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
266 observer.observe(observation);
267 }));
268}
269
270pub struct OperationScope {
272 id: OperationId,
273 kind: OperationKind,
274 context: ObservationContext,
275 observer: Arc<dyn RuntimeObserver>,
276 started_at: Instant,
277 finished: bool,
278 content: Option<ObservationContent>,
279}
280
281impl OperationScope {
282 pub fn start(
283 observer: Arc<dyn RuntimeObserver>,
284 parent_id: Option<OperationId>,
285 context: ObservationContext,
286 detail: OperationDetail,
287 ) -> Self {
288 let id = OperationId::next();
289 let kind = detail.kind();
290 dispatch(
291 &observer,
292 RuntimeObservation::OperationStarted(OperationStarted {
293 id,
294 parent_id,
295 context: context.clone(),
296 detail,
297 }),
298 );
299 Self {
300 id,
301 kind,
302 context,
303 observer,
304 started_at: Instant::now(),
305 finished: false,
306 content: None,
307 }
308 }
309
310 pub fn id(&self) -> OperationId {
311 self.id
312 }
313
314 pub fn attach_content(&mut self, content: ObservationContent) {
317 self.content = Some(content);
318 }
319
320 pub fn finish(
321 mut self,
322 outcome: OperationOutcome,
323 error_category: Option<ErrorCategory>,
324 measurements: RuntimeMeasurements,
325 ) {
326 self.emit_finish(outcome, error_category, measurements);
327 }
328
329 fn emit_finish(
330 &mut self,
331 outcome: OperationOutcome,
332 error_category: Option<ErrorCategory>,
333 measurements: RuntimeMeasurements,
334 ) {
335 if self.finished {
336 return;
337 }
338 self.finished = true;
339 dispatch(
340 &self.observer,
341 RuntimeObservation::OperationFinished(OperationFinished {
342 id: self.id,
343 kind: self.kind,
344 context: self.context.clone(),
345 outcome,
346 error_category,
347 duration: self.started_at.elapsed(),
348 measurements,
349 content: self.content.take(),
350 }),
351 );
352 }
353}
354
355impl Drop for OperationScope {
356 fn drop(&mut self) {
357 self.emit_finish(
358 OperationOutcome::Abandoned,
359 Some(ErrorCategory::Runtime),
360 RuntimeMeasurements::default(),
361 );
362 }
363}
364
365#[cfg(test)]
366tests_bridge_macro::tests_bridge!("observability");