featherbit/plugins/native/
tcp_logger.rs1use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Duration;
24
25use async_trait::async_trait;
26use serde_json::Value;
27use tokio::io::AsyncWriteExt;
28use tokio::net::TcpStream;
29
30use crate::batch::{BatchConfig, BatchFlusher, BatchSink, FlushError};
31use crate::context::Context;
32use crate::plugins::util::log_entry::{build_entry, parse_log_format, LogFormat};
33use crate::plugins::{Plugin, PluginOutput, PluginResult};
34
35fn entries_to_lines(entries: &[Value]) -> String {
39 let mut out = String::new();
40 for entry in entries {
41 out.push_str(&serde_json::to_string(entry).unwrap_or_else(|_| "null".to_string()));
43 out.push('\n');
44 }
45 out
46}
47
48struct TcpFlusher {
50 host: String,
51 port: u16,
52 timeout: Duration,
53}
54
55impl TcpFlusher {
56 async fn send(&self, payload: &[u8]) -> Result<(), String> {
57 let addr = format!("{}:{}", self.host, self.port);
58 let mut stream = tokio::time::timeout(self.timeout, TcpStream::connect(&addr))
59 .await
60 .map_err(|_| format!("timed out connecting to TCP server {addr}"))?
61 .map_err(|e| format!("failed to connect to TCP server {addr}: {e}"))?;
62 tokio::time::timeout(self.timeout, stream.write_all(payload))
63 .await
64 .map_err(|_| format!("timed out sending to TCP server {addr}"))?
65 .map_err(|e| format!("failed to send to TCP server {addr}: {e}"))?;
66 let _ = stream.shutdown().await;
67 Ok(())
68 }
69}
70
71#[async_trait]
72impl BatchFlusher for TcpFlusher {
73 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
74 let payload = entries_to_lines(entries);
75 self.send(payload.as_bytes())
76 .await
77 .map_err(|message| FlushError {
78 message,
79 first_fail: None,
81 })
82 }
83}
84
85pub struct TcpLoggerPlugin {
87 sink: BatchSink,
88 log_format: Option<LogFormat>,
89 include_req_body: bool,
90 include_resp_body: bool,
91}
92
93impl TcpLoggerPlugin {
94 pub fn from_config(config: &HashMap<String, Value>) -> Result<Self, String> {
120 let host = config
121 .get("host")
122 .and_then(|v| v.as_str())
123 .filter(|s| !s.is_empty())
124 .ok_or("tcp-logger: `host` is required")?
125 .to_string();
126 let port = config
127 .get("port")
128 .and_then(|v| v.as_u64())
129 .filter(|p| *p <= u16::MAX as u64)
130 .ok_or("tcp-logger: `port` is required and must be 0-65535")? as u16;
131
132 if config.get("tls").and_then(|v| v.as_bool()).unwrap_or(false) {
133 return Err("tcp-logger: TLS (`tls: true`) is not yet supported".to_string());
134 }
135
136 let timeout_ms = config
137 .get("timeout")
138 .and_then(|v| v.as_u64())
139 .unwrap_or(1000);
140 let timeout = Duration::from_millis(timeout_ms.max(1));
141
142 let log_format = parse_log_format(config)?;
143 let include_req_body = config
144 .get("include_req_body")
145 .and_then(|v| v.as_bool())
146 .unwrap_or(false);
147 let include_resp_body = config
148 .get("include_resp_body")
149 .and_then(|v| v.as_bool())
150 .unwrap_or(false);
151
152 let batch_cfg = BatchConfig::from_config(config)?;
153 let flusher = Arc::new(TcpFlusher {
154 host: host.clone(),
155 port,
156 timeout,
157 });
158 let sink = BatchSink::spawn(&format!("tcp-logger:{host}:{port}"), batch_cfg, flusher);
159
160 Ok(Self {
161 sink,
162 log_format,
163 include_req_body,
164 include_resp_body,
165 })
166 }
167}
168
169#[async_trait]
170impl Plugin for TcpLoggerPlugin {
171 fn plugin_type(&self) -> &str {
172 "tcp-logger"
173 }
174
175 fn reads_response_body(&self) -> bool {
176 crate::plugins::util::log_entry::reads_response_body(
177 self.log_format.as_ref(),
178 self.include_resp_body,
179 )
180 }
181
182 async fn execute(&self, ctx: Context) -> PluginResult {
183 let entry = build_entry(
184 &ctx,
185 self.log_format.as_ref(),
186 self.include_req_body,
187 self.include_resp_body,
188 );
189 self.sink.push(entry);
190 Ok(PluginOutput::success(ctx))
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use serde_json::json;
198
199 fn base_config() -> HashMap<String, Value> {
200 let mut c = HashMap::new();
201 c.insert("host".to_string(), json!("127.0.0.1"));
202 c.insert("port".to_string(), json!(5044));
203 c
204 }
205
206 #[test]
207 fn from_config_requires_host_and_port() {
208 assert!(TcpLoggerPlugin::from_config(&HashMap::new()).is_err());
209 let mut c = HashMap::new();
210 c.insert("host".to_string(), json!("h"));
211 assert!(
212 TcpLoggerPlugin::from_config(&c).is_err(),
213 "missing port must fail"
214 );
215 }
216
217 #[test]
218 fn from_config_rejects_tls() {
219 let mut c = base_config();
220 c.insert("tls".to_string(), json!(true));
221 let err = TcpLoggerPlugin::from_config(&c).err().unwrap();
222 assert!(err.contains("TLS"), "error should mention TLS: {err}");
223 }
224
225 #[tokio::test]
226 async fn from_config_ok_with_valid_config() {
227 assert!(TcpLoggerPlugin::from_config(&base_config()).is_ok());
229 }
230
231 #[test]
232 fn entries_to_lines_is_newline_delimited_json() {
233 let entries = vec![json!({"a": 1}), json!({"b": 2})];
234 let out = entries_to_lines(&entries);
235 assert_eq!(out, "{\"a\":1}\n{\"b\":2}\n");
236 for line in out.lines() {
238 let _: Value = serde_json::from_str(line).unwrap();
239 }
240 }
241}