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    async fn execute(
60        &self,
61        ctx: Context,
62        _named_inputs: &HashMap<String, serde_json::Value>,
63    ) -> PluginResult {
64        let mut fields = serde_json::json!({
65            "method": ctx.request.method,
66            "path": ctx.request.path,
67            "host": ctx.request.host,
68            "remote_addr": ctx.request.remote_addr,
69            "status": ctx.response.status_code,
70            "response_body_bytes": ctx.response.body.len(),
71        });
72
73        if self.include_headers {
74            fields["request_headers"] =
75                serde_json::to_value(&ctx.request.headers).unwrap_or_default();
76            fields["response_headers"] =
77                serde_json::to_value(&ctx.response.headers).unwrap_or_default();
78        }
79
80        if !ctx.errors.is_empty() {
81            fields["errors"] = serde_json::to_value(&ctx.errors).unwrap_or_default();
82        }
83
84        info!(target: "access_log", "{}", fields);
85
86        Ok(PluginOutput {
87            context: ctx,
88            named_outputs: HashMap::new(),
89        })
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    //! featherbit-native structured access logger. It is a fire-and-forget
96    //! pass-through: the log line goes to the tracing subscriber (asserting its
97    //! content needs a subscriber, out of scope here), so these tests pin the two
98    //! things unit-testable in isolation: config parsing and that the plugin never
99    //! alters the request/response it logs.
100    use super::*;
101    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
102    use bytes::Bytes;
103
104    fn ctx() -> Context {
105        Context {
106            request: GatewayRequest {
107                method: "GET".to_string(),
108                path: "/hello".to_string(),
109                host: "h".to_string(),
110                scheme: "http".to_string(),
111                headers: HashMap::new(),
112                query_params: HashMap::new(),
113                body: Bytes::from_static(b"req"),
114                remote_addr: "1.2.3.4:5".to_string(),
115                protocol: Protocol::Http1,
116            },
117            response: GatewayResponse {
118                status_code: 200,
119                headers: HashMap::new(),
120                body: Bytes::from_static(b"resp"),
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(), &HashMap::new()).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}