///|
/// A timestamp represented as a UTC day and time, with the original input kept.
pub(all) struct NormalizedTimestamp {
epoch_day : Int
second_of_day : Int
nanosecond : Int
raw : String
} derive(Debug, Eq, ToJson)
///|
/// A canonical severity label used by normalized events.
pub(all) enum Severity {
Trace
Debug
Info
Warning
Error
Fatal
Unknown(String)
} derive(Debug, Eq, ToJson)
///|
/// A normalized event that retains its source line exactly as supplied.
pub(all) struct NormalizedEvent {
timestamp : NormalizedTimestamp?
severity : Severity?
source_id : String
raw_content : String
} derive(Debug, Eq, ToJson)
///|
/// A normalization failure with the original timestamp that could not be read.
pub(all) suberror NormalizationError {
InvalidTimestamp(String)
} derive(Debug, Eq)
///|
fn digit_at(value : String, index : Int) -> Int? {
match value[index] {
'0' => Some(0)
'1' => Some(1)
'2' => Some(2)
'3' => Some(3)
'4' => Some(4)
'5' => Some(5)
'6' => Some(6)
'7' => Some(7)
'8' => Some(8)
'9' => Some(9)
_ => None
}
}
///|
fn number_at(
value : String,
start : Int,
length : Int,
) -> Int raise NormalizationError {
if start < 0 || start + length > value.length() {
raise NormalizationError::InvalidTimestamp(value)
}
let mut result = 0
for index in start..<(start + length) {
match digit_at(value, index) {
Some(digit) => result = result * 10 + digit
None => raise NormalizationError::InvalidTimestamp(value)
}
}
result
}
///|
fn is_leap_year(year : Int) -> Bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
///|
fn days_in_month(year : Int, month : Int) -> Int {
match month {
2 => if is_leap_year(year) { 29 } else { 28 }
4 | 6 | 9 | 11 => 30
_ => 31
}
}
///|
fn epoch_day(year : Int, month : Int, day : Int) -> Int {
let adjusted_year = if month <= 2 { year - 1 } else { year }
let era = adjusted_year / 400
let year_of_era = adjusted_year - era * 400
let adjusted_month = if month > 2 { month - 3 } else { month + 9 }
let day_of_year = (153 * adjusted_month + 2) / 5 + day - 1
let day_of_era = year_of_era * 365 +
year_of_era / 4 -
year_of_era / 100 +
day_of_year
era * 146097 + day_of_era - 719468
}
///|
/// Parses an RFC 3339 timestamp and converts it to a UTC comparison key.
pub fn normalize_timestamp(
value : String,
) -> NormalizedTimestamp raise NormalizationError {
let length = value.length()
guard length >= 20 &&
value[4] == '-' &&
value[7] == '-' &&
(value[10] == 'T' || value[10] == 't') &&
value[13] == ':' &&
value[16] == ':' else {
raise NormalizationError::InvalidTimestamp(value)
}
let year = number_at(value, 0, 4)
let month = number_at(value, 5, 2)
let day = number_at(value, 8, 2)
let hour = number_at(value, 11, 2)
let minute = number_at(value, 14, 2)
let second = number_at(value, 17, 2)
let zone_start = if value[length - 1] == 'Z' || value[length - 1] == 'z' {
length - 1
} else {
length - 6
}
guard zone_start >= 19 else {
raise NormalizationError::InvalidTimestamp(value)
}
let mut nanosecond = 0
if zone_start > 19 {
guard value[19] == '.' && zone_start > 20 else {
raise NormalizationError::InvalidTimestamp(value)
}
let mut fraction_digits = 0
for index in 20.. {
if fraction_digits < 9 {
nanosecond = nanosecond * 10 + digit
}
fraction_digits += 1
}
None => raise NormalizationError::InvalidTimestamp(value)
}
}
for _ in fraction_digits..<9 {
nanosecond *= 10
}
}
let mut offset_seconds = 0
if zone_start == length - 6 {
guard (value[zone_start] == '+' || value[zone_start] == '-') &&
value[zone_start + 3] == ':' else {
raise NormalizationError::InvalidTimestamp(value)
}
let offset_hour = number_at(value, zone_start + 1, 2)
let offset_minute = number_at(value, zone_start + 4, 2)
guard offset_hour <= 23 && offset_minute <= 59 else {
raise NormalizationError::InvalidTimestamp(value)
}
let sign = if value[zone_start] == '+' { 1 } else { -1 }
offset_seconds = sign * (offset_hour * 3600 + offset_minute * 60)
} else {
guard zone_start == length - 1 else {
raise NormalizationError::InvalidTimestamp(value)
}
}
guard year >= 1 &&
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= days_in_month(year, month) &&
hour <= 23 &&
minute <= 59 &&
second <= 59 else {
raise NormalizationError::InvalidTimestamp(value)
}
let local_seconds = hour * 3600 + minute * 60 + second
let utc_seconds = local_seconds - offset_seconds
let utc_day = epoch_day(year, month, day) + utc_seconds / 86400
let second_of_day = (utc_seconds % 86400 + 86400) % 86400
let adjusted_day = if utc_seconds < 0 && utc_seconds % 86400 != 0 {
utc_day - 1
} else {
utc_day
}
{ epoch_day: adjusted_day, second_of_day, nanosecond, raw: value, }
}
///|
/// Compares timestamps chronologically, returning a negative, zero, or positive value.
pub fn compare_timestamps(
left : NormalizedTimestamp,
right : NormalizedTimestamp,
) -> Int {
if left.epoch_day != right.epoch_day {
left.epoch_day - right.epoch_day
} else if left.second_of_day != right.second_of_day {
left.second_of_day - right.second_of_day
} else {
left.nanosecond - right.nanosecond
}
}
///|
/// Maps common severity spellings to a stable enum while retaining unknown labels.
pub fn normalize_severity(value : String) -> Severity {
match value.trim() {
"TRACE" | "Trace" | "trace" => Severity::Trace
"DEBUG" | "Debug" | "debug" => Severity::Debug
"INFO" | "Info" | "info" => Severity::Info
"WARN" | "Warn" | "warn" | "WARNING" | "Warning" | "warning" =>
Severity::Warning
"ERROR" | "Error" | "error" => Severity::Error
"FATAL" | "Fatal" | "fatal" | "CRITICAL" | "Critical" | "critical" =>
Severity::Fatal
other => Severity::Unknown(other.to_owned())
}
}
///|
/// Creates a stable slash-delimited source identifier from trimmed components.
pub fn normalize_source_id(
system : String,
component : String,
host : String?,
) -> String {
let system = system.trim().to_owned()
let component = component.trim().to_owned()
let parts : Array[String] = []
if system != "" {
parts.push(system)
}
if component != "" {
parts.push(component)
}
match host {
Some(host) => {
let host = host.trim()
if host != "" {
parts.push(host.to_owned())
}
}
None => ()
}
parts.join("/")
}
///|
/// Normalizes event fields and keeps the original log content unchanged.
pub fn normalize_event(
timestamp : String?,
severity : String?,
system : String,
component : String,
host : String?,
raw_content : String,
) -> NormalizedEvent raise NormalizationError {
let timestamp = match timestamp {
Some(value) => Some(normalize_timestamp(value))
None => None
}
let severity = match severity {
Some(value) => Some(normalize_severity(value))
None => None
}
{
timestamp,
severity,
source_id: normalize_source_id(system, component, host),
raw_content,
}
}