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, LogFormat};
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 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
79pub 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 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 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}