Skip to main content

nidus_http/
logging.rs

1//! Structured logging helpers built on `tracing` and `tracing-subscriber`.
2
3use std::borrow::Cow;
4
5use http::Request;
6use tower_http::trace::MakeSpan;
7use tracing::{Level, Span};
8use tracing_subscriber::{
9    EnvFilter, Layer, Registry,
10    fmt::{self, MakeWriter},
11    layer::SubscriberExt,
12    util::SubscriberInitExt,
13};
14
15use crate::context::{header_to_str, parse_traceparent};
16
17/// Structured logging output format.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum LoggingFormat {
20    /// JSON logs for production log pipelines.
21    Json,
22    /// Pretty logs for local development.
23    Pretty,
24}
25
26/// Typed configuration for Nidus logging helpers.
27///
28/// `LoggingConfig` builds `tracing-subscriber` subscribers and structured
29/// service/request spans. It does not install HTTP middleware by itself; pair it
30/// with `tower_http::trace::TraceLayer` and [`StructuredMakeSpan`] for request
31/// spans.
32///
33/// ```no_run
34/// use nidus_http::logging::{LoggingConfig, StructuredMakeSpan};
35/// use tower_http::trace::TraceLayer;
36///
37/// let logging = LoggingConfig::production("users-api")
38///     .version("1.2.3")
39///     .environment("production")
40///     .level_filter("info,tower_http=debug")
41///     .redact_header("authorization");
42///
43/// logging.init()?;
44/// let trace_layer = TraceLayer::new_for_http()
45///     .make_span_with(StructuredMakeSpan::new(logging));
46/// # Ok::<(), tracing_subscriber::util::TryInitError>(())
47/// ```
48#[derive(Clone, Debug)]
49pub struct LoggingConfig {
50    service_name: String,
51    version: Option<String>,
52    environment: Option<String>,
53    format: LoggingFormat,
54    level_filter: String,
55    // Kept sorted after ASCII normalization so request-time lookups can use
56    // binary search without allocating a lowercase `String`.
57    redacted_headers: Vec<String>,
58}
59
60impl LoggingConfig {
61    /// Creates production JSON logging config for a service.
62    ///
63    /// Defaults to JSON output, `info` filtering, and no redacted headers.
64    pub fn production(service_name: impl Into<String>) -> Self {
65        Self {
66            service_name: service_name.into(),
67            version: None,
68            environment: None,
69            format: LoggingFormat::Json,
70            level_filter: "info".to_owned(),
71            redacted_headers: Vec::new(),
72        }
73    }
74
75    /// Creates development pretty logging config for a service.
76    ///
77    /// This keeps the same service metadata defaults as production but uses
78    /// pretty text formatting.
79    pub fn development(service_name: impl Into<String>) -> Self {
80        Self::production(service_name).with_format(LoggingFormat::Pretty)
81    }
82
83    /// Returns the service name.
84    pub fn service_name(&self) -> &str {
85        &self.service_name
86    }
87
88    /// Sets the service version.
89    ///
90    /// The version is included in [`Self::service_span`] and
91    /// [`StructuredMakeSpan`] fields.
92    pub fn version(mut self, version: impl Into<String>) -> Self {
93        self.version = Some(version.into());
94        self
95    }
96
97    /// Sets the deployment environment.
98    ///
99    /// The environment is included in [`Self::service_span`] and
100    /// [`StructuredMakeSpan`] fields.
101    pub fn environment(mut self, environment: impl Into<String>) -> Self {
102        self.environment = Some(environment.into());
103        self
104    }
105
106    /// Sets the logging format.
107    pub fn with_format(mut self, format: LoggingFormat) -> Self {
108        self.format = format;
109        self
110    }
111
112    /// Sets the tracing level filter directive.
113    pub fn level_filter(mut self, level_filter: impl Into<String>) -> Self {
114        self.level_filter = level_filter.into();
115        self
116    }
117
118    /// Marks a header as redacted for application log code.
119    ///
120    /// This stores redaction policy for callers via [`Self::redacts_header`].
121    /// The built-in [`StructuredMakeSpan`] does not log arbitrary request
122    /// headers, so there is no automatic header scrubber to install.
123    pub fn redact_header(mut self, header: impl AsRef<str>) -> Self {
124        let header = header.as_ref().to_ascii_lowercase();
125        if let Err(index) = self.redacted_headers.binary_search(&header) {
126            self.redacted_headers.insert(index, header);
127        }
128        self
129    }
130
131    /// Returns whether the config redacts a header name.
132    pub fn redacts_header(&self, header: impl AsRef<str>) -> bool {
133        let header = header.as_ref();
134        self.redacted_headers
135            .binary_search_by(|configured| {
136                configured
137                    .bytes()
138                    .cmp(header.bytes().map(|byte| byte.to_ascii_lowercase()))
139            })
140            .is_ok()
141    }
142
143    /// Returns the configured output format.
144    pub const fn output_format(&self) -> LoggingFormat {
145        self.format
146    }
147
148    /// Returns the configured output format.
149    pub const fn format(&self) -> LoggingFormat {
150        self.format
151    }
152
153    /// Creates a root service span carrying stable deployment attributes.
154    pub fn service_span(&self) -> Span {
155        tracing::info_span!(
156            "service",
157            service.name = %self.service_name,
158            service.version = %self.version.as_deref().unwrap_or(""),
159            deployment.environment = %self.environment.as_deref().unwrap_or("")
160        )
161    }
162
163    /// Installs this config as the process-global tracing subscriber.
164    ///
165    /// Like other `tracing-subscriber` global installs, this usually succeeds
166    /// once per process. Tests often prefer [`Self::subscriber_with_writer`] to
167    /// avoid global state.
168    pub fn init(&self) -> Result<(), tracing_subscriber::util::TryInitError> {
169        match self.format {
170            LoggingFormat::Json => self.subscriber_with_writer(std::io::stderr).try_init(),
171            LoggingFormat::Pretty => self
172                .pretty_subscriber_with_writer(std::io::stderr)
173                .try_init(),
174        }
175    }
176
177    /// Builds a JSON subscriber using a caller-provided writer.
178    pub fn subscriber_with_writer<W>(
179        &self,
180        writer: W,
181    ) -> impl tracing::Subscriber + Send + Sync + 'static
182    where
183        W: for<'writer> MakeWriter<'writer> + Clone + Send + Sync + 'static,
184    {
185        let filter =
186            EnvFilter::try_new(&self.level_filter).unwrap_or_else(|_| EnvFilter::new("info"));
187        let layer = fmt::layer()
188            .json()
189            .flatten_event(true)
190            .with_current_span(true)
191            .with_span_list(false)
192            .with_ansi(false)
193            .with_target(false)
194            .with_writer(writer)
195            .with_filter(filter);
196        Registry::default().with(layer)
197    }
198
199    /// Builds a pretty subscriber using a caller-provided writer.
200    pub fn pretty_subscriber_with_writer<W>(
201        &self,
202        writer: W,
203    ) -> impl tracing::Subscriber + Send + Sync + 'static
204    where
205        W: for<'writer> MakeWriter<'writer> + Clone + Send + Sync + 'static,
206    {
207        let filter =
208            EnvFilter::try_new(&self.level_filter).unwrap_or_else(|_| EnvFilter::new("info"));
209        let layer = fmt::layer()
210            .pretty()
211            .with_ansi(false)
212            .with_target(false)
213            .with_writer(writer)
214            .with_filter(filter);
215        Registry::default().with(layer)
216    }
217}
218
219/// Span maker that records service, request, route, and trace context fields.
220///
221/// The span includes `service.name`, `service.version`,
222/// `deployment.environment`, `request.id`, `trace.id`, `http.method`,
223/// `http.route`, and `http.target`. Request ID and trace ID are read from
224/// `x-request-id` and `traceparent` headers respectively; use the request ID
225/// middleware before tracing when you need every request span to have an ID.
226#[derive(Clone, Debug)]
227pub struct StructuredMakeSpan {
228    config: LoggingConfig,
229    route: Option<Cow<'static, str>>,
230}
231
232impl StructuredMakeSpan {
233    /// Creates a structured HTTP span maker.
234    pub fn new(config: LoggingConfig) -> Self {
235        Self {
236            config,
237            route: None,
238        }
239    }
240
241    /// Sets the stable route pattern for spans made by this value.
242    ///
243    /// When unset, the span maker falls back to Axum's
244    /// [`axum::extract::MatchedPath`] extension and then `"<unknown>"`.
245    pub fn route(mut self, route: impl Into<Cow<'static, str>>) -> Self {
246        self.route = Some(route.into());
247        self
248    }
249}
250
251impl<B> MakeSpan<B> for StructuredMakeSpan {
252    fn make_span(&mut self, request: &Request<B>) -> Span {
253        let request_id = header_to_str(request.headers(), "x-request-id").unwrap_or_default();
254        let trace_id = header_to_str(request.headers(), "traceparent")
255            .and_then(parse_traceparent)
256            .map(|context| context.trace_id)
257            .unwrap_or_default();
258        let route = self
259            .route
260            .as_deref()
261            .or_else(|| {
262                request
263                    .extensions()
264                    .get::<axum::extract::MatchedPath>()
265                    .map(axum::extract::MatchedPath::as_str)
266            })
267            .unwrap_or("<unknown>");
268
269        tracing::span!(
270            Level::INFO,
271            "http.request",
272            service.name = %self.config.service_name,
273            service.version = %self.config.version.as_deref().unwrap_or(""),
274            deployment.environment = %self.config.environment.as_deref().unwrap_or(""),
275            request.id = %request_id,
276            trace.id = %trace_id,
277            http.method = %request.method(),
278            http.route = %route,
279            http.target = %request.uri(),
280        )
281    }
282}