1use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19use tokio::io::{AsyncReadExt, AsyncSeekExt};
20
21use super::config::LogLevel;
22use super::offsets::OffsetStore;
23use super::parse::{parse_line, LogEvent};
24use crate::error::Result;
25
26const LOG_FILENAME_PREFIX: &str = "ant-node.";
28const LOG_FILENAME_SUFFIX: &str = ".log";
30
31const MAX_CHUNK_BYTES: usize = 1024 * 1024;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TailedEvent {
38 pub node_id: u32,
39 pub file_name: String,
41 pub byte_offset: u64,
43 pub event: LogEvent,
44}
45
46impl TailedEvent {
47 #[must_use]
59 pub fn document_id(&self, installation_id: &str) -> String {
60 format!(
61 "{}-{}-{}-{}",
62 installation_id, self.node_id, self.file_name, self.byte_offset
63 )
64 }
65}
66
67pub struct LogTailer {
69 node_id: u32,
70 log_dir: PathBuf,
71 primed: bool,
75 last_seen_len: HashMap<String, u64>,
78}
79
80impl LogTailer {
81 #[must_use]
82 pub fn new(node_id: u32, log_dir: PathBuf) -> Self {
83 Self {
84 node_id,
85 log_dir,
86 primed: false,
87 last_seen_len: HashMap::new(),
88 }
89 }
90
91 #[must_use]
92 pub fn node_id(&self) -> u32 {
93 self.node_id
94 }
95
96 #[must_use]
97 pub fn log_dir(&self) -> &Path {
98 &self.log_dir
99 }
100
101 pub fn mark_primed(&mut self) {
106 self.primed = true;
107 }
108
109 pub async fn poll(
115 &mut self,
116 offsets: &mut OffsetStore,
117 min_level: LogLevel,
118 ) -> Result<PollOutcome> {
119 let files = self.discover_files().await?;
120 let mut outcome = PollOutcome::default();
121
122 for path in &files {
123 match self.poll_file(path, offsets, min_level).await {
124 Ok(mut file_outcome) => {
125 outcome.events.append(&mut file_outcome.events);
126 outcome.dropped_by_level += file_outcome.dropped_by_level;
127 }
128 Err(error) => {
131 tracing::debug!(
132 "log forwarding: skipping {} this poll: {error}",
133 path.display()
134 );
135 }
136 }
137 }
138
139 let live: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
140 offsets.prune(&live);
141 self.last_seen_len.retain(|key, _| live.contains(key));
142 self.primed = true;
143
144 Ok(outcome)
145 }
146
147 async fn discover_files(&self) -> Result<Vec<PathBuf>> {
152 let mut entries = match tokio::fs::read_dir(&self.log_dir).await {
153 Ok(entries) => entries,
154 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
156 Err(error) => return Err(error.into()),
157 };
158
159 let mut files = Vec::new();
160 while let Some(entry) = entries.next_entry().await? {
161 let name = entry.file_name().to_string_lossy().to_string();
162 if name.starts_with(LOG_FILENAME_PREFIX) && name.ends_with(LOG_FILENAME_SUFFIX) {
163 files.push(entry.path());
164 }
165 }
166 files.sort();
167 Ok(files)
168 }
169
170 async fn poll_file(
171 &mut self,
172 path: &Path,
173 offsets: &mut OffsetStore,
174 min_level: LogLevel,
175 ) -> Result<PollOutcome> {
176 let key = path.display().to_string();
177 let file_name = path
178 .file_name()
179 .map(|n| n.to_string_lossy().to_string())
180 .unwrap_or_else(|| key.clone());
181
182 let mut file = tokio::fs::File::open(path).await?;
183 let len = file.metadata().await?.len();
184
185 let mut start = match offsets.get(&key) {
186 Some(offset) => offset,
187 None if !self.primed => len,
190 None => 0,
191 };
192
193 if len < start {
196 tracing::debug!("log forwarding: {key} shrank; restarting from the beginning");
197 start = 0;
198 }
199
200 let was_growing = self.last_seen_len.insert(key.clone(), len) != Some(len);
201
202 if len == start {
203 offsets.set(&key, start);
204 return Ok(PollOutcome::default());
205 }
206
207 let to_read = usize::try_from(len - start)
208 .unwrap_or(MAX_CHUNK_BYTES)
209 .min(MAX_CHUNK_BYTES);
210 file.seek(std::io::SeekFrom::Start(start)).await?;
211 let mut buffer = vec![0u8; to_read];
212 let read = file.read_exact(&mut buffer).await.map(|_| to_read)?;
213 buffer.truncate(read);
214
215 let reached_eof = start + read as u64 >= len;
216
217 let usable = match buffer.iter().rposition(|b| *b == b'\n') {
221 Some(index) => index + 1,
222 None if read == MAX_CHUNK_BYTES => read,
223 None => {
224 offsets.set(&key, start);
225 return Ok(PollOutcome::default());
226 }
227 };
228
229 let text = String::from_utf8_lossy(&buffer[..usable]).to_string();
230 let outcome = self.collect_events(
231 &text,
232 start,
233 &file_name,
234 min_level,
235 reached_eof && !was_growing,
238 );
239
240 offsets.set(&key, outcome.next_offset);
241 Ok(outcome.into_poll_outcome())
242 }
243
244 fn collect_events(
246 &self,
247 text: &str,
248 chunk_start: u64,
249 file_name: &str,
250 min_level: LogLevel,
251 release_final_event: bool,
252 ) -> CollectOutcome {
253 let mut outcome = CollectOutcome {
254 next_offset: chunk_start,
255 ..CollectOutcome::default()
256 };
257 let mut pending: Option<TailedEvent> = None;
258 let mut cursor = chunk_start;
259
260 for line in text.split_inclusive('\n') {
261 let line_start = cursor;
262 cursor += line.len() as u64;
263 let content = line.trim_end_matches(['\n', '\r']);
264
265 match parse_line(content) {
266 Some(event) => {
267 if let Some(previous) = pending.take() {
268 outcome.push(previous, min_level);
269 }
270 outcome.next_offset = line_start;
273 pending = Some(TailedEvent {
274 node_id: self.node_id,
275 file_name: file_name.to_string(),
276 byte_offset: line_start,
277 event,
278 });
279 }
280 None => match pending.as_mut() {
281 Some(held) => held.event.push_continuation(content),
282 None => outcome.next_offset = cursor,
285 },
286 }
287 }
288
289 match pending {
290 Some(event) if release_final_event => {
291 outcome.push(event, min_level);
292 outcome.next_offset = cursor;
293 }
294 Some(_) => {}
297 None => outcome.next_offset = cursor,
298 }
299
300 outcome
301 }
302}
303
304#[derive(Debug, Default)]
306pub struct PollOutcome {
307 pub events: Vec<TailedEvent>,
308 pub dropped_by_level: u64,
310}
311
312#[derive(Debug, Default)]
313struct CollectOutcome {
314 events: Vec<TailedEvent>,
315 dropped_by_level: u64,
316 next_offset: u64,
317}
318
319impl CollectOutcome {
320 fn push(&mut self, event: TailedEvent, min_level: LogLevel) {
321 if event.event.level < min_level {
322 self.dropped_by_level += 1;
323 } else {
324 self.events.push(event);
325 }
326 }
327
328 fn into_poll_outcome(self) -> PollOutcome {
329 PollOutcome {
330 events: self.events,
331 dropped_by_level: self.dropped_by_level,
332 }
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use std::io::Write;
340
341 fn line(level: &str, message: &str) -> String {
342 format!("2026-08-19T20:50:00.123456Z {level} ant_node::node: {message}\n")
343 }
344
345 struct Fixture {
346 _dir: tempfile::TempDir,
347 log_dir: PathBuf,
348 offsets_path: PathBuf,
349 }
350
351 impl Fixture {
352 fn new() -> Self {
353 let dir = tempfile::tempdir().unwrap();
354 let log_dir = dir.path().join("logs");
355 std::fs::create_dir_all(&log_dir).unwrap();
356 let offsets_path = dir.path().join("offsets.json");
357 Self {
358 _dir: dir,
359 log_dir,
360 offsets_path,
361 }
362 }
363
364 fn append(&self, file_name: &str, contents: &str) {
365 let mut file = std::fs::OpenOptions::new()
366 .create(true)
367 .append(true)
368 .open(self.log_dir.join(file_name))
369 .unwrap();
370 file.write_all(contents.as_bytes()).unwrap();
371 }
372
373 fn offsets(&self) -> OffsetStore {
374 OffsetStore::load(&self.offsets_path)
375 }
376
377 fn tailer(&self) -> LogTailer {
378 LogTailer::new(7, self.log_dir.clone())
379 }
380 }
381
382 async fn drain(
389 tailer: &mut LogTailer,
390 offsets: &mut OffsetStore,
391 min_level: LogLevel,
392 ) -> PollOutcome {
393 let mut merged = tailer.poll(offsets, min_level).await.unwrap();
394 let mut second = tailer.poll(offsets, min_level).await.unwrap();
395 merged.events.append(&mut second.events);
396 merged.dropped_by_level += second.dropped_by_level;
397 merged
398 }
399
400 #[tokio::test]
402 async fn the_first_poll_joins_existing_files_at_their_end() {
403 let fixture = Fixture::new();
404 fixture.append("ant-node.2026-08-19.log", &line("INFO", "historic"));
405
406 let mut tailer = fixture.tailer();
407 let mut offsets = fixture.offsets();
408 let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
409
410 assert!(outcome.events.is_empty(), "history must not be shipped");
411
412 fixture.append("ant-node.2026-08-19.log", &line("INFO", "fresh"));
413 let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
414 assert_eq!(outcome.events.len(), 1);
415 assert_eq!(outcome.events[0].event.message, "fresh");
416 }
417
418 #[tokio::test]
419 async fn a_restart_resumes_from_the_persisted_offset_without_duplicating() {
420 let fixture = Fixture::new();
421 fixture.append("ant-node.2026-08-19.log", &line("INFO", "first"));
422
423 let mut tailer = fixture.tailer();
424 let mut offsets = fixture.offsets();
425 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
426 fixture.append("ant-node.2026-08-19.log", &line("INFO", "second"));
427 let before = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
428 assert_eq!(before.events.len(), 1);
429 offsets.save().unwrap();
430
431 let mut restarted = fixture.tailer();
433 restarted.mark_primed();
434 let mut reloaded = fixture.offsets();
435
436 let outcome = drain(&mut restarted, &mut reloaded, LogLevel::Info).await;
437 assert!(outcome.events.is_empty(), "nothing new, nothing re-sent");
438
439 fixture.append("ant-node.2026-08-19.log", &line("INFO", "third"));
440 let outcome = drain(&mut restarted, &mut reloaded, LogLevel::Info).await;
441 assert_eq!(outcome.events.len(), 1);
442 assert_eq!(outcome.events[0].event.message, "third");
443 }
444
445 #[tokio::test]
448 async fn lines_written_while_the_daemon_was_down_are_not_lost() {
449 let fixture = Fixture::new();
450 fixture.append("ant-node.2026-08-19.log", &line("INFO", "before"));
451
452 let mut tailer = fixture.tailer();
453 let mut offsets = fixture.offsets();
454 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
455 offsets.save().unwrap();
456
457 fixture.append("ant-node.2026-08-19.log", &line("INFO", "during downtime"));
458
459 let mut restarted = fixture.tailer();
460 restarted.mark_primed();
461 let mut reloaded = fixture.offsets();
462 let outcome = drain(&mut restarted, &mut reloaded, LogLevel::Info).await;
463
464 assert_eq!(outcome.events.len(), 1);
465 assert_eq!(outcome.events[0].event.message, "during downtime");
466 }
467
468 #[tokio::test]
475 async fn a_node_started_after_enabling_is_captured_from_its_first_line() {
476 let fixture = Fixture::new();
477
478 let mut tailer = fixture.tailer();
480 let mut offsets = fixture.offsets();
481 let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
482 assert!(outcome.events.is_empty());
483
484 fixture.append(
486 "ant-node.2026-08-19.log",
487 &format!(
488 "{}{}",
489 line("INFO", "starting version=0.17.2 commit=abc1234"),
490 line("INFO", "listening for connections"),
491 ),
492 );
493 let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
494
495 let messages: Vec<&str> = outcome
496 .events
497 .iter()
498 .map(|e| e.event.message.as_str())
499 .collect();
500 assert_eq!(
501 messages,
502 vec![
503 "starting version=0.17.2 commit=abc1234",
504 "listening for connections"
505 ],
506 "the node's first line must not be skipped"
507 );
508 assert_eq!(outcome.events[0].byte_offset, 0);
509 assert_eq!(outcome.events[0].event.version.as_deref(), Some("0.17.2"));
510 assert_eq!(outcome.events[0].event.commit.as_deref(), Some("abc1234"));
511 }
512
513 #[tokio::test]
516 async fn enabling_after_the_node_started_skips_its_startup_line() {
517 let fixture = Fixture::new();
518 fixture.append(
519 "ant-node.2026-08-19.log",
520 &line("INFO", "starting version=0.17.2 commit=abc1234"),
521 );
522
523 let mut tailer = fixture.tailer();
524 let mut offsets = fixture.offsets();
525 let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
526 assert!(
527 outcome.events.is_empty(),
528 "pre-consent lines are not uploaded"
529 );
530
531 fixture.append("ant-node.2026-08-19.log", &line("INFO", "later activity"));
532 let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
533
534 let messages: Vec<&str> = outcome
535 .events
536 .iter()
537 .map(|e| e.event.message.as_str())
538 .collect();
539 assert_eq!(messages, vec!["later activity"]);
540 }
541
542 #[tokio::test]
543 async fn a_new_days_file_is_read_from_the_beginning() {
544 let fixture = Fixture::new();
545 fixture.append("ant-node.2026-08-19.log", &line("INFO", "yesterday"));
546
547 let mut tailer = fixture.tailer();
548 let mut offsets = fixture.offsets();
549 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
550
551 fixture.append("ant-node.2026-08-20.log", &line("INFO", "today"));
552 let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
553
554 assert_eq!(outcome.events.len(), 1);
555 assert_eq!(outcome.events[0].event.message, "today");
556 assert_eq!(outcome.events[0].file_name, "ant-node.2026-08-20.log");
557 }
558
559 #[tokio::test]
560 async fn a_truncated_file_restarts_from_the_beginning() {
561 let fixture = Fixture::new();
562 fixture.append("ant-node.2026-08-19.log", &line("INFO", "original content"));
563
564 let mut tailer = fixture.tailer();
565 let mut offsets = fixture.offsets();
566 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
567
568 std::fs::write(
569 fixture.log_dir.join("ant-node.2026-08-19.log"),
570 line("INFO", "new"),
571 )
572 .unwrap();
573 let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
574
575 assert_eq!(outcome.events.len(), 1);
576 assert_eq!(outcome.events[0].event.message, "new");
577 }
578
579 #[tokio::test]
580 async fn a_partially_written_line_is_held_until_it_is_complete() {
581 let fixture = Fixture::new();
582 fixture.append("ant-node.2026-08-19.log", &line("INFO", "complete"));
583
584 let mut tailer = fixture.tailer();
585 let mut offsets = fixture.offsets();
586 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
587
588 fixture.append(
589 "ant-node.2026-08-19.log",
590 "2026-08-19T20:50:01.000000Z INFO ant_node::node: half a li",
591 );
592 let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
593 assert!(outcome.events.is_empty(), "a partial line is not an event");
594
595 fixture.append("ant-node.2026-08-19.log", "ne here\n");
596 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
598 let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
599
600 assert_eq!(outcome.events.len(), 1);
601 assert_eq!(outcome.events[0].event.message, "half a line here");
602 }
603
604 #[tokio::test]
605 async fn a_panic_and_its_backtrace_stay_one_event() {
606 let fixture = Fixture::new();
607 fixture.append("ant-node.2026-08-19.log", &line("INFO", "before the panic"));
608
609 let mut tailer = fixture.tailer();
610 let mut offsets = fixture.offsets();
611 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
612
613 fixture.append(
614 "ant-node.2026-08-19.log",
615 &format!(
616 "{}thread 'main' panicked\n at src/node.rs:42\n",
617 line("ERROR", "it broke")
618 ),
619 );
620 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
621 let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
622
623 assert_eq!(outcome.events.len(), 1);
624 assert_eq!(
625 outcome.events[0].event.message,
626 "it broke\nthread 'main' panicked\n at src/node.rs:42"
627 );
628 }
629
630 #[tokio::test]
631 async fn events_below_the_minimum_level_are_dropped_and_counted() {
632 let fixture = Fixture::new();
633 fixture.append("ant-node.2026-08-19.log", &line("INFO", "seed"));
634
635 let mut tailer = fixture.tailer();
636 let mut offsets = fixture.offsets();
637 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
638
639 fixture.append(
640 "ant-node.2026-08-19.log",
641 &format!(
642 "{}{}{}",
643 line("DEBUG", "chatter"),
644 line("TRACE", "more chatter"),
645 line("WARN", "worth keeping")
646 ),
647 );
648 let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
649
650 let messages: Vec<&str> = outcome
651 .events
652 .iter()
653 .map(|e| e.event.message.as_str())
654 .collect();
655 assert_eq!(messages, vec!["worth keeping"]);
656 assert_eq!(outcome.dropped_by_level, 2);
657 }
658
659 #[tokio::test]
660 async fn a_missing_log_directory_is_not_an_error() {
661 let dir = tempfile::tempdir().unwrap();
662 let mut tailer = LogTailer::new(1, dir.path().join("never-created"));
663 let mut offsets = OffsetStore::load(&dir.path().join("offsets.json"));
664
665 let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
666 assert!(outcome.events.is_empty());
667 }
668
669 #[tokio::test]
670 async fn unrelated_files_in_the_log_directory_are_ignored() {
671 let fixture = Fixture::new();
672 fixture.append("ant-node.2026-08-19.log", &line("INFO", "seed"));
673 fixture.append("notes.txt", "not a log file\n");
674 fixture.append("ant-node.2026-08-19.log.gz", "compressed\n");
675
676 let mut tailer = fixture.tailer();
677 let mut offsets = fixture.offsets();
678 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
679
680 assert_eq!(offsets.len(), 1, "only the rolling log file is tracked");
681 }
682
683 #[tokio::test]
684 async fn offsets_for_retention_deleted_files_are_pruned() {
685 let fixture = Fixture::new();
686 fixture.append("ant-node.2026-08-18.log", &line("INFO", "old"));
687 fixture.append("ant-node.2026-08-19.log", &line("INFO", "current"));
688
689 let mut tailer = fixture.tailer();
690 let mut offsets = fixture.offsets();
691 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
692 assert_eq!(offsets.len(), 2);
693
694 std::fs::remove_file(fixture.log_dir.join("ant-node.2026-08-18.log")).unwrap();
695 tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
696
697 assert_eq!(offsets.len(), 1);
698 }
699
700 const INSTALL_A: &str = "0123456789abcdef";
701 const INSTALL_B: &str = "fedcba9876543210";
702
703 #[test]
704 fn the_document_id_is_stable_and_position_derived() {
705 let event = TailedEvent {
706 node_id: 7,
707 file_name: "ant-node.2026-08-19.log".to_string(),
708 byte_offset: 104_857,
709 event: parse_line(&line("INFO", "hello")).unwrap(),
710 };
711
712 assert_eq!(
713 event.document_id(INSTALL_A),
714 "0123456789abcdef-7-ant-node.2026-08-19.log-104857"
715 );
716 assert_eq!(
717 event.document_id(INSTALL_A),
718 event.clone().document_id(INSTALL_A)
719 );
720 assert!(
721 event.document_id(INSTALL_A).len() <= 512,
722 "Elasticsearch caps _id at 512 bytes"
723 );
724 }
725
726 #[test]
732 fn identical_positions_on_two_installations_do_not_collide() {
733 let same_event = || TailedEvent {
734 node_id: 1,
735 file_name: "ant-node.2026-08-19.log".to_string(),
736 byte_offset: 0,
737 event: parse_line(&line("INFO", "starting version=0.17.2")).unwrap(),
738 };
739
740 assert_ne!(
741 same_event().document_id(INSTALL_A),
742 same_event().document_id(INSTALL_B),
743 "the same local position on two machines must not share a document id"
744 );
745 }
746
747 #[test]
748 fn document_ids_differ_across_nodes_files_and_positions() {
749 let base = TailedEvent {
750 node_id: 7,
751 file_name: "ant-node.2026-08-19.log".to_string(),
752 byte_offset: 100,
753 event: parse_line(&line("INFO", "hello")).unwrap(),
754 };
755 let other_node = TailedEvent {
756 node_id: 8,
757 ..base.clone()
758 };
759 let other_file = TailedEvent {
760 file_name: "ant-node.2026-08-20.log".to_string(),
761 ..base.clone()
762 };
763 let other_offset = TailedEvent {
764 byte_offset: 200,
765 ..base.clone()
766 };
767
768 let ids = [
769 base.document_id(INSTALL_A),
770 other_node.document_id(INSTALL_A),
771 other_file.document_id(INSTALL_A),
772 other_offset.document_id(INSTALL_A),
773 ];
774 let unique: std::collections::HashSet<&String> = ids.iter().collect();
775 assert_eq!(unique.len(), 4);
776 }
777}