fix(parser): preserve non-string timestamp/level fields instead of silently dropping them (closes #19)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-06-10 10:47:04 +08:00
parent 2cebbd94c4
commit 8e9600dda2

View File

@@ -66,6 +66,37 @@ pub fn detect_json_log(line: &str) -> bool {
matches!(serde_json::from_str::<Value>(line), Ok(Value::Object(_)))
}
// ─── take_string_field 辅助函数 ──────────────────────────────────────────────
// 从 HashMap 中安全提取字符串字段。
//
// 遍历候选键名列表,找到第一个值为字符串类型的字段,移除并返回其值。
// 如果值不是字符串(如数字、布尔值等),保留该字段在 HashMap 中,继续尝试下一个候选键。
// 这样可以避免非字符串类型的字段被静默丢弃(数据丢失 bug
//
// 参数:
// - fields: &mut HashMap<String, Value> — JSON 字段的 HashMap可变引用
// - keys: &[&str] — 候选键名列表(按优先级排列)。
// 返回: Option<String> — 找到字符串值返回 Some(String),否则返回 None。
fn take_string_field(fields: &mut HashMap<String, Value>, keys: &[&str]) -> Option<String> {
for key in keys {
// 先检查值是否为字符串类型peek不移除
// is_some_and(Value::is_string) 等价于:
// match fields.get(*key) {
// Some(Value::String(_)) => true,
// _ => false,
// }
if fields.get(*key).is_some_and(Value::is_string) {
// 确认是字符串后才移除,取出 owned String无需 clone
// unreachable! 在这里永远不会触发,因为我们刚刚确认了值的类型。
let Some(Value::String(value)) = fields.remove(*key) else {
unreachable!("value was checked as string");
};
return Some(value);
}
}
None
}
// ─── parse_line 函数 ──────────────────────────────────────────────────────
// 将一行 JSON 文本解析为 LogEntry日志条目结构体。
//
@@ -104,57 +135,13 @@ pub fn parse_line(line: &str) -> Option<LogEntry> {
let raw_line = line.to_string();
// ─── 提取时间戳字段 ──────────────────────────────────────────────────
// 这段代码尝试从 JSON 中提取时间戳,逻辑如下:
//
// 1. ["timestamp", "time", "ts", "@timestamp"] — 候选键名数组。
// 不同日志系统使用不同的时间戳字段名,这里列出常见的几种。
//
// 2. .iter() — 创建数组的迭代器,可以逐个遍历元素。
//
// 3. .find_map(|key| fields.remove(*key)) — 对每个候选键名:
// - fields.remove(*key): 尝试从 HashMap 中移除该键并返回对应的值。
// 如果键不存在remove 返回 None。
// *key 是解引用deref将 &str引用转换为 str因为 remove 接受 &str 类型。
// - find_map: 遍历所有候选键,返回第一个 Some(值) 的结果。
// 即找到第一个存在的键就停止。
//
// 4. .and_then(|v| v.as_str().map(String::from)) — 如果找到了时间戳值:
// - v.as_str(): 尝试将 serde_json::Value 转换为 &str字符串切片
// 如果 Value 不是字符串类型(比如是数字),返回 None。
// - .map(String::from): 如果是字符串,将其转换为 String拥有所有权的字符串
// - and_then: 类似于 map但用于"扁平化"嵌套的 Option。
// 如果 as_str() 返回 None整个链返回 None。
let timestamp = ["timestamp", "time", "ts", "@timestamp"]
.iter()
.find_map(|key| fields.remove(*key))
.and_then(|v| v.as_str().map(String::from));
// 使用 take_string_field 辅助函数安全提取字符串类型的时间戳。
// 只有值为字符串时才会从 fields 中移除;非字符串值(如数字时间戳)保留在 fields 中。
let timestamp = take_string_field(&mut fields, &["timestamp", "time", "ts", "@timestamp"]);
// ─── 提取日志级别字段 ──────────────────────────────────────────────
// 与时间戳提取类似,但多了一步:将字符串解析为 LogLevel 枚举。
let level = ["level", "lvl", "severity"]
.iter()
.find_map(|key| fields.remove(*key))
.and_then(|v| v.as_str().map(String::from))
// .map(|s| s.parse::<LogLevel>(...)) — 尝试将字符串解析为 LogLevel 枚举。
// parse::<LogLevel> 中的 ::<LogLevel> 是泛型参数turbofish 语法),
// 指定我们要将字符串解析为 LogLevel 类型。
//
// .unwrap_or_else(|e| match e {}) — 错误处理:
// - 如果解析成功,直接返回 LogLevel 值。
// - 如果解析失败(字符串不匹配任何已知的日志级别),执行闭包。
// - |e| match e {}: 这个闭包接收解析错误 e用 match e {} 进行"穷尽匹配"。
// 由于 LogLevel 的 parse 错误类型是一个空枚举(没有任何变体),
// match e {} 意味着"这个分支永远不会执行"unreachable
// 但实际上,如果 parse 失败unwrap_or_else 不会执行这个闭包——
// 等等,这里有个细微之处:
// unwrap_or_else 只在 Err 时执行闭包,但 match e {} 对空枚举是合法的
// (因为空枚举没有任何可能的值,所以 match 是穷尽的)。
// 不过这里的实际效果是:如果 parse 失败,整个 .map() 返回 None
// (因为 unwrap_or_else 返回的类型是 LogLevel而空 match 不会有返回值)。
//
// 实际上更准确的解释parse() 的错误类型是 Infallible不可失败的
// 即解析总是成功。所以 unwrap_or_else 永远不会被执行。
// 但即使如此unwrap_or_else 的闭包也需要类型正确match e {} 满足这一点。
let level = take_string_field(&mut fields, &["level", "lvl", "severity"])
.map(|s| s.parse::<LogLevel>().unwrap_or_else(|e| match e {}));
// ─── 构建 LogEntry 并返回 ──────────────────────────────────────────
@@ -357,4 +344,63 @@ mod tests {
assert_eq!(entry.level, Some(LogLevel::Info), "failed for key: {key}");
}
}
#[test]
// 测试:数字类型的 level 值不应被提取,应保留在 fields 中。
fn test_numeric_level_preserved_in_fields() {
let line = r#"{"level":30,"message":"hello"}"#;
let entry = parse_line(line).unwrap();
// level 不是字符串,应返回 None。
assert!(entry.level.is_none());
// 数字 level 应保留在 fields 中,不被静默丢弃。
assert_eq!(entry.fields.get("level"), Some(&Value::Number(30.into())));
// message 仍正常存在。
assert_eq!(
entry.fields.get("message"),
Some(&Value::String("hello".to_string()))
);
}
#[test]
// 测试:数字类型的 timestamp 值不应被提取,应保留在 fields 中。
fn test_numeric_timestamp_preserved_in_fields() {
let line = r#"{"timestamp":1718000000,"message":"hello"}"#;
let entry = parse_line(line).unwrap();
// timestamp 不是字符串,应返回 None。
assert!(entry.timestamp.is_none());
// 数字 timestamp 应保留在 fields 中。
assert_eq!(
entry.fields.get("timestamp"),
Some(&Value::Number(1718000000.into()))
);
}
#[test]
// 测试:当第一个候选键是数字时,应回退到下一个字符串类型的候选键。
fn test_fallback_to_string_key() {
let line = r#"{"level":30,"lvl":"INFO","message":"hello"}"#;
let entry = parse_line(line).unwrap();
// "level" 是数字,应跳过;"lvl" 是字符串,应成功提取。
assert_eq!(entry.level, Some(LogLevel::Info));
// 数字 "level" 保留在 fields 中。
assert_eq!(entry.fields.get("level"), Some(&Value::Number(30.into())));
// "lvl" 已被成功提取并从 fields 中移除。
assert!(entry.fields.get("lvl").is_none());
}
#[test]
// 测试timestamp 的 fallback 行为 — 数字 timestamp 被保留,字符串 time 被提取。
fn test_timestamp_fallback_preserves_numeric() {
let line = r#"{"timestamp":1718000000,"time":"2024-01-01T00:00:00Z"}"#;
let entry = parse_line(line).unwrap();
// "timestamp" 是数字,跳过;"time" 是字符串,成功提取。
assert_eq!(entry.timestamp, Some("2024-01-01T00:00:00Z".to_string()));
// 数字 "timestamp" 保留在 fields 中。
assert_eq!(
entry.fields.get("timestamp"),
Some(&Value::Number(1718000000.into()))
);
// "time" 已被提取并从 fields 中移除。
assert!(entry.fields.get("time").is_none());
}
}