Skip to main content

agent_first_data/
afdata_tracing.rs

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