featherbit/plugins/native/
udp_logger.rs1use std::collections::HashMap;
21use std::sync::Arc;
22use std::time::Duration;
23
24use async_trait::async_trait;
25use serde_json::Value;
26use tokio::net::UdpSocket;
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 entry_to_datagram(entry: &Value) -> Vec<u8> {
36 serde_json::to_vec(entry).unwrap_or_else(|_| b"null".to_vec())
37}
38
39struct UdpFlusher {
41 host: String,
42 port: u16,
43 timeout: Duration,
44}
45
46impl UdpFlusher {
47 async fn send_all(&self, entries: &[Value]) -> Result<usize, (usize, String)> {
48 let addr = format!("{}:{}", self.host, self.port);
49 let socket = UdpSocket::bind("0.0.0.0:0")
50 .await
51 .map_err(|e| (0usize, format!("failed to bind UDP socket: {e}")))?;
52 for (i, entry) in entries.iter().enumerate() {
53 let payload = entry_to_datagram(entry);
54 let send = socket.send_to(&payload, &addr);
55 match tokio::time::timeout(self.timeout, send).await {
56 Ok(Ok(_)) => {}
57 Ok(Err(e)) => return Err((i, format!("failed to send to UDP server {addr}: {e}"))),
58 Err(_) => return Err((i, format!("timed out sending to UDP server {addr}"))),
59 }
60 }
61 Ok(entries.len())
62 }
63}
64
65#[async_trait]
66impl BatchFlusher for UdpFlusher {
67 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
68 match self.send_all(entries).await {
69 Ok(_) => Ok(()),
70 Err((i, message)) => Err(FlushError {
72 message,
73 first_fail: Some(i),
74 }),
75 }
76 }
77}
78
79pub struct UdpLoggerPlugin {
81 sink: BatchSink,
82 log_format: Option<HashMap<String, Value>>,
83 include_req_body: bool,
84 include_resp_body: bool,
85}
86
87impl UdpLoggerPlugin {
88 pub fn from_config(config: &HashMap<String, Value>) -> Result<Self, String> {
106 let host = config
107 .get("host")
108 .and_then(|v| v.as_str())
109 .filter(|s| !s.is_empty())
110 .ok_or("udp-logger: `host` is required")?
111 .to_string();
112 let port = config
113 .get("port")
114 .and_then(|v| v.as_u64())
115 .filter(|p| *p <= u16::MAX as u64)
116 .ok_or("udp-logger: `port` is required and must be 0-65535")? as u16;
117
118 let timeout_s = config.get("timeout").and_then(|v| v.as_u64()).unwrap_or(3);
120 let timeout = Duration::from_secs(timeout_s.max(1));
121
122 let log_format = parse_log_format(config)?;
123 let include_req_body = config
124 .get("include_req_body")
125 .and_then(|v| v.as_bool())
126 .unwrap_or(false);
127 let include_resp_body = config
128 .get("include_resp_body")
129 .and_then(|v| v.as_bool())
130 .unwrap_or(false);
131
132 let batch_cfg = BatchConfig::from_config(config)?;
133 let flusher = Arc::new(UdpFlusher {
134 host: host.clone(),
135 port,
136 timeout,
137 });
138 let sink = BatchSink::spawn(&format!("udp-logger:{host}:{port}"), batch_cfg, flusher);
139
140 Ok(Self {
141 sink,
142 log_format,
143 include_req_body,
144 include_resp_body,
145 })
146 }
147}
148
149#[async_trait]
150impl Plugin for UdpLoggerPlugin {
151 fn plugin_type(&self) -> &str {
152 "udp-logger"
153 }
154
155 async fn execute(&self, ctx: Context, _named_inputs: &HashMap<String, Value>) -> PluginResult {
156 let entry = build_entry(
157 &ctx,
158 self.log_format.as_ref(),
159 self.include_req_body,
160 self.include_resp_body,
161 );
162 self.sink.push(entry);
163 Ok(PluginOutput {
164 context: ctx,
165 named_outputs: HashMap::new(),
166 })
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use serde_json::json;
174
175 fn base_config() -> HashMap<String, Value> {
176 let mut c = HashMap::new();
177 c.insert("host".to_string(), json!("127.0.0.1"));
178 c.insert("port".to_string(), json!(5140));
179 c
180 }
181
182 #[test]
183 fn from_config_requires_host_and_port() {
184 assert!(UdpLoggerPlugin::from_config(&HashMap::new()).is_err());
185 let mut c = HashMap::new();
186 c.insert("port".to_string(), json!(514));
187 assert!(
188 UdpLoggerPlugin::from_config(&c).is_err(),
189 "missing host must fail"
190 );
191 }
192
193 #[tokio::test]
194 async fn from_config_ok_with_valid_config() {
195 assert!(UdpLoggerPlugin::from_config(&base_config()).is_ok());
196 }
197
198 #[test]
199 fn entry_to_datagram_is_compact_json() {
200 let payload = entry_to_datagram(&json!({"a": 1, "b": "x"}));
201 let back: Value = serde_json::from_slice(&payload).unwrap();
202 assert_eq!(back, json!({"a": 1, "b": "x"}));
203 }
204}