Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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 <name>` 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

```
Expand Down Expand Up @@ -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
```
Expand Down
1 change: 1 addition & 0 deletions crates/window/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ license.workspace = true

[dependencies]
chrono.workspace = true
serde_json = "1"
tstamp.workspace = true

[[bin]]
Expand Down
23 changes: 23 additions & 0 deletions crates/window/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateTime<FixedOffset>> {
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`
Expand Down Expand Up @@ -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].
Expand Down
31 changes: 24 additions & 7 deletions crates/window/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <RFC3339>] [--to <RFC3339>]
ts-window [--from <RFC3339>] [--to <RFC3339>] [--field <name>]

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 <name> treats each line as a JSON object (JSONL) and reads the timestamp
from its top-level string field <name> 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<DateTime<FixedOffset>>,
to: Option<DateTime<FixedOffset>>,
field: Option<String>,
}

fn parse_args() -> Result<Args, String> {
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() {
Expand All @@ -48,13 +54,20 @@ fn parse_args() -> Result<Args, String> {
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 }),
}
}

Expand Down Expand Up @@ -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.
Expand Down
Loading