Skip to main content

agent_first_data/
afdata_tracing.rs

1//! AFDATA-compliant tracing layer.
2//!
3//! Outputs log events using agent-first-data formatting functions:
4//! - JSON: single-line JSONL via `output_json` (secrets redacted, original keys)
5//! - Plain: single-line logfmt via `output_plain` (keys stripped, values formatted)
6//! - YAML: multi-line via `output_yaml` (keys stripped, values formatted)
7//!
8//! Span fields are flattened into every event line (e.g. `request_id`).
9//! All other tracing features (macros, spans, EnvFilter) work unchanged.
10//!
11//! # Usage
12//! ```ignore
13//! use agent_first_data::afdata_tracing;
14//! use tracing_subscriber::EnvFilter;
15//!
16//! afdata_tracing::try_init(EnvFilter::new("info"), LogFormat::Json, Redactor::new())?;
17//! ```
18
19use std::io::{self, Write};
20
21use tracing::field::{Field, Visit};
22use tracing::span;
23use tracing::{Event, Level, Subscriber};
24use tracing_subscriber::Layer;
25use tracing_subscriber::layer::Context;
26use tracing_subscriber::registry::LookupSpan;
27use tracing_subscriber::util::TryInitError;
28
29/// Output format for the AFDATA tracing layer.
30#[derive(Clone, Copy, Debug)]
31pub enum LogFormat {
32    Json,
33    Plain,
34    Yaml,
35}
36
37/// A tracing Layer that outputs AFDATA-compliant log lines to stdout.
38pub struct AfdataLayer {
39    format: LogFormat,
40    redactor: crate::Redactor,
41}
42
43/// Try to initialize tracing with AFDATA output.
44///
45/// Returns `Err` if a global tracing subscriber is already initialized. This is
46/// the single entry point for tracing initialization; pass your desired format
47/// and redactor configuration.
48///
49/// # Arguments
50/// * `filter` - tracing_subscriber::EnvFilter controlling which events are recorded
51/// * `format` - LogFormat::Json, LogFormat::Plain, or LogFormat::Yaml
52/// * `redactor` - Redactor with optional custom secret field names and policy
53pub fn try_init(
54    filter: tracing_subscriber::EnvFilter,
55    format: LogFormat,
56    redactor: crate::Redactor,
57) -> Result<(), TryInitError> {
58    use tracing_subscriber::layer::SubscriberExt;
59    use tracing_subscriber::util::SubscriberInitExt;
60
61    tracing_subscriber::registry()
62        .with(filter)
63        .with(AfdataLayer { format, redactor })
64        .try_init()
65}
66
67impl AfdataLayer {
68    fn output_options(&self) -> crate::OutputOptions {
69        crate::OutputOptions {
70            redaction: self.redactor.clone(),
71            style: crate::OutputStyle::Readable,
72        }
73    }
74
75    fn format_value(&self, value: &serde_json::Value) -> String {
76        let options = self.output_options();
77        match self.format {
78            LogFormat::Json => crate::output_json_with_options(value, &options),
79            LogFormat::Plain => crate::output_plain_with_options(value, &options),
80            LogFormat::Yaml => crate::output_yaml_with_options(value, &options),
81        }
82    }
83}
84
85/// Stored in span extensions to carry structured fields.
86struct SpanFields(Vec<(String, serde_json::Value)>);
87
88impl<S> Layer<S> for AfdataLayer
89where
90    S: Subscriber + for<'a> LookupSpan<'a>,
91{
92    fn on_new_span(&self, attrs: &span::Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) {
93        let mut visitor = JsonVisitor::new();
94        attrs.record(&mut visitor);
95
96        if let Some(span) = ctx.span(id) {
97            span.extensions_mut().insert(SpanFields(visitor.fields));
98        }
99    }
100
101    fn on_record(&self, id: &span::Id, values: &span::Record<'_>, ctx: Context<'_, S>) {
102        if let Some(span) = ctx.span(id) {
103            let mut visitor = JsonVisitor::new();
104            values.record(&mut visitor);
105
106            let mut extensions = span.extensions_mut();
107            if let Some(existing) = extensions.get_mut::<SpanFields>() {
108                existing.0.extend(visitor.fields);
109            } else {
110                extensions.insert(SpanFields(visitor.fields));
111            }
112        }
113    }
114
115    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
116        let meta = event.metadata();
117
118        // Collect fields from the event
119        let mut visitor = JsonVisitor::new();
120        event.record(&mut visitor);
121
122        // Build output object with AFDATA field names
123        let mut map = serde_json::Map::with_capacity(4 + visitor.fields.len());
124
125        let level = match *meta.level() {
126            Level::TRACE => "trace",
127            Level::DEBUG => "debug",
128            Level::INFO => "info",
129            Level::WARN => "warn",
130            Level::ERROR => "error",
131        };
132
133        map.insert(
134            "timestamp_epoch_ms".into(),
135            serde_json::Value::Number(chrono::Utc::now().timestamp_millis().into()),
136        );
137
138        // "message" field from the tracing macro's format string
139        if let Some(msg) = visitor.message.take() {
140            map.insert("message".into(), serde_json::Value::String(msg));
141        }
142
143        // Flatten span fields from root to leaf (child overrides parent on collision)
144        if let Some(scope) = ctx.event_scope(event) {
145            for span in scope.from_root() {
146                let extensions = span.extensions();
147                if let Some(fields) = extensions.get::<SpanFields>() {
148                    for (k, v) in &fields.0 {
149                        map.insert(k.clone(), v.clone());
150                    }
151                }
152            }
153        }
154
155        map.insert("level".into(), serde_json::Value::String(level.to_string()));
156
157        // Append event-level structured fields. Logs no longer use top-level
158        // protocol code; code may be a tool-defined field inside the log
159        // payload.
160        for (k, v) in visitor.fields {
161            map.insert(k, v);
162        }
163
164        // Normalize the tracing adapter's conventional message and level
165        // fields. These are adapter output conventions, not protocol fields.
166        let message = map
167            .remove("message")
168            .and_then(|v| v.as_str().map(|s| s.to_string()))
169            .unwrap_or_else(|| "(no message)".to_string());
170        let level_str = map
171            .remove("level")
172            .and_then(|v| v.as_str().map(|s| s.to_string()))
173            .unwrap_or_else(|| "info".to_string());
174        let level = match level_str.as_str() {
175            "debug" => crate::LogLevel::Debug,
176            "info" => crate::LogLevel::Info,
177            "warn" => crate::LogLevel::Warn,
178            "error" => crate::LogLevel::Error,
179            _ => crate::LogLevel::Info,
180        };
181
182        map.insert(
183            "level".to_string(),
184            serde_json::Value::String(level.as_str().to_string()),
185        );
186        map.insert("message".to_string(), serde_json::Value::String(message));
187        let builder = crate::json_log(serde_json::Value::Object(map));
188        #[allow(clippy::expect_used)]
189        let value = builder.build().expect("log event builder failed");
190
191        // Format using the library's own output functions.
192        let line = self.format_value(&value);
193
194        let mut out = io::stdout().lock();
195        let _ = out.write_all(line.as_bytes());
196        let _ = out.write_all(b"\n");
197    }
198}
199
200/// Visitor that collects tracing event fields into a JSON map.
201struct JsonVisitor {
202    message: Option<String>,
203    fields: Vec<(String, serde_json::Value)>,
204}
205
206impl JsonVisitor {
207    fn new() -> Self {
208        Self {
209            message: None,
210            fields: Vec::new(),
211        }
212    }
213}
214
215impl Visit for JsonVisitor {
216    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
217        let val = format!("{:?}", value);
218        if field.name() == "message" {
219            self.message = Some(val);
220        } else {
221            // Push the raw value under its field name. Redaction happens at emit
222            // time in `on_event` via `output_json`/`output_plain`/`output_yaml`,
223            // which redact by field name (`_secret` suffix, `_url` scrubbing) —
224            // exactly like every other AFDATA surface. The visitor never scans
225            // rendered values for secret markers.
226            self.fields
227                .push((field.name().to_string(), serde_json::Value::String(val)));
228        }
229    }
230
231    fn record_str(&mut self, field: &Field, value: &str) {
232        if field.name() == "message" {
233            self.message = Some(value.to_string());
234        } else {
235            self.fields.push((
236                field.name().to_string(),
237                serde_json::Value::String(value.to_string()),
238            ));
239        }
240    }
241
242    fn record_i64(&mut self, field: &Field, value: i64) {
243        self.fields.push((
244            field.name().to_string(),
245            serde_json::Value::Number(value.into()),
246        ));
247    }
248
249    fn record_u64(&mut self, field: &Field, value: u64) {
250        self.fields.push((
251            field.name().to_string(),
252            serde_json::Value::Number(value.into()),
253        ));
254    }
255
256    fn record_f64(&mut self, field: &Field, value: f64) {
257        if let Some(n) = serde_json::Number::from_f64(value) {
258            self.fields
259                .push((field.name().to_string(), serde_json::Value::Number(n)));
260        } else {
261            self.fields.push((
262                field.name().to_string(),
263                serde_json::Value::String(value.to_string()),
264            ));
265        }
266    }
267
268    fn record_bool(&mut self, field: &Field, value: bool) {
269        self.fields
270            .push((field.name().to_string(), serde_json::Value::Bool(value)));
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use serde_json::json;
278
279    // The tracing layer redacts log fields the same way every AFDATA surface
280    // does: by FIELD NAME, applied by `output_*` at emit time — never by
281    // scanning a rendered value for the substring "_secret". These tests pin
282    // that contract (the visitor records raw values; emit redacts by name).
283
284    #[test]
285    fn code_field_is_accepted_by_log_builder() {
286        let result = crate::json_log(json!({"code": "cache_miss"})).build();
287        assert!(
288            result.is_ok(),
289            "builder should accept a tool-defined log code"
290        );
291        let Ok(value) = result else {
292            return;
293        };
294
295        assert_eq!(value["log"]["code"], "cache_miss");
296    }
297
298    #[test]
299    fn secret_named_field_is_redacted_at_emit() {
300        let line = crate::output_json(&json!({
301            "code": "info",
302            "api_key_secret": "sk-live-123",
303        }));
304        assert!(line.contains("\"api_key_secret\":\"***\""), "{line}");
305        assert!(!line.contains("sk-live-123"), "{line}");
306    }
307
308    #[test]
309    fn non_secret_field_whose_value_mentions_secret_is_not_redacted() {
310        // A real secret value never contains the literal "_secret"; the old
311        // substring scan only ever produced false positives like this one.
312        let line = crate::output_json(&json!({
313            "code": "info",
314            "note": "see the api_key_secret field in docs",
315        }));
316        assert!(
317            line.contains("see the api_key_secret field in docs"),
318            "{line}"
319        );
320    }
321
322    #[test]
323    fn secret_typed_field_is_redacted_regardless_of_record_path() {
324        // record_str / record_i64 etc. push raw values too; emit-time redaction
325        // covers every record_* path, not just record_debug.
326        let line = crate::output_json(&json!({
327            "code": "warn",
328            "db_password_secret": 1234,
329        }));
330        assert!(line.contains("\"db_password_secret\":\"***\""), "{line}");
331    }
332
333    #[test]
334    fn legacy_secret_names_are_redacted_when_layer_has_options() {
335        #[allow(clippy::expect_used)]
336        let value = crate::json_log(json!({
337            "level": "info",
338            "message": "authorization appears in message but is not name-redacted",
339            "timestamp_epoch_ms": 1,
340            "authorization": "Bearer legacy",
341            "request_url": "https://example.test/path?authorization=legacy&ok=1",
342        }))
343        .build()
344        .expect("builder failed");
345        let redactor = crate::Redactor::new().secret_names(vec!["authorization".to_string()]);
346
347        for format in [LogFormat::Json, LogFormat::Plain, LogFormat::Yaml] {
348            let layer = AfdataLayer {
349                format,
350                redactor: redactor.clone(),
351            };
352            let line = layer.format_value(&value);
353            assert!(line.contains("***"), "{line}");
354            assert!(
355                !line.contains("Bearer legacy"),
356                "legacy field value should be redacted: {line}"
357            );
358            assert!(
359                !line.contains("authorization=legacy"),
360                "legacy URL query parameter should be redacted: {line}"
361            );
362            assert!(
363                line.contains("authorization appears in message"),
364                "message is free-form and should remain readable: {line}"
365            );
366        }
367    }
368
369    #[test]
370    fn legacy_secret_names_are_visible_without_layer_options() {
371        #[allow(clippy::expect_used)]
372        let value = crate::json_log(json!({
373            "level": "info",
374            "message": "ready",
375            "timestamp_epoch_ms": 1,
376            "authorization": "Bearer visible",
377        }))
378        .build()
379        .expect("builder failed");
380        let layer = AfdataLayer {
381            format: LogFormat::Json,
382            redactor: crate::Redactor::new(),
383        };
384
385        let line = layer.format_value(&value);
386        assert!(
387            line.contains("\"authorization\":\"Bearer visible\""),
388            "{line}"
389        );
390    }
391}