ant_core/node/daemon/forward/
es.rs1use std::time::Duration;
23
24use futures::future::BoxFuture;
25
26use super::document::ForwardDocument;
27use super::sink::{BatchOutcome, DocumentOutcome, LogSink};
28
29const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
31
32pub struct ElasticsearchSink {
34 client: reqwest::Client,
35 bulk_url: String,
36 token: String,
37}
38
39impl ElasticsearchSink {
40 pub fn new(endpoint_base: &str, token: &str) -> crate::error::Result<Self> {
42 let client = reqwest::Client::builder()
43 .timeout(REQUEST_TIMEOUT)
44 .build()
45 .map_err(|e| crate::error::Error::LogForward(format!("HTTP client: {e}")))?;
46
47 Ok(Self {
48 client,
49 bulk_url: format!("{}/_bulk", endpoint_base.trim_end_matches('/')),
50 token: token.to_string(),
51 })
52 }
53
54 #[must_use]
59 pub fn build_body(batch: &[ForwardDocument]) -> String {
60 let mut body = String::new();
61
62 for document in batch {
63 let action = serde_json::json!({
64 "create": { "_index": document.index, "_id": document.id }
65 });
66 body.push_str(&serde_json::to_string(&action).unwrap_or_default());
69 body.push('\n');
70 body.push_str(&serde_json::to_string(&document.source).unwrap_or_default());
71 body.push('\n');
72 }
73
74 body
75 }
76
77 #[must_use]
79 pub fn classify_item_status(status: u64) -> DocumentOutcome {
80 match status {
81 200..=299 | 409 => DocumentOutcome::Delivered,
84 429 | 500..=599 => DocumentOutcome::Retryable,
86 _ => DocumentOutcome::Rejected,
89 }
90 }
91
92 #[must_use]
94 pub fn classify_response(body: &str, batch_len: usize) -> BatchOutcome {
95 let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
96 return BatchOutcome {
99 outcomes: vec![DocumentOutcome::Retryable; batch_len],
100 transport_failure: false,
101 error: Some("could not parse the bulk response".to_string()),
102 };
103 };
104
105 if value.get("errors").and_then(serde_json::Value::as_bool) == Some(false) {
106 return BatchOutcome::all_delivered(batch_len);
107 }
108
109 let Some(items) = value.get("items").and_then(serde_json::Value::as_array) else {
110 return BatchOutcome {
111 outcomes: vec![DocumentOutcome::Retryable; batch_len],
112 transport_failure: false,
113 error: Some("bulk response reported errors but listed no items".to_string()),
114 };
115 };
116
117 let mut outcomes = Vec::with_capacity(items.len());
118 let mut permanent_error = None;
122 let mut transient_error = None;
123
124 for item in items {
125 let entry = item
127 .as_object()
128 .and_then(|object| object.values().next())
129 .and_then(serde_json::Value::as_object);
130
131 let status = entry
132 .and_then(|entry| entry.get("status"))
133 .and_then(serde_json::Value::as_u64);
134
135 let outcome = match status {
136 Some(status) => {
137 let classified = Self::classify_item_status(status);
138 match classified {
139 DocumentOutcome::Rejected if permanent_error.is_none() => {
140 permanent_error = Some(describe_item_error(entry, status));
141 }
142 DocumentOutcome::Retryable if transient_error.is_none() => {
143 transient_error = Some(describe_item_error(entry, status));
144 }
145 _ => {}
146 }
147 classified
148 }
149 None => DocumentOutcome::Retryable,
151 };
152 outcomes.push(outcome);
153 }
154
155 BatchOutcome {
158 outcomes,
159 transport_failure: false,
160 error: permanent_error.or(transient_error),
161 }
162 }
163}
164
165fn describe_item_error(
166 entry: Option<&serde_json::Map<String, serde_json::Value>>,
167 status: u64,
168) -> String {
169 let reason = entry
170 .and_then(|entry| entry.get("error"))
171 .and_then(|error| error.get("reason"))
172 .and_then(serde_json::Value::as_str);
173
174 match reason {
175 Some(reason) => format!("bulk item failed with {status}: {reason}"),
176 None => format!("bulk item failed with {status}"),
177 }
178}
179
180impl LogSink for ElasticsearchSink {
181 fn send<'a>(&'a self, batch: &'a [ForwardDocument]) -> BoxFuture<'a, BatchOutcome> {
182 Box::pin(async move {
183 if batch.is_empty() {
184 return BatchOutcome::all_delivered(0);
185 }
186
187 let response = self
188 .client
189 .post(&self.bulk_url)
190 .header("Authorization", format!("ApiKey {}", self.token))
191 .header("Content-Type", "application/x-ndjson")
192 .body(Self::build_body(batch))
193 .send()
194 .await;
195
196 let response = match response {
197 Ok(response) => response,
198 Err(error) => return BatchOutcome::transport_failure(error.to_string()),
201 };
202
203 let status = response.status();
204
205 if status.is_success() {
206 let body = response.text().await.unwrap_or_default();
207 return Self::classify_response(&body, batch.len());
208 }
209
210 let outcome = if status.as_u16() == 429 || status.is_server_error() {
213 DocumentOutcome::Retryable
214 } else {
215 DocumentOutcome::Rejected
216 };
217
218 BatchOutcome {
219 outcomes: vec![outcome; batch.len()],
220 transport_failure: false,
221 error: Some(format!("bulk request rejected with HTTP {status}")),
222 }
223 })
224 }
225
226 fn describe(&self) -> String {
227 self.bulk_url.clone()
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::node::daemon::forward::document::DocumentSource;
235
236 fn document(id: &str, index: &str) -> ForwardDocument {
237 ForwardDocument {
238 id: id.to_string(),
239 index: index.to_string(),
240 source: DocumentSource {
241 timestamp: "2026-08-19T20:50:00.000000Z".to_string(),
242 level: "INFO".to_string(),
243 target: Some("ant_node::node".to_string()),
244 message: "hello".to_string(),
245 node_id: "7".to_string(),
246 service: "node7".to_string(),
247 binary_version: "0.17.2".to_string(),
248 channel: "beta".to_string(),
249 os: "linux".to_string(),
250 arch: "x86_64".to_string(),
251 peer_id: None,
252 version: None,
253 commit: None,
254 },
255 }
256 }
257
258 #[test]
259 fn the_bulk_action_is_create_not_index() {
260 let body = ElasticsearchSink::build_body(&[document("id-1", "beta-nodes-2026.08.19")]);
261 let action: serde_json::Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
262
263 assert!(
264 action.get("create").is_some(),
265 "`index` is refused with a per-item 403: {body}"
266 );
267 assert!(action.get("index").is_none());
268 assert_eq!(action["create"]["_index"], "beta-nodes-2026.08.19");
269 assert_eq!(action["create"]["_id"], "id-1");
270 }
271
272 #[test]
273 fn the_body_is_ndjson_with_the_required_trailing_newline() {
274 let body = ElasticsearchSink::build_body(&[
275 document("id-1", "beta-nodes-2026.08.19"),
276 document("id-2", "beta-nodes-2026.08.19"),
277 ]);
278
279 assert!(body.ends_with('\n'), "Elasticsearch requires it");
280 let lines: Vec<&str> = body.lines().collect();
281 assert_eq!(lines.len(), 4, "one action and one source per document");
282
283 for line in &lines {
284 serde_json::from_str::<serde_json::Value>(line)
285 .unwrap_or_else(|_| panic!("every line must be standalone JSON: {line}"));
286 }
287 let source: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
288 assert_eq!(source["@timestamp"], "2026-08-19T20:50:00.000000Z");
289 }
290
291 #[test]
292 fn an_empty_batch_produces_an_empty_body() {
293 assert_eq!(ElasticsearchSink::build_body(&[]), "");
294 }
295
296 #[test]
299 fn created_filtered_and_already_indexed_all_count_as_delivered() {
300 for status in [200, 201, 409] {
301 assert_eq!(
302 ElasticsearchSink::classify_item_status(status),
303 DocumentOutcome::Delivered,
304 "status {status}"
305 );
306 }
307 }
308
309 #[test]
310 fn busy_and_server_errors_are_retryable() {
311 for status in [429, 500, 502, 503] {
312 assert_eq!(
313 ElasticsearchSink::classify_item_status(status),
314 DocumentOutcome::Retryable,
315 "status {status}"
316 );
317 }
318 }
319
320 #[test]
321 fn permission_and_mapping_errors_are_permanent() {
322 for status in [400, 401, 403, 404] {
323 assert_eq!(
324 ElasticsearchSink::classify_item_status(status),
325 DocumentOutcome::Rejected,
326 "status {status}"
327 );
328 }
329 }
330
331 #[test]
332 fn a_clean_response_delivers_the_whole_batch() {
333 let outcome = ElasticsearchSink::classify_response(r#"{"errors":false}"#, 3);
334 assert_eq!(outcome.outcomes, vec![DocumentOutcome::Delivered; 3]);
335 assert!(outcome.error.is_none());
336 assert!(!outcome.transport_failure);
337 }
338
339 #[test]
342 fn a_mixed_response_is_mapped_position_by_position() {
343 let body = r#"{
344 "errors": true,
345 "items": [
346 {"create": {"status": 201}},
347 {"create": {"status": 429}},
348 {"create": {"status": 403, "error": {"reason": "action [create] is unauthorized"}}},
349 {"create": {"status": 200}},
350 {"create": {"status": 409}}
351 ]
352 }"#;
353
354 let outcome = ElasticsearchSink::classify_response(body, 5);
355
356 assert_eq!(
357 outcome.outcomes,
358 vec![
359 DocumentOutcome::Delivered,
360 DocumentOutcome::Retryable,
361 DocumentOutcome::Rejected,
362 DocumentOutcome::Delivered,
363 DocumentOutcome::Delivered,
364 ]
365 );
366 let error = outcome.error.unwrap();
367 assert!(
368 error.contains("403") && error.contains("unauthorized"),
369 "the permanent failure is the actionable one, not the transient 429: {error}"
370 );
371 }
372
373 #[test]
375 fn a_transient_error_is_surfaced_when_it_is_the_only_one() {
376 let body = r#"{"errors":true,"items":[{"create":{"status":429}}]}"#;
377 let error = ElasticsearchSink::classify_response(body, 1).error.unwrap();
378 assert!(error.contains("429"), "{error}");
379 }
380
381 #[test]
383 fn a_conflict_is_not_reported_as_an_error() {
384 let body = r#"{"errors":true,"items":[{"create":{"status":409}}]}"#;
385 let outcome = ElasticsearchSink::classify_response(body, 1);
386
387 assert_eq!(outcome.outcomes, vec![DocumentOutcome::Delivered]);
388 assert!(
389 outcome.error.is_none(),
390 "a deduplicated replay is the mechanism working"
391 );
392 }
393
394 #[test]
395 fn a_short_items_array_leaves_the_tail_unaccounted_for() {
396 let body = r#"{"errors":true,"items":[{"create":{"status":201}}]}"#;
397 let outcome = ElasticsearchSink::classify_response(body, 3);
398
399 assert_eq!(
400 outcome.outcomes.len(),
401 1,
402 "the tail is left for deliver() to retry rather than assumed delivered"
403 );
404 }
405
406 #[test]
407 fn an_item_without_a_status_is_retried_rather_than_assumed_delivered() {
408 let body = r#"{"errors":true,"items":[{"create":{}}]}"#;
409 let outcome = ElasticsearchSink::classify_response(body, 1);
410 assert_eq!(outcome.outcomes, vec![DocumentOutcome::Retryable]);
411 }
412
413 #[test]
414 fn an_unparseable_body_is_retried_not_discarded() {
415 let outcome = ElasticsearchSink::classify_response("<html>gateway error</html>", 2);
416 assert_eq!(outcome.outcomes, vec![DocumentOutcome::Retryable; 2]);
417 assert!(outcome.error.unwrap().contains("parse"));
418 }
419
420 #[test]
421 fn errors_reported_without_items_are_retried() {
422 let outcome = ElasticsearchSink::classify_response(r#"{"errors":true}"#, 2);
423 assert_eq!(outcome.outcomes, vec![DocumentOutcome::Retryable; 2]);
424 }
425
426 #[test]
427 fn the_sink_describes_the_bulk_url_it_targets() {
428 let sink = ElasticsearchSink::new("https://logs.autonomi.com/", "key").unwrap();
429 assert_eq!(sink.describe(), "https://logs.autonomi.com/_bulk");
430 }
431}