featherbit/plugins/native/
elasticsearch_logger.rs1use std::collections::HashMap;
28use std::sync::atomic::{AtomicUsize, Ordering};
29use std::sync::Arc;
30use std::time::Duration;
31
32use async_trait::async_trait;
33use base64::{engine::general_purpose::STANDARD, Engine};
34use bytes::Bytes;
35use serde_json::Value;
36
37use crate::batch::{BatchConfig, BatchFlusher, BatchSink, FlushError};
38use crate::context::Context;
39use crate::outbound::{OutboundClient, OutboundRequest};
40use crate::plugins::resources::PluginResources;
41use crate::plugins::util::log_entry::{build_entry, parse_log_format};
42use crate::plugins::{Plugin, PluginOutput, PluginResult};
43
44pub struct ElasticsearchLoggerPlugin {
46 sink: BatchSink,
47 log_format: Option<HashMap<String, Value>>,
48 include_req_body: bool,
49 include_resp_body: bool,
50}
51
52struct EsFlusher {
54 client: Arc<OutboundClient>,
55 endpoints: Vec<String>,
57 cursor: AtomicUsize,
59 index: String,
61 authorization: Option<String>,
63 ssl_verify: bool,
64 timeout: Duration,
65}
66
67impl ElasticsearchLoggerPlugin {
68 pub fn from_config(
104 config: &HashMap<String, Value>,
105 resources: &Arc<PluginResources>,
106 ) -> Result<Self, String> {
107 let endpoints = collect_endpoints(config)?;
108
109 let field = config
110 .get("field")
111 .and_then(|v| v.as_object())
112 .ok_or_else(|| "elasticsearch-logger requires 'field'".to_string())?;
113 let index = field
114 .get("index")
115 .and_then(|v| v.as_str())
116 .filter(|s| !s.is_empty())
117 .ok_or_else(|| "elasticsearch-logger requires 'field.index'".to_string())?
118 .to_string();
119
120 let authorization = config
121 .get("auth")
122 .and_then(|v| v.as_object())
123 .and_then(|auth| {
124 let user = auth.get("username").and_then(|v| v.as_str())?;
125 let pass = auth.get("password").and_then(|v| v.as_str())?;
126 Some(format!(
127 "Basic {}",
128 STANDARD.encode(format!("{user}:{pass}"))
129 ))
130 });
131
132 let ssl_verify = config
133 .get("ssl_verify")
134 .and_then(|v| v.as_bool())
135 .unwrap_or(true);
136 let timeout =
137 Duration::from_secs(config.get("timeout").and_then(|v| v.as_u64()).unwrap_or(10));
138
139 let include_req_body = config
140 .get("include_req_body")
141 .and_then(|v| v.as_bool())
142 .unwrap_or(false);
143 let include_resp_body = config
144 .get("include_resp_body")
145 .and_then(|v| v.as_bool())
146 .unwrap_or(false);
147 let log_format = parse_log_format(config)?;
148
149 let batch_cfg = BatchConfig::from_config(config)?;
150 let flusher = Arc::new(EsFlusher {
151 client: resources.outbound.clone(),
152 endpoints,
153 cursor: AtomicUsize::new(0),
154 index,
155 authorization,
156 ssl_verify,
157 timeout,
158 });
159 let sink = BatchSink::spawn("elasticsearch-logger", batch_cfg, flusher);
160
161 Ok(Self {
162 sink,
163 log_format,
164 include_req_body,
165 include_resp_body,
166 })
167 }
168}
169
170fn collect_endpoints(config: &HashMap<String, Value>) -> Result<Vec<String>, String> {
173 let mut endpoints = Vec::new();
174 if let Some(s) = config.get("endpoint_addr").and_then(|v| v.as_str()) {
175 endpoints.push(s.trim_end_matches('/').to_string());
176 }
177 if let Some(arr) = config.get("endpoint_addrs").and_then(|v| v.as_array()) {
178 for v in arr {
179 if let Some(s) = v.as_str() {
180 endpoints.push(s.trim_end_matches('/').to_string());
181 }
182 }
183 }
184 if endpoints.is_empty() {
185 return Err(
186 "elasticsearch-logger requires 'endpoint_addr' or 'endpoint_addrs'".to_string(),
187 );
188 }
189 Ok(endpoints)
190}
191
192fn build_bulk_body(index: &str, entries: &[Value]) -> String {
196 let action = serde_json::json!({ "index": { "_index": index } });
197 let action_line = serde_json::to_string(&action).unwrap_or_default();
198 let mut body = String::new();
199 for entry in entries {
200 body.push_str(&action_line);
201 body.push('\n');
202 body.push_str(&serde_json::to_string(entry).unwrap_or_else(|_| "{}".to_string()));
203 body.push('\n');
204 }
205 body
206}
207
208#[async_trait]
209impl BatchFlusher for EsFlusher {
210 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
211 let idx = self.cursor.fetch_add(1, Ordering::Relaxed) % self.endpoints.len();
212 let url = format!("{}/_bulk", self.endpoints[idx]);
213 let body = build_bulk_body(&self.index, entries);
214
215 let mut headers = vec![
216 (
217 "Content-Type".to_string(),
218 "application/x-ndjson".to_string(),
219 ),
220 (
221 "Accept".to_string(),
222 "application/vnd.elasticsearch+json".to_string(),
223 ),
224 ];
225 if let Some(auth) = &self.authorization {
226 headers.push(("Authorization".to_string(), auth.clone()));
227 }
228
229 let req = OutboundRequest {
230 method: http::Method::POST,
231 url,
232 headers,
233 body: Bytes::from(body),
234 timeout: self.timeout,
235 ssl_verify: self.ssl_verify,
236 tls: None,
237 };
238
239 match self.client.request(req).await {
240 Ok(resp) if resp.status == 200 => Ok(()),
241 Ok(resp) => Err(FlushError {
242 message: format!(
243 "elasticsearch returned status {}: {}",
244 resp.status,
245 String::from_utf8_lossy(&resp.body)
246 ),
247 first_fail: None,
248 }),
249 Err(e) => Err(FlushError {
250 message: format!("elasticsearch callout failed: {e}"),
251 first_fail: None,
252 }),
253 }
254 }
255}
256
257#[async_trait]
258impl Plugin for ElasticsearchLoggerPlugin {
259 fn plugin_type(&self) -> &str {
260 "elasticsearch-logger"
261 }
262
263 async fn execute(&self, ctx: Context, _named_inputs: &HashMap<String, Value>) -> PluginResult {
264 let entry = build_entry(
265 &ctx,
266 self.log_format.as_ref(),
267 self.include_req_body,
268 self.include_resp_body,
269 );
270 self.sink.push(entry);
271 Ok(PluginOutput {
272 context: ctx,
273 named_outputs: HashMap::new(),
274 })
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use serde_json::json;
282
283 fn cfg(v: Value) -> HashMap<String, Value> {
284 serde_json::from_value(v).unwrap()
285 }
286
287 #[tokio::test]
288 async fn requires_endpoint_and_index() {
289 assert!(ElasticsearchLoggerPlugin::from_config(
291 &cfg(json!({ "field": { "index": "svc" } })),
292 &PluginResources::empty()
293 )
294 .is_err());
295 assert!(ElasticsearchLoggerPlugin::from_config(
297 &cfg(json!({ "endpoint_addr": "http://es:9200" })),
298 &PluginResources::empty()
299 )
300 .is_err());
301 assert!(ElasticsearchLoggerPlugin::from_config(
303 &cfg(json!({ "endpoint_addr": "http://es:9200/", "field": { "index": "svc" } })),
304 &PluginResources::empty()
305 )
306 .is_ok());
307 }
308
309 #[test]
310 fn collects_and_trims_endpoints() {
311 let e = collect_endpoints(&cfg(json!({
312 "endpoint_addr": "http://a:9200/",
313 "endpoint_addrs": ["http://b:9200/", "http://c:9200"]
314 })))
315 .unwrap();
316 assert_eq!(e, vec!["http://a:9200", "http://b:9200", "http://c:9200"]);
317 }
318
319 #[tokio::test]
320 async fn basic_auth_header_encoded() {
321 let p = ElasticsearchLoggerPlugin::from_config(
322 &cfg(json!({
323 "endpoint_addr": "http://es:9200",
324 "field": { "index": "svc" },
325 "auth": { "username": "elastic", "password": "secret" }
326 })),
327 &PluginResources::empty(),
328 );
329 assert!(p.is_ok());
330 }
331
332 #[test]
333 fn bulk_body_ndjson_shape() {
334 let entries = vec![json!({ "a": 1 }), json!({ "b": 2 })];
335 let body = build_bulk_body("svc", &entries);
336 let lines: Vec<&str> = body.split('\n').collect();
337 assert_eq!(lines.len(), 5);
339 assert_eq!(lines[0], r#"{"index":{"_index":"svc"}}"#);
340 assert_eq!(lines[1], r#"{"a":1}"#);
341 assert_eq!(lines[2], r#"{"index":{"_index":"svc"}}"#);
342 assert_eq!(lines[3], r#"{"b":2}"#);
343 assert_eq!(lines[4], "");
344 assert!(body.ends_with('\n'));
345 }
346}