From 8e9600dda2472edf5e3b95cb594c3b8840078d27 Mon Sep 17 00:00:00 2001 From: dailz Date: Wed, 10 Jun 2026 10:47:04 +0800 Subject: [PATCH] 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 --- crates/core/src/parser/json.rs | 142 ++++++++++++++++++++++----------- 1 file changed, 94 insertions(+), 48 deletions(-) diff --git a/crates/core/src/parser/json.rs b/crates/core/src/parser/json.rs index 863fd33..5f974d1 100644 --- a/crates/core/src/parser/json.rs +++ b/crates/core/src/parser/json.rs @@ -66,6 +66,37 @@ pub fn detect_json_log(line: &str) -> bool { matches!(serde_json::from_str::(line), Ok(Value::Object(_))) } +// ─── take_string_field 辅助函数 ────────────────────────────────────────────── +// 从 HashMap 中安全提取字符串字段。 +// +// 遍历候选键名列表,找到第一个值为字符串类型的字段,移除并返回其值。 +// 如果值不是字符串(如数字、布尔值等),保留该字段在 HashMap 中,继续尝试下一个候选键。 +// 这样可以避免非字符串类型的字段被静默丢弃(数据丢失 bug)。 +// +// 参数: +// - fields: &mut HashMap — JSON 字段的 HashMap(可变引用)。 +// - keys: &[&str] — 候选键名列表(按优先级排列)。 +// 返回: Option — 找到字符串值返回 Some(String),否则返回 None。 +fn take_string_field(fields: &mut HashMap, keys: &[&str]) -> Option { + 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 { 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 枚举。 - // parse:: 中的 :: 是泛型参数(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::().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()); + } }