featherbit/plugins/native/
error_log_logger.rs1use std::collections::HashMap;
41use std::sync::Arc;
42use std::time::Duration;
43
44use async_trait::async_trait;
45use serde_json::Value;
46use tokio::io::AsyncWriteExt;
47use tokio::net::TcpStream;
48
49use crate::batch::{BatchConfig, BatchFlusher, BatchSink, FlushError};
50use crate::context::Context;
51use crate::plugins::util::log_entry::{build_entry, parse_log_format, LogFormat};
52use crate::plugins::{Plugin, PluginOutput, PluginResult};
53
54fn entries_to_lines(entries: &[Value]) -> String {
56 let mut out = String::new();
57 for entry in entries {
58 out.push_str(&serde_json::to_string(entry).unwrap_or_else(|_| "null".to_string()));
59 out.push('\n');
60 }
61 out
62}
63
64struct TcpFlusher {
66 host: String,
67 port: u16,
68 timeout: Duration,
69}
70
71#[async_trait]
72impl BatchFlusher for TcpFlusher {
73 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
74 let addr = format!("{}:{}", self.host, self.port);
75 let payload = entries_to_lines(entries);
76 let result = async {
77 let mut stream = tokio::time::timeout(self.timeout, TcpStream::connect(&addr))
78 .await
79 .map_err(|_| format!("timed out connecting to TCP server {addr}"))?
80 .map_err(|e| format!("failed to connect to TCP server {addr}: {e}"))?;
81 tokio::time::timeout(self.timeout, stream.write_all(payload.as_bytes()))
82 .await
83 .map_err(|_| format!("timed out sending to TCP server {addr}"))?
84 .map_err(|e| format!("failed to send to TCP server {addr}: {e}"))?;
85 let _ = stream.shutdown().await;
86 Ok::<(), String>(())
87 }
88 .await;
89 result.map_err(|message| FlushError {
90 message,
91 first_fail: None,
92 })
93 }
94}
95
96pub struct ErrorLogLoggerPlugin {
98 sink: BatchSink,
99 log_format: Option<LogFormat>,
100}
101
102impl ErrorLogLoggerPlugin {
103 pub fn from_config(config: &HashMap<String, Value>) -> Result<Self, String> {
123 let host = config
124 .get("host")
125 .and_then(|v| v.as_str())
126 .filter(|s| !s.is_empty())
127 .ok_or("error-log-logger: `host` is required")?
128 .to_string();
129 let port = config
130 .get("port")
131 .and_then(|v| v.as_u64())
132 .filter(|p| *p <= u16::MAX as u64)
133 .ok_or("error-log-logger: `port` is required and must be 0-65535")?
134 as u16;
135
136 if config.get("tls").and_then(|v| v.as_bool()).unwrap_or(false) {
137 return Err("error-log-logger: TLS (`tls: true`) is not yet supported".to_string());
138 }
139
140 if let Some(level) = config.get("level").and_then(|v| v.as_str()) {
142 const LEVELS: [&str; 10] = [
143 "STDERR", "EMERG", "ALERT", "CRIT", "ERR", "ERROR", "WARN", "NOTICE", "INFO",
144 "DEBUG",
145 ];
146 if !LEVELS.contains(&level) {
147 return Err(format!("error-log-logger: unknown `level` {level}"));
148 }
149 }
150
151 let timeout_s = config.get("timeout").and_then(|v| v.as_u64()).unwrap_or(3);
153 let timeout = Duration::from_secs(timeout_s.max(1));
154
155 let log_format = parse_log_format(config)?;
156
157 let batch_cfg = BatchConfig::from_config(config)?;
158 let flusher = Arc::new(TcpFlusher {
159 host: host.clone(),
160 port,
161 timeout,
162 });
163 let sink = BatchSink::spawn(
164 &format!("error-log-logger:{host}:{port}"),
165 batch_cfg,
166 flusher,
167 );
168
169 Ok(Self { sink, log_format })
170 }
171}
172
173#[async_trait]
174impl Plugin for ErrorLogLoggerPlugin {
175 fn plugin_type(&self) -> &str {
176 "error-log-logger"
177 }
178
179 fn reads_response_body(&self) -> bool {
180 crate::plugins::util::log_entry::reads_response_body(self.log_format.as_ref(), false)
183 }
184
185 async fn execute(&self, ctx: Context) -> PluginResult {
186 if !ctx.errors.is_empty() {
188 let entry = build_entry(&ctx, self.log_format.as_ref(), false, false);
189 self.sink.push(entry);
190 }
191 Ok(PluginOutput::success(ctx))
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::context::{GatewayError, GatewayRequest, GatewayResponse, Protocol};
199 use bytes::Bytes;
200 use serde_json::json;
201
202 fn base_config() -> HashMap<String, Value> {
203 let mut c = HashMap::new();
204 c.insert("host".to_string(), json!("127.0.0.1"));
205 c.insert("port".to_string(), json!(5044));
206 c
207 }
208
209 fn ctx(errors: Vec<GatewayError>) -> Context {
210 Context {
211 request: GatewayRequest {
212 method: "GET".to_string(),
213 path: "/".to_string(),
214 host: "example.com".to_string(),
215 scheme: "http".to_string(),
216 headers: HashMap::new(),
217 query_params: HashMap::new(),
218 body: Bytes::new(),
219 remote_addr: "127.0.0.1:5000".to_string(),
220 protocol: Protocol::Http1,
221 },
222 response: GatewayResponse {
223 status_code: 500,
224 headers: HashMap::new(),
225 body: Bytes::new(),
226 stream: None,
227 },
228 message: HashMap::new(),
229 errors,
230 }
231 }
232
233 #[test]
234 fn from_config_requires_host_and_port() {
235 assert!(ErrorLogLoggerPlugin::from_config(&HashMap::new()).is_err());
236 }
237
238 #[test]
239 fn from_config_rejects_bad_level_and_tls() {
240 let mut c = base_config();
241 c.insert("level".to_string(), json!("LOUD"));
242 assert!(ErrorLogLoggerPlugin::from_config(&c).is_err());
243
244 let mut c = base_config();
245 c.insert("tls".to_string(), json!(true));
246 assert!(ErrorLogLoggerPlugin::from_config(&c)
247 .err()
248 .unwrap()
249 .contains("TLS"));
250 }
251
252 #[tokio::test]
253 async fn execute_logs_only_on_errors() {
254 let clean = build_entry(&ctx(vec![]), None, false, false);
258 assert!(clean.get("errors").is_none());
259
260 let errored = ctx(vec![GatewayError {
261 node_id: "upstream".to_string(),
262 code: "502".to_string(),
263 message: "bad gateway".to_string(),
264 metadata: HashMap::new(),
265 }]);
266 let entry = build_entry(&errored, None, false, false);
267 assert!(entry.get("errors").is_some());
268 }
269}