Skip to main content

nidus_http/
logging.rs

1//! Structured logging helpers built on `tracing` and `tracing-subscriber`.
2
3use std::{borrow::Cow, collections::BTreeSet};
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_string;
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    redacted_headers: BTreeSet<String>,
56}
57
58impl LoggingConfig {
59    /// Creates production JSON logging config for a service.
60    ///
61    /// Defaults to JSON output, `info` filtering, and no redacted headers.
62    pub fn production(service_name: impl Into<String>) -> Self {
63        Self {
64            service_name: service_name.into(),
65            version: None,
66            environment: None,
67            format: LoggingFormat::Json,
68            level_filter: "info".to_owned(),
69            redacted_headers: BTreeSet::new(),
70        }
71    }
72
73    /// Creates development pretty logging config for a service.
74    ///
75    /// This keeps the same service metadata defaults as production but uses
76    /// pretty text formatting.
77    pub fn development(service_name: impl Into<String>) -> Self {
78        Self::production(service_name).with_format(LoggingFormat::Pretty)
79    }
80
81    /// Returns the service name.
82    pub fn service_name(&self) -> &str {
83        &self.service_name
84    }
85
86    /// Sets the service version.
87    ///
88    /// The version is included in [`Self::service_span`] and
89    /// [`StructuredMakeSpan`] fields.
90    pub fn version(mut self, version: impl Into<String>) -> Self {
91        self.version = Some(version.into());
92        self
93    }
94
95    /// Sets the deployment environment.
96    ///
97    /// The environment is included in [`Self::service_span`] and
98    /// [`StructuredMakeSpan`] fields.
99    pub fn environment(mut self, environment: impl Into<String>) -> Self {
100        self.environment = Some(environment.into());
101        self
102    }
103
104    /// Sets the logging format.
105    pub fn with_format(mut self, format: LoggingFormat) -> Self {
106        self.format = format;
107        self
108    }
109
110    /// Sets the tracing level filter directive.
111    pub fn level_filter(mut self, level_filter: impl Into<String>) -> Self {
112        self.level_filter = level_filter.into();
113        self
114    }
115
116    /// Marks a header as redacted for application log code.
117    ///
118    /// This stores redaction policy for callers via [`Self::redacts_header`].
119    /// The built-in [`StructuredMakeSpan`] does not log arbitrary request
120    /// headers, so there is no automatic header scrubber to install.
121    pub fn redact_header(mut self, header: impl AsRef<str>) -> Self {
122        self.redacted_headers
123            .insert(header.as_ref().to_ascii_lowercase());
124        self
125    }
126
127    /// Returns whether the config redacts a header name.
128    pub fn redacts_header(&self, header: impl AsRef<str>) -> bool {
129        self.redacted_headers
130            .contains(&header.as_ref().to_ascii_lowercase())
131    }
132
133    /// Returns the configured output format.
134    pub const fn output_format(&self) -> LoggingFormat {
135        self.format
136    }
137
138    /// Returns the configured output format.
139    pub const fn format(&self) -> LoggingFormat {
140        self.format
141    }
142
143    /// Creates a root service span carrying stable deployment attributes.
144    pub fn service_span(&self) -> Span {
145        tracing::info_span!(
146            "service",
147            service.name = %self.service_name,
148            service.version = %self.version.as_deref().unwrap_or(""),
149            deployment.environment = %self.environment.as_deref().unwrap_or("")
150        )
151    }
152
153    /// Installs this config as the process-global tracing subscriber.
154    ///
155    /// Like other `tracing-subscriber` global installs, this usually succeeds
156    /// once per process. Tests often prefer [`Self::subscriber_with_writer`] to
157    /// avoid global state.
158    pub fn init(&self) -> Result<(), tracing_subscriber::util::TryInitError> {
159        match self.format {
160            LoggingFormat::Json => self.subscriber_with_writer(std::io::stderr).try_init(),
161            LoggingFormat::Pretty => self
162                .pretty_subscriber_with_writer(std::io::stderr)
163                .try_init(),
164        }
165    }
166
167    /// Builds a JSON subscriber using a caller-provided writer.
168    pub fn subscriber_with_writer<W>(
169        &self,
170        writer: W,
171    ) -> impl tracing::Subscriber + Send + Sync + 'static
172    where
173        W: for<'writer> MakeWriter<'writer> + Clone + Send + Sync + 'static,
174    {
175        let filter =
176            EnvFilter::try_new(&self.level_filter).unwrap_or_else(|_| EnvFilter::new("info"));
177        let layer = fmt::layer()
178            .json()
179            .flatten_event(true)
180            .with_current_span(true)
181            .with_span_list(false)
182            .with_ansi(false)
183            .with_target(false)
184            .with_writer(writer)
185            .with_filter(filter);
186        Registry::default().with(layer)
187    }
188
189    /// Builds a pretty subscriber using a caller-provided writer.
190    pub fn pretty_subscriber_with_writer<W>(
191        &self,
192        writer: W,
193    ) -> impl tracing::Subscriber + Send + Sync + 'static
194    where
195        W: for<'writer> MakeWriter<'writer> + Clone + Send + Sync + 'static,
196    {
197        let filter =
198            EnvFilter::try_new(&self.level_filter).unwrap_or_else(|_| EnvFilter::new("info"));
199        let layer = fmt::layer()
200            .pretty()
201            .with_ansi(false)
202            .with_target(false)
203            .with_writer(writer)
204            .with_filter(filter);
205        Registry::default().with(layer)
206    }
207}
208
209/// Span maker that records service, request, route, and trace context fields.
210///
211/// The span includes `service.name`, `service.version`,
212/// `deployment.environment`, `request.id`, `trace.id`, `http.method`,
213/// `http.route`, and `http.target`. Request ID and trace ID are read from
214/// `x-request-id` and `traceparent` headers respectively; use the request ID
215/// middleware before tracing when you need every request span to have an ID.
216#[derive(Clone, Debug)]
217pub struct StructuredMakeSpan {
218    config: LoggingConfig,
219    route: Option<Cow<'static, str>>,
220}
221
222impl StructuredMakeSpan {
223    /// Creates a structured HTTP span maker.
224    pub fn new(config: LoggingConfig) -> Self {
225        Self {
226            config,
227            route: None,
228        }
229    }
230
231    /// Sets the stable route pattern for spans made by this value.
232    ///
233    /// When unset, the span maker falls back to Axum's
234    /// [`axum::extract::MatchedPath`] extension and then `"<unknown>"`.
235    pub fn route(mut self, route: impl Into<Cow<'static, str>>) -> Self {
236        self.route = Some(route.into());
237        self
238    }
239}
240
241impl<B> MakeSpan<B> for StructuredMakeSpan {
242    fn make_span(&mut self, request: &Request<B>) -> Span {
243        let request_id = header_to_string(request.headers(), "x-request-id").unwrap_or_default();
244        let trace_id = header_to_string(request.headers(), "traceparent")
245            .and_then(|value| value.split('-').nth(1).map(str::to_owned))
246            .unwrap_or_default();
247        let route = self
248            .route
249            .as_deref()
250            .map(str::to_owned)
251            .or_else(|| {
252                request
253                    .extensions()
254                    .get::<axum::extract::MatchedPath>()
255                    .map(|path| path.as_str().to_owned())
256            })
257            .unwrap_or_else(|| "<unknown>".to_owned());
258
259        tracing::span!(
260            Level::INFO,
261            "http.request",
262            service.name = %self.config.service_name,
263            service.version = %self.config.version.as_deref().unwrap_or(""),
264            deployment.environment = %self.config.environment.as_deref().unwrap_or(""),
265            request.id = %request_id,
266            trace.id = %trace_id,
267            http.method = %request.method(),
268            http.route = %route,
269            http.target = %request.uri(),
270        )
271    }
272}