From e373c8430f7cdf7b0d4c9c0309eb7768d5b9de41 Mon Sep 17 00:00:00 2001 From: honbin Date: Mon, 19 Oct 2026 19:01:10 +0900 Subject: [PATCH] Add JSON field support to ts-window --- Cargo.lock | 61 +++++++++++++++++++++++++++++++++++++++ README.md | 9 ++++-- crates/window/Cargo.toml | 1 + crates/window/src/lib.rs | 23 +++++++++++++++ crates/window/src/main.rs | 31 +++++++++++++++----- 5 files changed, 116 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3274fdf..7ff52a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,12 @@ dependencies = [ name = "integration-tests" version = "0.1.0" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "js-sys" version = "0.3.106" @@ -166,6 +172,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "num-traits" version = "0.2.19" @@ -211,6 +223,48 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "series" version = "0.1.0" @@ -332,6 +386,7 @@ name = "window" version = "0.1.0" dependencies = [ "chrono", + "serde_json", "tstamp", ] @@ -401,3 +456,9 @@ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/README.md b/README.md index 6631909..176c677 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ span 4s (first→last) ``` # narrow a raw log to the incident window, then look at the shape cat alb.jsonl \ - | ts-window --from 2026-09-03T10:20:00+09:00 --to 2026-09-03T10:30:00+09:00 \ + | ts-window --field time --from 2026-09-03T10:20:00+09:00 --to 2026-09-03T10:30:00+09:00 \ | jq -r 'select(.elb_status_code == 502) | .time' \ | ts-burst ``` @@ -148,6 +148,11 @@ prints the original line unchanged when it falls in `[--from, --to]` (inclusive; at least one bound required). Comparison is on the instant, so the bounds and the lines may use any offset. A `kept N / total` summary goes to stderr. +For JSONL, `--field ` reads the timestamp from that top-level string field +instead of the leading token, so whole records pass through for `jq` downstream. +Lines that aren't JSON objects, lack the field, or contain a non-string or +invalid RFC3339 value count as unparseable. + ## ts-window-audit ``` @@ -191,7 +196,7 @@ signals: ``` # response-time distribution over a time window cat alb.jsonl \ - | ts-window --from 2026-09-03T10:20:00+09:00 --to 2026-09-03T10:30:00+09:00 \ + | ts-window --field time --from 2026-09-03T10:20:00+09:00 --to 2026-09-03T10:30:00+09:00 \ | jq -r .target_processing_time \ | ts-dist ``` diff --git a/crates/window/Cargo.toml b/crates/window/Cargo.toml index 6134d96..882369a 100644 --- a/crates/window/Cargo.toml +++ b/crates/window/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] chrono.workspace = true +serde_json = "1" tstamp.workspace = true [[bin]] diff --git a/crates/window/src/lib.rs b/crates/window/src/lib.rs index d643da5..423c737 100644 --- a/crates/window/src/lib.rs +++ b/crates/window/src/lib.rs @@ -5,6 +5,15 @@ use chrono::{DateTime, FixedOffset}; +/// Read an RFC3339 timestamp from a top-level string field of a JSON object line. +/// +/// Lives here rather than in `tstamp`: only `window` needs the whole record to +/// survive, so the other tools keep taking `jq -r .field` output. +pub fn parse_field(line: &str, field: &str) -> Option> { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + DateTime::parse_from_rfc3339(value.get(field)?.as_str()?).ok() +} + /// Inclusive range test. A `None` bound is unbounded on that side. /// /// Comparison is on the instant (`DateTime` orders by UTC instant), so `from` @@ -76,6 +85,20 @@ mod tests { assert!(!in_range(ts("2026-09-03T11:00:00+09:00"), None, Some(to))); } + #[test] + fn parse_field_reads_named_top_level_string() { + let line = r#"{"time":"2026-09-03T10:21:31+09:00","elb_status_code":502}"#; + assert_eq!( + parse_field(line, "time"), + Some(ts("2026-09-03T10:21:31+09:00")) + ); + assert!(parse_field(line, "timestamp").is_none()); // missing + assert!(parse_field(line, "elb_status_code").is_none()); // not a string + assert!(parse_field(r#"{"req":{"time":"2026-09-03T10:21:31Z"}}"#, "time").is_none()); + assert!(parse_field("2026-09-03T10:21:31+09:00 plain", "time").is_none()); + assert!(parse_field("", "time").is_none()); + } + #[test] fn compares_across_offsets() { // 10:25+09:00 == 01:25Z, which is inside [10:20+09:00, 10:30+09:00]. diff --git a/crates/window/src/main.rs b/crates/window/src/main.rs index afad267..f5e7c7a 100644 --- a/crates/window/src/main.rs +++ b/crates/window/src/main.rs @@ -4,7 +4,7 @@ //! within [--from, --to] (inclusive), prints the *original line unchanged*. So //! it composes in front of other tools: //! -//! cat alb.jsonl | ts-window --from … --to … | jq -r .time | ts-burst +//! cat alb.jsonl | ts-window --field time --from … --to … | jq -r .time | ts-burst //! //! Comparison is on the instant, so --from/--to may use any offset and lines may //! mix offsets — window never reformats timestamps, it only decides keep/drop. @@ -14,29 +14,35 @@ use std::process::ExitCode; use chrono::{DateTime, FixedOffset}; use tstamp::parse_line; -use window::in_range; +use window::{in_range, parse_field}; const HELP: &str = "\ ts-window — pass through stdin lines whose RFC3339 timestamp is in [from, to] usage: - ts-window [--from ] [--to ] + ts-window [--from ] [--to ] [--field ] At least one bound is required; bounds are inclusive. The timestamp is read from each line (leading token, surrounding quotes stripped) and the original line is printed unchanged. --from/--to may use any UTC offset. -example: - ts-window --from 2026-09-03T10:20:00+09:00 --to 2026-09-03T10:30:00+09:00"; +--field treats each line as a JSON object (JSONL) and reads the timestamp +from its top-level string field instead of the leading token. + +examples: + ts-window --from 2026-09-03T10:20:00+09:00 --to 2026-09-03T10:30:00+09:00 + cat alb.jsonl | ts-window --field time --from 2026-09-03T10:20:00+09:00"; struct Args { from: Option>, to: Option>, + field: Option, } fn parse_args() -> Result { let mut from = None; let mut to = None; + let mut field = None; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { @@ -48,13 +54,20 @@ fn parse_args() -> Result { let v = args.next().ok_or("--to requires an RFC3339 value")?; to = Some(DateTime::parse_from_rfc3339(&v).map_err(|e| format!("--to: {e}"))?); } + "--field" => { + let name = args.next().ok_or("--field requires a field name")?; + if name.is_empty() { + return Err("--field requires a non-empty field name".into()); + } + field = Some(name); + } other => return Err(format!("unknown argument: {other}\n\n{HELP}")), } } match (from, to) { (None, None) => Err(format!("need at least one of --from / --to\n\n{HELP}")), (Some(f), Some(t)) if f > t => Err(format!("--from ({f}) is after --to ({t})")), - _ => Ok(Args { from, to }), + _ => Ok(Args { from, to, field }), } } @@ -85,7 +98,11 @@ fn main() -> ExitCode { } }; total += 1; - match parse_line(&line) { + let ts = match &args.field { + Some(field) => parse_field(&line, field), + None => parse_line(&line), + }; + match ts { Some(ts) if in_range(ts, args.from, args.to) => { if let Err(e) = writeln!(out, "{line}") { // Downstream closed the pipe (e.g. `| head`): stop quietly.