Skip to main content

featherbit/plugins/native/
file_logger.rs

1//! The `file-logger` node — appends per-request access-log entries as
2//! newline-delimited JSON to a local file, mirroring APISIX's `file-logger`
3//! plugin.
4//!
5//! Entries are built with the shared [`build_entry`](crate::plugins::util::log_entry)
6//! helper and handed to a [`BatchSink`]; a background task opens the target
7//! file in append mode per flush and writes each entry as one JSON line.
8//! Writing is fire-and-forget on the request path — [`Plugin::execute`] pushes
9//! the entry and returns the context unchanged — so place this node in the
10//! response pipeline **after the `upstream` node**.
11//!
12//! ## Deviations from APISIX
13//! - APISIX writes each request immediately; featherbit routes writes through
14//!   the shared [`BatchSink`] for consistency with the other loggers. Set
15//!   `batch_max_size: 1` to write every entry as it arrives.
16//! - The parent directory is **not** created: if it is missing the flush fails
17//!   (logged and, per [`BatchConfig`], retried/dropped). The file itself is
18//!   created if absent.
19
20use std::collections::HashMap;
21use std::path::PathBuf;
22use std::sync::Arc;
23
24use async_trait::async_trait;
25use serde_json::Value;
26use tokio::io::AsyncWriteExt;
27
28use crate::batch::{BatchConfig, BatchFlusher, BatchSink, FlushError};
29use crate::context::Context;
30use crate::plugins::util::log_entry::{build_entry, parse_log_format};
31use crate::plugins::{Plugin, PluginOutput, PluginResult};
32
33/// Serializes each entry as one line of JSON, newline-terminated. Pure and
34/// I/O-independent for unit testing.
35fn entries_to_lines(entries: &[Value]) -> String {
36    let mut out = String::new();
37    for entry in entries {
38        out.push_str(&serde_json::to_string(entry).unwrap_or_else(|_| "null".to_string()));
39        out.push('\n');
40    }
41    out
42}
43
44/// [`BatchFlusher`] that appends the batch to `path`.
45struct FileFlusher {
46    path: PathBuf,
47}
48
49#[async_trait]
50impl BatchFlusher for FileFlusher {
51    async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
52        let payload = entries_to_lines(entries);
53        let mut file = tokio::fs::OpenOptions::new()
54            .create(true)
55            .append(true)
56            .open(&self.path)
57            .await
58            .map_err(|e| FlushError {
59                message: format!("failed to open log file {}: {e}", self.path.display()),
60                first_fail: None,
61            })?;
62        file.write_all(payload.as_bytes())
63            .await
64            .map_err(|e| FlushError {
65                message: format!("failed to write log file {}: {e}", self.path.display()),
66                first_fail: None,
67            })
68    }
69}
70
71/// The `file-logger` plugin node.
72pub struct FileLoggerPlugin {
73    sink: BatchSink,
74    log_format: Option<HashMap<String, Value>>,
75    include_req_body: bool,
76    include_resp_body: bool,
77}
78
79impl FileLoggerPlugin {
80    /// Builds the plugin from node config.
81    ///
82    /// Config keys:
83    /// - `path` (string, **required**): target file path. Opened in append mode;
84    ///   created if absent, but its parent directory must already exist.
85    /// - `log_format` (object): custom `name -> "$var"` entry.
86    /// - `include_req_body` / `include_resp_body` (bool, default `false`).
87    /// - batch keys (optional) — see [`BatchConfig::from_config`]. Use
88    ///   `batch_max_size: 1` for immediate per-request writes.
89    ///
90    /// ```yaml
91    /// - id: file-log
92    ///   type: file-logger
93    ///   config:
94    ///     path: /var/log/featherbit/access.log
95    ///     batch_max_size: 1
96    /// ```
97    pub fn from_config(config: &HashMap<String, Value>) -> Result<Self, String> {
98        let path = config
99            .get("path")
100            .and_then(|v| v.as_str())
101            .filter(|s| !s.is_empty())
102            .ok_or("file-logger: `path` is required")?
103            .to_string();
104
105        let log_format = parse_log_format(config)?;
106        let include_req_body = config
107            .get("include_req_body")
108            .and_then(|v| v.as_bool())
109            .unwrap_or(false);
110        let include_resp_body = config
111            .get("include_resp_body")
112            .and_then(|v| v.as_bool())
113            .unwrap_or(false);
114
115        let batch_cfg = BatchConfig::from_config(config)?;
116        let flusher = Arc::new(FileFlusher {
117            path: PathBuf::from(&path),
118        });
119        let sink = BatchSink::spawn(&format!("file-logger:{path}"), batch_cfg, flusher);
120
121        Ok(Self {
122            sink,
123            log_format,
124            include_req_body,
125            include_resp_body,
126        })
127    }
128}
129
130#[async_trait]
131impl Plugin for FileLoggerPlugin {
132    fn plugin_type(&self) -> &str {
133        "file-logger"
134    }
135
136    async fn execute(&self, ctx: Context, _named_inputs: &HashMap<String, Value>) -> PluginResult {
137        let entry = build_entry(
138            &ctx,
139            self.log_format.as_ref(),
140            self.include_req_body,
141            self.include_resp_body,
142        );
143        self.sink.push(entry);
144        Ok(PluginOutput {
145            context: ctx,
146            named_outputs: HashMap::new(),
147        })
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use serde_json::json;
155
156    #[test]
157    fn from_config_requires_path() {
158        assert!(FileLoggerPlugin::from_config(&HashMap::new()).is_err());
159    }
160
161    #[test]
162    fn entries_to_lines_is_newline_delimited() {
163        let out = entries_to_lines(&[json!({"a": 1}), json!({"b": 2})]);
164        assert_eq!(out, "{\"a\":1}\n{\"b\":2}\n");
165    }
166
167    #[tokio::test]
168    async fn flusher_appends_to_file() {
169        let mut path = std::env::temp_dir();
170        path.push(format!(
171            "featherbit-file-logger-test-{}.log",
172            std::process::id()
173        ));
174        // Clean any stale file from a prior run.
175        let _ = tokio::fs::remove_file(&path).await;
176
177        let flusher = FileFlusher { path: path.clone() };
178        flusher.flush(&[json!({"n": 1})]).await.unwrap();
179        flusher.flush(&[json!({"n": 2})]).await.unwrap();
180
181        let contents = tokio::fs::read_to_string(&path).await.unwrap();
182        assert_eq!(contents, "{\"n\":1}\n{\"n\":2}\n");
183
184        let _ = tokio::fs::remove_file(&path).await;
185    }
186}