Skip to main content

cratefield_runtime_native/
tracing_setup.rs

1//! `tracing-subscriber` JSON to stdout (issue #19) with the crate-wide
2//! redaction rules applied in the formatter — the native counterpart of
3//! `cratefield-runtime-cloudflare`'s hand-rolled console subscriber (which
4//! exists because a dispatcher hangs the workerd isolate; none of that
5//! applies on tokio, so here the real subscriber runs).
6//!
7//! One JSON line per event: `timestamp`, `level`, `target`, the event's
8//! fields (secret-ish names `[redacted]`, email-ish values as truncated
9//! SHA-256 hashes — the rules live in `cratefield-core` so Workers and
10//! native logs cannot drift). Span fields are not emitted as events; the
11//! per-request span is carried on the `Scope`, its shape asserted by
12//! core's tests. `RUST_LOG` sets the filter (default `info`).
13
14use std::fmt;
15use std::sync::OnceLock;
16
17use serde_json::{Map, Value, json};
18use tracing::field::Visit;
19use tracing::{Event, Subscriber};
20use tracing_subscriber::Registry;
21use tracing_subscriber::fmt as ts_fmt;
22use tracing_subscriber::fmt::format::{DefaultFields, Writer};
23use tracing_subscriber::fmt::{FmtContext, FormatEvent};
24use tracing_subscriber::layer::SubscriberExt;
25use tracing_subscriber::registry::LookupSpan;
26
27static INSTALLED: OnceLock<()> = OnceLock::new();
28
29/// Installs the stdout JSON subscriber exactly once per process.
30/// Process-wide infrastructure installed before the first response, not
31/// request state (ADR 0007). Fails silently only when the process
32/// already installed another global subscriber (e.g. a test harness),
33/// which then keeps receiving the events.
34pub fn install_tracing() {
35    INSTALLED.get_or_init(|| {
36        let filter = tracing_subscriber::EnvFilter::try_from_default_env()
37            .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
38        let fmt_layer = ts_fmt::Layer::default()
39            .event_format(RedactingJson)
40            .with_writer(std::io::stdout);
41        let subscriber = Registry::default().with(filter).with(fmt_layer);
42        let _ = tracing::subscriber::set_global_default(subscriber);
43    });
44}
45
46/// A `FormatEvent` that mirrors the Workers subscriber's JSON line with
47/// core's redaction rules applied to every field. Serializes to a string
48/// first: the tracing `Writer` only implements `fmt::Write`.
49struct RedactingJson;
50
51impl<S> FormatEvent<S, DefaultFields> for RedactingJson
52where
53    S: Subscriber + for<'a> LookupSpan<'a>,
54{
55    fn format_event(
56        &self,
57        _ctx: &FmtContext<'_, S, DefaultFields>,
58        mut writer: Writer<'_>,
59        event: &Event<'_>,
60    ) -> fmt::Result {
61        let mut visitor = RedactingVisitor::default();
62        event.record(&mut visitor);
63
64        let metadata = event.metadata();
65        let mut fields = visitor.fields;
66        fields.insert("level".into(), json!(metadata.level().as_str()));
67        fields.insert("target".into(), json!(metadata.target()));
68        let timestamp = time::OffsetDateTime::now_utc()
69            .format(&time::format_description::well_known::Rfc3339)
70            .unwrap_or_else(|_| "unknown".to_owned());
71        fields.insert("timestamp".into(), json!(timestamp));
72
73        writer.write_str(&Value::Object(fields).to_string())?;
74        writer.write_char('\n')
75    }
76}
77
78/// The field half of runtime-cloudflare's `JsonRedactingVisitor`: the
79/// redaction rules themselves are `cratefield_core::redacted_value`.
80#[derive(Default)]
81struct RedactingVisitor {
82    fields: Map<String, Value>,
83}
84
85impl RedactingVisitor {
86    fn record_field(&mut self, name: &str, value: &str) {
87        let redacted = cratefield_core::redacted_value(name, value);
88        if redacted == "[redacted]" {
89            self.fields.insert(name.to_owned(), json!("[redacted]"));
90        } else if let Some(hash) = redacted.strip_prefix("subject_hash:") {
91            self.fields
92                .insert(format!("{name}_hash"), json!(format!("sha256:{hash}")));
93        } else {
94            // The redacted form, not the raw value: for a generic field it
95            // is the value with embedded emails, tokens and query strings
96            // scrubbed (issue #135).
97            self.fields.insert(name.to_owned(), json!(redacted));
98        }
99    }
100}
101
102impl Visit for RedactingVisitor {
103    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
104        self.record_field(field.name(), value);
105    }
106
107    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
108        self.record_field(field.name(), &format!("{value:?}"));
109    }
110
111    fn record_error(
112        &mut self,
113        field: &tracing::field::Field,
114        value: &(dyn std::error::Error + 'static),
115    ) {
116        self.record_field(field.name(), &value.to_string());
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn visitor_redacts_like_core() {
126        let mut visitor = RedactingVisitor::default();
127        visitor.record_field("authorization", "Bearer x");
128        visitor.record_field("email", "nick@example.com");
129        visitor.record_field("outcome", "sent");
130        assert_eq!(visitor.fields["authorization"], "[redacted]");
131        assert!(
132            visitor.fields["email_hash"]
133                .as_str()
134                .is_some_and(|hash| hash.starts_with("sha256:"))
135        );
136        assert_eq!(visitor.fields["outcome"], "sent");
137
138        // A secret inside a *generic* field name (issue #135): the value
139        // itself is scrubbed, so a driver DETAIL or a redirect target
140        // cannot carry an email or a token into the JSON line.
141        let mut visitor = RedactingVisitor::default();
142        visitor.record_field(
143            "error",
144            "DETAIL: Key (email)=(nick@example.com) exists; see /v1/waitlist/status?token=abc",
145        );
146        let error = visitor.fields["error"].as_str().expect("error field");
147        assert!(!error.contains('@'), "{error}");
148        assert!(!error.contains("token=abc"), "{error}");
149        assert!(error.contains("subject_hash:"), "{error}");
150    }
151}