featherbit/plugins/native/
file_logger.rs1use 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
33fn 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
44struct 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
71pub 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 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 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}