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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user