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, LogFormat};
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        // tokio::fs::File buffers writes through a background blocking task and
69        // does NOT guarantee delivery on drop — without this await, the batch
70        // can still be in flight when flush() returns (lost lines on exit, and
71        // a race observed as CI flakiness in flusher_appends_to_file).
72        file.flush().await.map_err(|e| FlushError {
73            message: format!("failed to flush log file {}: {e}", self.path.display()),
74            first_fail: None,
75        })
76    }
77}
78
79/// The `file-logger` plugin node.
80pub struct FileLoggerPlugin {
81    sink: BatchSink,
82    log_format: Option<LogFormat>,
83    include_req_body: bool,
84    include_resp_body: bool,
85}
86
87impl FileLoggerPlugin {
88    /// Builds the plugin from node config.
89    ///
90    /// Config keys:
91    /// - `path` (string, **required**): target file path. Opened in append mode;
92    ///   created if absent, but its parent directory must already exist.
93    /// - `log_format` (object): custom `name -> "template"` entry (`{{namespace.path}}` references plus legacy `$var` interpolation).
94    /// - `include_req_body` / `include_resp_body` (bool, default `false`).
95    /// - batch keys (optional) — see [`BatchConfig::from_config`]. Use
96    ///   `batch_max_size: 1` for immediate per-request writes.
97    ///
98    /// ```yaml
99    /// - id: file-log
100    ///   type: file-logger
101    ///   config:
102    ///     path: /var/log/featherbit/access.log
103    ///     batch_max_size: 1
104    /// ```
105    pub fn from_config(config: &HashMap<String, Value>) -> Result<Self, String> {
106        let path = config
107            .get("path")
108            .and_then(|v| v.as_str())
109            .filter(|s| !s.is_empty())
110            .ok_or("file-logger: `path` is required")?
111            .to_string();
112
113        let log_format = parse_log_format(config)?;
114        let include_req_body = config
115            .get("include_req_body")
116            .and_then(|v| v.as_bool())
117            .unwrap_or(false);
118        let include_resp_body = config
119            .get("include_resp_body")
120            .and_then(|v| v.as_bool())
121            .unwrap_or(false);
122
123        let batch_cfg = BatchConfig::from_config(config)?;
124        let flusher = Arc::new(FileFlusher {
125            path: PathBuf::from(&path),
126        });
127        let sink = BatchSink::spawn(&format!("file-logger:{path}"), batch_cfg, flusher);
128
129        Ok(Self {
130            sink,
131            log_format,
132            include_req_body,
133            include_resp_body,
134        })
135    }
136}
137
138#[async_trait]
139impl Plugin for FileLoggerPlugin {
140    fn plugin_type(&self) -> &str {
141        "file-logger"
142    }
143
144    fn reads_response_body(&self) -> bool {
145        crate::plugins::util::log_entry::reads_response_body(
146            self.log_format.as_ref(),
147            self.include_resp_body,
148        )
149    }
150
151    async fn execute(&self, ctx: Context) -> PluginResult {
152        let entry = build_entry(
153            &ctx,
154            self.log_format.as_ref(),
155            self.include_req_body,
156            self.include_resp_body,
157        );
158        self.sink.push(entry);
159        Ok(PluginOutput::success(ctx))
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use serde_json::json;
167
168    #[test]
169    fn from_config_requires_path() {
170        assert!(FileLoggerPlugin::from_config(&HashMap::new()).is_err());
171    }
172
173    #[test]
174    fn entries_to_lines_is_newline_delimited() {
175        let out = entries_to_lines(&[json!({"a": 1}), json!({"b": 2})]);
176        assert_eq!(out, "{\"a\":1}\n{\"b\":2}\n");
177    }
178
179    #[tokio::test]
180    async fn flusher_appends_to_file() {
181        let mut path = std::env::temp_dir();
182        path.push(format!(
183            "featherbit-file-logger-test-{}.log",
184            std::process::id()
185        ));
186        // Clean any stale file from a prior run.
187        let _ = tokio::fs::remove_file(&path).await;
188
189        let flusher = FileFlusher { path: path.clone() };
190        flusher.flush(&[json!({"n": 1})]).await.unwrap();
191        flusher.flush(&[json!({"n": 2})]).await.unwrap();
192
193        let contents = tokio::fs::read_to_string(&path).await.unwrap();
194        assert_eq!(contents, "{\"n\":1}\n{\"n\":2}\n");
195
196        let _ = tokio::fs::remove_file(&path).await;
197    }
198}