Skip to content

Commit b1baead

Browse files
author
Paulo Remoli
committed
fix unix epoch parsing
1 parent 99db0da commit b1baead

2 files changed

Lines changed: 178 additions & 6 deletions

File tree

src/filters/date_filter.rs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,14 @@ fn normalize_log_timestamp(ts: &str) -> Option<NormalizedTimestamp> {
553553
return normalize_nanos_ts(s);
554554
}
555555

556+
// Unix epoch: 10-digit seconds, 13-digit milliseconds, 16-digit microseconds
557+
// (journalctl JSON __REALTIME_TIMESTAMP), or decimal seconds (journalctl short-unix)
558+
if s.as_bytes().first().map(|b| b.is_ascii_digit()).unwrap_or(false) {
559+
if let Some(n) = normalize_epoch_ts(s) {
560+
return Some(n);
561+
}
562+
}
563+
556564
// Handle bracket-prefixed timestamps
557565
if s.starts_with('[') {
558566
// dmesg: [ seconds.usecs] — digits/spaces/dots only inside brackets
@@ -717,8 +725,7 @@ fn normalize_bsd_ts(s: &str) -> Option<NormalizedTimestamp> {
717725
})
718726
}
719727

720-
fn normalize_nanos_ts(s: &str) -> Option<NormalizedTimestamp> {
721-
let nanos: i128 = s.parse().ok()?;
728+
fn nanos_to_normalized(nanos: i128) -> Option<NormalizedTimestamp> {
722729
let dt = time::OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()?;
723730
let ms = ((nanos.unsigned_abs() % 1_000_000_000) / 1_000_000) as u16;
724731
let year = dt.year() as u32;
@@ -733,6 +740,46 @@ fn normalize_nanos_ts(s: &str) -> Option<NormalizedTimestamp> {
733740
})
734741
}
735742

743+
fn normalize_nanos_ts(s: &str) -> Option<NormalizedTimestamp> {
744+
let nanos: i128 = s.parse().ok()?;
745+
nanos_to_normalized(nanos)
746+
}
747+
748+
/// Handle Unix epoch timestamps in integer and decimal forms:
749+
/// - 10 digits: seconds (e.g. `1436735381`)
750+
/// - 13 digits: milliseconds (e.g. `1436735381000`)
751+
/// - 16 digits: microseconds (e.g. `1699999999000000`, journalctl JSON)
752+
/// - decimal: seconds.fraction (e.g. `1436735381.000000`, journalctl short-unix)
753+
fn normalize_epoch_ts(s: &str) -> Option<NormalizedTimestamp> {
754+
if s.bytes().all(|b| b.is_ascii_digit()) {
755+
let nanos = match s.len() {
756+
10 => s.parse::<i128>().ok()? * 1_000_000_000,
757+
13 => s.parse::<i128>().ok()? * 1_000_000,
758+
16 => s.parse::<i128>().ok()? * 1_000,
759+
_ => return None,
760+
};
761+
return nanos_to_normalized(nanos);
762+
}
763+
// Decimal seconds: integer part must be at least 9 digits (epoch ≥ ~2001)
764+
if let Some(dot) = s.find('.') {
765+
let int_part = &s[..dot];
766+
let frac_part = &s[dot + 1..];
767+
if int_part.len() >= 9
768+
&& int_part.bytes().all(|b| b.is_ascii_digit())
769+
&& !frac_part.is_empty()
770+
&& frac_part.bytes().all(|b| b.is_ascii_digit())
771+
{
772+
let secs: i64 = int_part.parse().ok()?;
773+
let frac_len = frac_part.len().min(9);
774+
let frac_digits: i128 = frac_part[..frac_len].parse().ok()?;
775+
let frac_nanos = frac_digits * 10i128.pow((9 - frac_len) as u32);
776+
let nanos = secs as i128 * 1_000_000_000 + frac_nanos;
777+
return nanos_to_normalized(nanos);
778+
}
779+
}
780+
None
781+
}
782+
736783
fn normalize_apache_error_ts(s: &str) -> Option<NormalizedTimestamp> {
737784
// "[Mon Jan 15 10:30:00.123456 2024]" or "[Fri Dec 31 23:59:59 2024]"
738785
if !s.starts_with('[') || !s.ends_with(']') {
@@ -1471,6 +1518,49 @@ mod tests {
14711518
assert!(normalize_log_timestamp("170004601023400000").is_none());
14721519
}
14731520

1521+
// ── Unix epoch (seconds / milliseconds / microseconds / decimal) ──
1522+
1523+
#[test]
1524+
fn test_normalize_epoch_micros_journalctl_json() {
1525+
// journalctl JSON __REALTIME_TIMESTAMP (16 digits = microseconds)
1526+
let n = normalize_log_timestamp("1700046010234000").unwrap();
1527+
assert_eq!(buf_as_str(&n.canonical), "2023-11-15 11:00:10.234");
1528+
}
1529+
1530+
#[test]
1531+
fn test_normalize_epoch_millis() {
1532+
// 13-digit millisecond epoch (Pino, Bunyan, etc.)
1533+
let n = normalize_log_timestamp("1700046010234").unwrap();
1534+
assert_eq!(buf_as_str(&n.canonical), "2023-11-15 11:00:10.234");
1535+
}
1536+
1537+
#[test]
1538+
fn test_normalize_epoch_secs() {
1539+
// 10-digit second epoch
1540+
let n = normalize_log_timestamp("1700046010").unwrap();
1541+
assert_eq!(buf_as_str(&n.canonical), "2023-11-15 11:00:10.000");
1542+
}
1543+
1544+
#[test]
1545+
fn test_normalize_epoch_decimal_secs() {
1546+
// Decimal seconds (journalctl short-unix, syslog unix)
1547+
let n = normalize_log_timestamp("1700046010.234000").unwrap();
1548+
assert_eq!(buf_as_str(&n.canonical), "2023-11-15 11:00:10.234");
1549+
}
1550+
1551+
#[test]
1552+
fn test_normalize_epoch_decimal_short_frac() {
1553+
// Fewer fractional digits
1554+
let n = normalize_log_timestamp("1700046010.5").unwrap();
1555+
assert_eq!(buf_as_str(&n.canonical), "2023-11-15 11:00:10.500");
1556+
}
1557+
1558+
#[test]
1559+
fn test_normalize_epoch_decimal_rejects_short_integer() {
1560+
// Integer part < 9 digits → not an epoch
1561+
assert!(normalize_log_timestamp("12345678.000000").is_none());
1562+
}
1563+
14741564
#[test]
14751565
fn test_canonical_timestamp_nanos() {
14761566
let result = canonical_timestamp("1700046010234000000", None);

src/parser/syslog.rs

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ enum SyslogFormat {
1212
/// rsyslog RSYSLOG_FileFormat: `ISO-timestamp hostname tag[pid]: message`
1313
/// (ISO 8601 timestamp with no `<PRI>` prefix).
1414
RsyslogIso,
15+
/// rsyslog with Unix epoch timestamp: `1436735381.000000 hostname tag[pid]: message`
16+
/// (no `<PRI>` prefix).
17+
Unix,
1518
}
1619

1720
impl SyslogFormat {
@@ -23,7 +26,8 @@ impl SyslogFormat {
2326
match i {
2427
0 => Self::Rfc3164,
2528
1 => Self::Rfc5424,
26-
_ => Self::RsyslogIso,
29+
2 => Self::RsyslogIso,
30+
_ => Self::Unix,
2731
}
2832
}
2933
}
@@ -33,7 +37,7 @@ const MIN_SAMPLES: u32 = 50;
3337
#[derive(Debug, Default)]
3438
pub struct SyslogParser {
3539
format: OnceLock<SyslogFormat>,
36-
fmt_counts: [AtomicU32; 3],
40+
fmt_counts: [AtomicU32; 4],
3741
fmt_total: AtomicU32,
3842
}
3943

@@ -455,6 +459,8 @@ fn detect_syslog_timestamp<'a>(s: &'a str, line: &'a [u8]) -> Option<(SyslogForm
455459
}
456460
} else if let Some((ts, _)) = super::timestamp::parse_iso_timestamp(body) {
457461
return Some((SyslogFormat::RsyslogIso, ts));
462+
} else if let Some((ts, _)) = super::timestamp::parse_unix_timestamp(body) {
463+
return Some((SyslogFormat::Unix, ts));
458464
}
459465
}
460466
None
@@ -489,12 +495,45 @@ fn extract_syslog_timestamp_rsyslog_iso(s: &str) -> Option<&str> {
489495
super::timestamp::parse_iso_timestamp(s).map(|(ts, _)| ts)
490496
}
491497

498+
fn extract_syslog_timestamp_unix(s: &str) -> Option<&str> {
499+
super::timestamp::parse_unix_timestamp(s).map(|(ts, _)| ts)
500+
}
501+
502+
/// Parse rsyslog with Unix epoch timestamp: `1436735381.000000 hostname tag[pid]: message`.
503+
/// No `<PRI>` prefix.
504+
fn parse_rsyslog_unix_inner<'a>(s: &'a str) -> Option<DisplayParts<'a>> {
505+
let (timestamp, ts_end) = super::timestamp::parse_unix_timestamp(s)?;
506+
let rest = s[ts_end..].strip_prefix(' ')?;
507+
508+
let mut parts = DisplayParts {
509+
timestamp: Some(timestamp),
510+
..Default::default()
511+
};
512+
513+
if rest.is_empty() {
514+
return Some(parts);
515+
}
516+
517+
let (hostname, rest) = next_token(rest)?;
518+
if !is_valid_syslog_hostname(hostname) {
519+
return None;
520+
}
521+
push_field_as(&mut parts.extra_fields, FieldSemantic::Hostname, hostname);
522+
523+
if rest.is_empty() {
524+
return Some(parts);
525+
}
526+
527+
extract_tag_and_message(rest, &mut parts);
528+
Some(parts)
529+
}
530+
492531
impl SyslogParser {
493532
fn record_format(&self, fmt: SyslogFormat) {
494533
self.fmt_counts[fmt.index()].fetch_add(1, Ordering::Relaxed);
495534
let total = self.fmt_total.fetch_add(1, Ordering::Relaxed) + 1;
496535
if total >= MIN_SAMPLES && self.format.get().is_none() {
497-
let winner = (0..3)
536+
let winner = (0..4)
498537
.max_by_key(|&i| self.fmt_counts[i].load(Ordering::Relaxed))
499538
.unwrap_or(0);
500539
let _ = self.format.set(SyslogFormat::from_index(winner));
@@ -517,6 +556,7 @@ impl LogFormatParser for SyslogParser {
517556
SyslogFormat::Rfc3164 => extract_syslog_timestamp_rfc3164(s, line),
518557
SyslogFormat::Rfc5424 => extract_syslog_timestamp_rfc5424(s, line),
519558
SyslogFormat::RsyslogIso => extract_syslog_timestamp_rsyslog_iso(s),
559+
SyslogFormat::Unix => extract_syslog_timestamp_unix(s),
520560
};
521561
}
522562
let (fmt, ts) = detect_syslog_timestamp(s, line)?;
@@ -544,6 +584,7 @@ impl LogFormatParser for SyslogParser {
544584
}
545585
}
546586
SyslogFormat::RsyslogIso => parse_rsyslog_iso_inner(s),
587+
SyslogFormat::Unix => parse_rsyslog_unix_inner(s),
547588
};
548589
if result.is_some() {
549590
return result;
@@ -572,6 +613,11 @@ impl LogFormatParser for SyslogParser {
572613
return Some(parts);
573614
}
574615

616+
if let Some(parts) = parse_rsyslog_unix_inner(s) {
617+
self.record_format(SyslogFormat::Unix);
618+
return Some(parts);
619+
}
620+
575621
None
576622
}
577623

@@ -608,7 +654,7 @@ impl LogFormatParser for SyslogParser {
608654
/// • ISO timestamp (`YYYY-MM-DDTHH:MM:SS…`) — rsyslog RSYSLOG_FileFormat
609655
///
610656
/// Plain BSD lines without a priority prefix (`Oct 11 22:14:15 host tag: msg`)
611-
/// are shared with journalctl `--output short` and are intentionally **not**
657+
/// and Unix epoch lines are shared with journalctl and are intentionally **not**
612658
/// claimed here so that piped `journalctl` output is still detected as
613659
/// journalctl. Those lines can still be *parsed* by `parse_line` once the
614660
/// format is locked by other lines in the sample.
@@ -1029,4 +1075,40 @@ mod tests {
10291075
assert_eq!(before.level, after.level);
10301076
assert_eq!(before.target, after.target);
10311077
}
1078+
1079+
// ── Unix epoch (rsyslog custom template) ─────────────────────────
1080+
1081+
#[test]
1082+
fn test_unix_epoch_basic() {
1083+
let line = b"1436735381.000000 myhost sshd[1234]: Connection closed";
1084+
let parser = SyslogParser::default();
1085+
let parts = parser.parse_line(line).unwrap();
1086+
assert_eq!(parts.timestamp, Some("1436735381.000000"));
1087+
assert_eq!(parts.target, Some("sshd"));
1088+
assert_eq!(parts.message, Some("Connection closed"));
1089+
assert!(
1090+
parts
1091+
.extra_fields
1092+
.iter()
1093+
.any(|(_, k, v)| *k == "hostname" && *v == "myhost")
1094+
);
1095+
}
1096+
1097+
#[test]
1098+
fn test_unix_epoch_parse_timestamp() {
1099+
let line = b"1700000000.123456 myhost systemd[1]: Started service";
1100+
let parser = SyslogParser::default();
1101+
let ts = parser.parse_timestamp(line).unwrap();
1102+
assert_eq!(ts, "1700000000.123456");
1103+
}
1104+
1105+
#[test]
1106+
fn test_unix_epoch_timestamp_has_year() {
1107+
let parser = SyslogParser::default();
1108+
let line = b"1436735381.000000 myhost sshd[1234]: Connection closed";
1109+
for _ in 0..MIN_SAMPLES {
1110+
parser.parse_line(line).unwrap();
1111+
}
1112+
assert!(parser.timestamp_has_year());
1113+
}
10321114
}

0 commit comments

Comments
 (0)