From 4cb4dd92e3ddf2c284ea5c9f0d67f6c02b0e5757 Mon Sep 17 00:00:00 2001 From: Chmouel Boudjnah Date: Thu, 13 Feb 2025 13:17:20 +0100 Subject: [PATCH] feat: add timezone support for timestamp conversion Added support for specifying a timezone using the `--timezone` flag or the `SNAZY_TIMEZONE` environment variable. This allows timestamps to be displayed in the specified timezone instead of the server's default timezone (usually UTC). Updated the `Config` struct and related functions to handle timezone conversion. Added tests to verify the correct parsing and conversion of timestamps with timezones. Fixes #276 Signed-off-by: Chmouel Boudjnah --- Cargo.lock | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 9 +++++ src/cli.rs | 5 +++ src/config.rs | 2 ++ src/parse.rs | 18 ++++++---- src/utils.rs | 49 ++++++++++++++++++++------- tests/tests.rs | 8 +++++ 8 files changed, 164 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index edee384..5fca62b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,27 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "chrono-tz" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6ac4f2c0bf0f44e9161aec9675e1050aa4a530663c4a9e37e108fa948bca9f" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" +dependencies = [ + "parse-zoneinfo", + "phf_codegen", +] + [[package]] name = "clap" version = "4.5.29" @@ -354,6 +375,53 @@ version = "1.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "proc-macro2" version = "1.0.93" @@ -372,6 +440,21 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "regex" version = "1.11.1" @@ -464,11 +547,18 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "snazy" version = "0.54.0" dependencies = [ "chrono", + "chrono-tz", "clap", "clap_complete", "color-print", diff --git a/Cargo.toml b/Cargo.toml index 766ee36..dcea778 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ tempfile = "3.16.0" clap_complete = "4.5.44" color-print = "0.3.7" is-terminal = "0.4.15" +chrono-tz = "0.10.1" diff --git a/README.md b/README.md index f7c4cc6..7685084 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,15 @@ are supported for now (ie: no rgb or fixed): [`strftime`](https://man7.org/linux/man-pages/man3/strftime.3.html) format strings. +- You can specify a timezone with the `--timezone` flag (or the environment variable + `SNAZY_TIMEZONE`). By default, the timestamps are displayed in the server's timezone + (usually UTC). The timezone should be specified in the IANA timezone database format + (e.g., "America/New_York", "Europe/Paris", "Asia/Tokyo"). + + ```shell + kubectl logs deployment/controller | snazy --timezone America/New_York + ``` + - If you want to skip showing some lines you can specify the flag `-S/--skip-line-regexp`. When it matches the word or regexp in this value it will simply skipping printing the line. You can have multiple flags diff --git a/src/cli.rs b/src/cli.rs index 293a004..5c79edd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -71,6 +71,10 @@ struct Args { /// A timeformat as documented by the strftime(3) manpage. pub time_format: String, + #[arg(long, env = "SNAZY_TIMEZONE")] + /// Convert timestamps to specified timezone (e.g. `Europe/Paris`, `America/New_York`) + pub timezone: Option, + #[arg( long, verbatim_doc_comment, @@ -248,6 +252,7 @@ pub fn build_cli_config() -> Config { kail_prefix_format: args.kail_prefix_format, kail_no_prefix: args.kail_no_prefix, time_format: args.time_format, + timezone: args.timezone, skip_line_regexp: args.skip_line_regexp, filter_levels: args.filter_levels, action_command: args.action_command, diff --git a/src/config.rs b/src/config.rs index f46bd3e..f91602c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -45,6 +45,7 @@ pub struct Config { pub regexp_colours: HashMap, pub skip_line_regexp: Vec, pub time_format: String, + pub timezone: Option, } impl Default for Config { @@ -54,6 +55,7 @@ impl Default for Config { kail_no_prefix: false, kail_prefix_format: String::from("{namespace}/{pod}[{container}]"), time_format: String::from("%H:%M:%S"), + timezone: None, filter_levels: >::new(), regexp_colours: HashMap::new(), json_keys: HashMap::new(), diff --git a/src/parse.rs b/src/parse.rs index f538db6..2c9a621 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -45,6 +45,7 @@ pub struct Info { pub fn extract_info(rawline: &str, config: &Config) -> HashMap { let time_format = config.time_format.as_str(); + let timezone = config.timezone.as_deref(); let mut msg = HashMap::new(); let mut kail_msg_prefix = String::new(); let mut line = rawline.to_string(); @@ -65,7 +66,11 @@ pub fn extract_info(rawline: &str, config: &Config) -> HashMap { // parse timestamp to a unix timestamp msg.insert( "ts".to_string(), - crate::utils::convert_str_to_ts(p.timestamp.as_str(), config.time_format.as_str()), + crate::utils::convert_str_to_ts( + p.timestamp.as_str(), + config.time_format.as_str(), + config.timezone.as_deref(), + ), ); let mut others = String::new(); if p.other.contains_key("provider") { @@ -84,7 +89,7 @@ pub fn extract_info(rawline: &str, config: &Config) -> HashMap { if let Some(ts) = p.other.get("ts") { msg.insert( String::from("ts"), - crate::utils::convert_ts_float_or_str(ts, time_format), + crate::utils::convert_ts_float_or_str(ts, time_format, timezone), ); }; } @@ -123,18 +128,19 @@ fn custom_json_match( if let Ok(p) = serde_json::from_str::(line) { for (key, value) in &config.json_keys { if p.pointer(value).is_some() { - // if value equal ts or timestamp or date then parse as timestamp if key == "ts" || key == "timestamp" || key == "date" { - // make a serde json Value let v = p.pointer(value).unwrap(); - let ts = crate::utils::convert_ts_float_or_str(v, time_format); + let ts = crate::utils::convert_ts_float_or_str( + v, + time_format, + config.timezone.as_deref(), + ); dico.insert(key.to_string(), ts); } else { let mut v = p.pointer(value).unwrap().to_string(); if v.contains('"') { v = v.replace('"', ""); } - dico.insert(key.to_string(), v); } } diff --git a/src/utils.rs b/src/utils.rs index 0ab0e84..1d98e51 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,5 @@ -use chrono::{DateTime, NaiveDateTime}; +use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; +use chrono_tz::Tz; use serde_json::Value; use yansi::Paint; @@ -35,24 +36,35 @@ pub fn convert_pac_provider_to_fa_icon(provider: &str) -> &str { } } -pub fn convert_str_to_ts(s: &str, time_format: &str) -> String { - // try to convert s to a nativdatetime if fail then return just the string +pub fn convert_str_to_ts(s: &str, time_format: &str, timezone: Option<&str>) -> String { if let Ok(ts) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S.%fZ") { - return ts.format(time_format).to_string(); + let utc_dt = Utc.from_utc_datetime(&ts); + if let Some(tz) = timezone { + if let Ok(tz) = tz.parse::() { + return utc_dt.with_timezone(&tz).format(time_format).to_string(); + } + } + return utc_dt.format(time_format).to_string(); } - s.to_string() } -fn convert_unix_ts(value: i64, time_format: &str) -> String { - let ts = DateTime::from_timestamp(value, 0).unwrap(); - ts.format(time_format).to_string() +fn convert_unix_ts(value: i64, time_format: &str, timezone: Option<&str>) -> String { + if let Some(ts) = DateTime::from_timestamp(value, 0) { + if let Some(tz) = timezone { + if let Ok(tz) = tz.parse::() { + return ts.with_timezone(&tz).format(time_format).to_string(); + } + } + return ts.format(time_format).to_string(); + } + value.to_string() } -pub fn convert_ts_float_or_str(value: &Value, time_format: &str) -> String { +pub fn convert_ts_float_or_str(value: &Value, time_format: &str, timezone: Option<&str>) -> String { match value { - Value::String(s) => convert_str_to_ts(s.as_str(), time_format), - Value::Number(n) => convert_unix_ts(n.as_f64().unwrap() as i64, time_format), + Value::String(s) => convert_str_to_ts(s.as_str(), time_format, timezone), + Value::Number(n) => convert_unix_ts(n.as_f64().unwrap() as i64, time_format, timezone), _ => String::new(), } } @@ -88,9 +100,22 @@ mod tests { assert_eq!( convert_ts_float_or_str( &Value::String("2020-01-01T00:00:00.000Z".to_string()), - "%Y-%m-%d %H:%M:%S" + "%Y-%m-%d %H:%M:%S", + None ), "2020-01-01 00:00:00" ); } + + #[test] + fn test_convert_ts_float_or_str_with_timezone() { + assert_eq!( + convert_ts_float_or_str( + &Value::String("2020-01-01T00:00:00.000Z".to_string()), + "%Y-%m-%d %H:%M:%S", + Some("Europe/Paris") + ), + "2020-01-01 01:00:00" // Paris is UTC+1 + ); + } } diff --git a/tests/tests.rs b/tests/tests.rs index 8ca7456..64ed0f8 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -185,6 +185,14 @@ snazytest!( false ); +snazytest!( + timezone_parsing, + ["--timezone", "America/New_York"], + r#"{"level":"info","ts":1739447782.2690723,"msg":"timezone test"}"#, + "INFO 06:56:22 timezone test\n", + false +); + #[test] #[should_panic] fn all_json_keys_need_tobe_specified() {