Skip to main content

featherbit/plugins/native/
logging.rs

1//! The `logging` node — emits a structured JSON access-log line for the
2//! current request/response via `tracing` (target `access_log`).
3
4use async_trait::async_trait;
5use std::collections::HashMap;
6use tracing::info;
7
8use crate::context::Context;
9use crate::plugins::{Plugin, PluginOutput, PluginResult};
10
11/// Logs a JSON record (method, path, host, remote address, status, response
12/// body size, and any accumulated errors) at `info` level under the
13/// `access_log` target, then passes the context through unchanged.
14pub struct LoggingPlugin {
15    include_headers: bool,
16    #[allow(dead_code)] // parsed config; body logging not yet emitted
17    include_body: bool,
18}
19
20impl LoggingPlugin {
21    /// Builds the plugin from node config.
22    ///
23    /// Accepted keys (all optional; this constructor never errors):
24    /// - `include_headers` (bool, default `false`): also log request and
25    ///   response headers.
26    /// - `include_body` (bool, default `false`): reserved flag, currently
27    ///   parsed but not acted on by `execute` (only the response body *size*
28    ///   is logged).
29    ///
30    /// ```yaml
31    /// type: logging
32    /// config:
33    ///   include_headers: true
34    /// ```
35    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
36        let include_headers = config
37            .get("include_headers")
38            .and_then(|v| v.as_bool())
39            .unwrap_or(false);
40
41        let include_body = config
42            .get("include_body")
43            .and_then(|v| v.as_bool())
44            .unwrap_or(false);
45
46        Ok(Self {
47            include_headers,
48            include_body,
49        })
50    }
51}
52
53#[async_trait]
54impl Plugin for LoggingPlugin {
55    fn plugin_type(&self) -> &str {
56        "logging"
57    }
58
59    // No `reads_response_body` override: `execute` below reads
60    // `ctx.response.body.len()` into `response_body_bytes` on every call, so
61    // this plugin must keep the trait default (`true`) and force buffering.
62    // If a future change stops reading the body at all, an override can be
63    // added then.
64
65    async fn execute(&self, ctx: Context) -> PluginResult {
66        let mut fields = serde_json::json!({
67            "method": ctx.request.method,
68            "path": ctx.request.path,
69            "host": ctx.request.host,
70            "remote_addr": ctx.request.remote_addr,
71            "status": ctx.response.status_code,
72            "response_body_bytes": ctx.response.body.len(),
73        });
74
75        if self.include_headers {
76            fields["request_headers"] =
77                serde_json::to_value(&ctx.request.headers).unwrap_or_default();
78            fields["response_headers"] =
79                serde_json::to_value(&ctx.response.headers).unwrap_or_default();
80        }
81
82        if !ctx.errors.is_empty() {
83            fields["errors"] = serde_json::to_value(&ctx.errors).unwrap_or_default();
84        }
85
86        info!(target: "access_log", "{}", fields);
87
88        Ok(PluginOutput::success(ctx))
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    //! featherbit-native structured access logger. It is a fire-and-forget
95    //! pass-through: the log line goes to the tracing subscriber (asserting its
96    //! content needs a subscriber, out of scope here), so these tests pin the two
97    //! things unit-testable in isolation: config parsing and that the plugin never
98    //! alters the request/response it logs.
99    use super::*;
100    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
101    use bytes::Bytes;
102
103    fn ctx() -> Context {
104        Context {
105            request: GatewayRequest {
106                method: "GET".to_string(),
107                path: "/hello".to_string(),
108                host: "h".to_string(),
109                scheme: "http".to_string(),
110                headers: HashMap::new(),
111                query_params: HashMap::new(),
112                body: Bytes::from_static(b"req"),
113                remote_addr: "1.2.3.4:5".to_string(),
114                protocol: Protocol::Http1,
115            },
116            response: GatewayResponse {
117                status_code: 200,
118                headers: HashMap::new(),
119                body: Bytes::from_static(b"resp"),
120                stream: None,
121            },
122            message: HashMap::new(),
123            errors: Vec::new(),
124        }
125    }
126
127    #[tokio::test]
128    async fn test_passes_context_through_unchanged() {
129        let mut map = HashMap::new();
130        map.insert("include_headers".to_string(), serde_json::json!(true));
131        let plugin = LoggingPlugin::from_config(&map).unwrap();
132
133        let out = plugin.execute(ctx()).await.unwrap();
134        // A logger must not mutate the traffic it observes.
135        assert_eq!(out.context.response.status_code, 200);
136        assert_eq!(out.context.response.body, Bytes::from_static(b"resp"));
137        assert_eq!(out.context.request.path, "/hello");
138    }
139
140    #[test]
141    fn test_config_defaults_off() {
142        let plugin = LoggingPlugin::from_config(&HashMap::new()).unwrap();
143        assert!(!plugin.include_headers);
144        assert!(!plugin.include_body);
145    }
146
147    /// `execute` reads `ctx.response.body.len()` into `response_body_bytes`
148    /// on every call, regardless of config, so this plugin must never opt
149    /// out of `reads_response_body` — it must keep the trait default
150    /// (`true`) and force buffering.
151    #[test]
152    fn test_reads_response_body_defaults_true() {
153        let plugin = LoggingPlugin::from_config(&HashMap::new()).unwrap();
154        assert!(plugin.reads_response_body());
155    }
156}