From 15db8326d0192d50fb4ab33bc85430a36edd01a3 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Thu, 25 Sep 2025 06:39:09 +0700 Subject: [PATCH] Add richer metadata field types and transports support --- CHANGELOG.md | 10 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 11 +- README.template.md | 7 +- src/app_error.rs | 1 + src/app_error/metadata.rs | 200 +++++++++++++++++++++++++++++++++-- src/convert/tonic.rs | 9 +- src/response/problem_json.rs | 120 +++++++++++++++++++-- 9 files changed, 337 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39651ac..36fd3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.21.2] - 2025-10-10 + +### Added +- Expanded `Metadata` field coverage with float, duration, IP address and optional JSON values, complete with typed builders, doctests + and unit tests covering the new cases. + +### Changed +- Enriched RFC7807 and gRPC adapters to propagate the new metadata types, hashing/masking them consistently across redaction policies. +- Documented the broader telemetry surface in the README so adopters discover the additional structured field builders. + ## [0.21.1] - 2025-10-09 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 8e6eb42..a609bf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.21.1" +version = "0.21.2" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 27f3748..00e286b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.21.1" +version = "0.21.2" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 17c08b6..7d0ca5e 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ of redaction and metadata. - **Native derives.** `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, `#[masterror(...)]` and `#[provide]` wire custom types into `AppError` while forwarding sources, backtraces, telemetry providers and redaction policy. -- **Typed telemetry.** `Metadata` stores structured key/value context with - per-field redaction controls and builders in `field::*`, so logs stay - structured without manual `String` maps. +- **Typed telemetry.** `Metadata` stores structured key/value context (strings, + integers, floats, durations, IP addresses and optional JSON) with per-field + redaction controls and builders in `field::*`, so logs stay structured without + manual `String` maps. - **Transport adapters.** Optional features expose Actix/Axum responders, `tonic::Status` conversions, WASM/browser logging and OpenAPI schema generation without contaminating the lean default build. @@ -73,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.21.1", default-features = false } +masterror = { version = "0.21.2", default-features = false } # or with features: -# masterror = { version = "0.21.1", features = [ +# masterror = { version = "0.21.2", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", diff --git a/README.template.md b/README.template.md index 74dc2d7..8d5482d 100644 --- a/README.template.md +++ b/README.template.md @@ -29,9 +29,10 @@ of redaction and metadata. - **Native derives.** `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, `#[masterror(...)]` and `#[provide]` wire custom types into `AppError` while forwarding sources, backtraces, telemetry providers and redaction policy. -- **Typed telemetry.** `Metadata` stores structured key/value context with - per-field redaction controls and builders in `field::*`, so logs stay - structured without manual `String` maps. +- **Typed telemetry.** `Metadata` stores structured key/value context (strings, + integers, floats, durations, IP addresses and optional JSON) with per-field + redaction controls and builders in `field::*`, so logs stay structured without + manual `String` maps. - **Transport adapters.** Optional features expose Actix/Axum responders, `tonic::Status` conversions, WASM/browser logging and OpenAPI schema generation without contaminating the lean default build. diff --git a/src/app_error.rs b/src/app_error.rs index d952cc0..e5a47db 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -72,6 +72,7 @@ pub use core::{AppError, AppResult, Error, MessageEditPolicy}; pub(crate) use core::{reset_backtrace_preference, set_backtrace_preference_override}; pub use context::Context; +pub(crate) use metadata::duration_to_string; pub use metadata::{Field, FieldRedaction, FieldValue, Metadata, field}; #[cfg(test)] diff --git a/src/app_error/metadata.rs b/src/app_error/metadata.rs index 0aa8267..7735ee1 100644 --- a/src/app_error/metadata.rs +++ b/src/app_error/metadata.rs @@ -1,7 +1,9 @@ use std::{ borrow::Cow, collections::BTreeMap, - fmt::{Display, Formatter, Result as FmtResult} + fmt::{Display, Formatter, Result as FmtResult, Write}, + net::IpAddr, + time::Duration }; /// Redaction policy associated with a metadata [`Field`]. @@ -18,6 +20,8 @@ pub enum FieldRedaction { Last4 } +#[cfg(feature = "serde_json")] +use serde_json::Value as JsonValue; use uuid::Uuid; /// Value stored inside [`Metadata`]. @@ -34,10 +38,19 @@ pub enum FieldValue { I64(i64), /// Unsigned 64-bit integer. U64(u64), + /// Floating-point value. + F64(f64), /// Boolean flag. Bool(bool), /// UUID represented with the canonical binary type. - Uuid(Uuid) + Uuid(Uuid), + /// Elapsed duration captured with nanosecond precision. + Duration(Duration), + /// IP address (v4 or v6). + Ip(IpAddr), + /// Structured JSON payload (requires the `serde_json` feature). + #[cfg(feature = "serde_json")] + Json(JsonValue) } impl Display for FieldValue { @@ -46,12 +59,82 @@ impl Display for FieldValue { Self::Str(value) => Display::fmt(value, f), Self::I64(value) => Display::fmt(value, f), Self::U64(value) => Display::fmt(value, f), + Self::F64(value) => Display::fmt(value, f), Self::Bool(value) => Display::fmt(value, f), - Self::Uuid(value) => Display::fmt(value, f) + Self::Uuid(value) => Display::fmt(value, f), + Self::Duration(value) => format_duration(*value, f), + Self::Ip(value) => Display::fmt(value, f), + #[cfg(feature = "serde_json")] + Self::Json(value) => Display::fmt(value, f) } } } +#[derive(Clone, Copy)] +struct TrimmedFraction { + value: u32, + width: u8 +} + +fn duration_parts(duration: Duration) -> (u64, Option) { + let secs = duration.as_secs(); + let nanos = duration.subsec_nanos(); + if nanos == 0 { + return (secs, None); + } + + let mut fraction = nanos; + let mut width = 9u8; + loop { + let divided = fraction / 10; + if divided * 10 != fraction { + break; + } + fraction = divided; + width -= 1; + } + + ( + secs, + Some(TrimmedFraction { + value: fraction, + width + }) + ) +} + +fn format_duration(duration: Duration, f: &mut Formatter<'_>) -> FmtResult { + let (secs, fraction) = duration_parts(duration); + if let Some(fraction) = fraction { + write!( + f, + "{}.{:0width$}s", + secs, + fraction.value, + width = fraction.width as usize + ) + } else { + write!(f, "{}s", secs) + } +} + +pub(crate) fn duration_to_string(duration: Duration) -> String { + let (secs, fraction) = duration_parts(duration); + let mut output = String::new(); + if let Some(fraction) = fraction { + let _ = write!( + &mut output, + "{}.{:0width$}s", + secs, + fraction.value, + width = fraction.width as usize + ); + } else { + let _ = write!(&mut output, "{}s", secs); + } + output +} + /// Single metadata field – name plus value. #[derive(Clone, Debug, PartialEq)] pub struct Field { @@ -288,8 +371,10 @@ impl IntoIterator for Metadata { /// Factories for [`Field`] values. pub mod field { - use std::borrow::Cow; + use std::{borrow::Cow, net::IpAddr, time::Duration}; + #[cfg(feature = "serde_json")] + use serde_json::Value as JsonValue; use uuid::Uuid; use super::{Field, FieldValue}; @@ -312,6 +397,19 @@ pub mod field { Field::new(name, FieldValue::U64(value)) } + /// Build an `f64` metadata field. + /// + /// ``` + /// use masterror::{field, FieldValue}; + /// + /// let (_, value, _) = field::f64("ratio", 0.5).into_parts(); + /// assert!(matches!(value, FieldValue::F64(ratio) if ratio.to_bits() == 0.5f64.to_bits())); + /// ``` + #[must_use] + pub fn f64(name: &'static str, value: f64) -> Field { + Field::new(name, FieldValue::F64(value)) + } + /// Build a boolean metadata field. #[must_use] pub fn bool(name: &'static str, value: bool) -> Field { @@ -323,15 +421,66 @@ pub mod field { pub fn uuid(name: &'static str, value: Uuid) -> Field { Field::new(name, FieldValue::Uuid(value)) } + + /// Build a duration metadata field. + /// + /// ``` + /// use std::time::Duration; + /// use masterror::{field, FieldValue}; + /// + /// let (_, value, _) = field::duration("elapsed", Duration::from_millis(1500)).into_parts(); + /// assert!(matches!(value, FieldValue::Duration(duration) if duration == Duration::from_millis(1500))); + /// ``` + #[must_use] + pub fn duration(name: &'static str, value: Duration) -> Field { + Field::new(name, FieldValue::Duration(value)) + } + + /// Build an IP address metadata field. + /// + /// ``` + /// use std::net::{IpAddr, Ipv4Addr}; + /// use masterror::{field, FieldValue}; + /// + /// let (_, value, _) = field::ip("peer", IpAddr::from(Ipv4Addr::LOCALHOST)).into_parts(); + /// assert!(matches!(value, FieldValue::Ip(addr) if addr.is_ipv4())); + /// ``` + #[must_use] + pub fn ip(name: &'static str, value: IpAddr) -> Field { + Field::new(name, FieldValue::Ip(value)) + } + + /// Build a JSON metadata field (requires the `serde_json` feature). + /// + /// ``` + /// # #[cfg(feature = "serde_json")] + /// # { + /// use masterror::{field, FieldValue}; + /// + /// let (_, value, _) = field::json("payload", serde_json::json!({"ok": true})).into_parts(); + /// assert!(matches!(value, FieldValue::Json(payload) if payload["ok"].as_bool() == Some(true))); + /// # } + /// ``` + #[cfg(feature = "serde_json")] + #[must_use] + pub fn json(name: &'static str, value: JsonValue) -> Field { + Field::new(name, FieldValue::Json(value)) + } } #[cfg(test)] mod tests { - use std::borrow::Cow; - + use std::{ + borrow::Cow, + net::{IpAddr, Ipv4Addr}, + time::Duration + }; + + #[cfg(feature = "serde_json")] + use serde_json::json; use uuid::Uuid; - use super::{FieldRedaction, FieldValue, Metadata, field}; + use super::{FieldRedaction, FieldValue, Metadata, duration_to_string, field}; #[test] fn metadata_roundtrip() { @@ -358,6 +507,37 @@ mod tests { assert_eq!(collected[1].0, "trace_id"); } + #[test] + fn metadata_supports_extended_field_types() { + let meta = Metadata::from_fields([ + field::f64("ratio", 0.25), + field::duration("elapsed", Duration::from_millis(1500)), + field::ip("peer", IpAddr::from(Ipv4Addr::new(192, 168, 0, 1))) + ]); + + assert!(meta.get("ratio").is_some_and( + |value| matches!(value, FieldValue::F64(ratio) if ratio.to_bits() == 0.25f64.to_bits()) + )); + assert_eq!( + meta.get("elapsed"), + Some(&FieldValue::Duration(Duration::from_millis(1500))) + ); + assert_eq!( + meta.get("peer"), + Some(&FieldValue::Ip(IpAddr::from(Ipv4Addr::new(192, 168, 0, 1)))) + ); + } + + #[cfg(feature = "serde_json")] + #[test] + fn metadata_supports_json_fields() { + let meta = Metadata::from_fields([field::json("payload", json!({ "status": "ok" }))]); + assert!(meta.get("payload").is_some_and(|value| matches!( + value, + FieldValue::Json(payload) if payload["status"] == "ok" + ))); + } + #[test] fn inserting_field_replaces_previous_value() { let mut meta = Metadata::from_fields([field::i64("count", 1)]); @@ -389,4 +569,10 @@ mod tests { assert_eq!(owned_value, field.value().clone()); assert_eq!(redaction, field.redaction()); } + + #[test] + fn duration_to_string_trims_trailing_zeroes() { + let text = duration_to_string(Duration::from_micros(1500)); + assert_eq!(text, "0.0015s"); + } } diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 648074c..f0058db 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -30,7 +30,7 @@ use tonic::{ use crate::CODE_MAPPINGS; use crate::{ AppErrorKind, Error, FieldRedaction, FieldValue, MessageEditPolicy, Metadata, RetryAdvice, - mapping_for_code + app_error::duration_to_string, mapping_for_code }; /// Error alias retained for backwards compatibility with 0.20 conversions. @@ -142,8 +142,13 @@ fn metadata_value_to_ascii(value: &FieldValue) -> Option> { } FieldValue::I64(value) => Some(Cow::Owned(value.to_string())), FieldValue::U64(value) => Some(Cow::Owned(value.to_string())), + FieldValue::F64(value) => Some(Cow::Owned(value.to_string())), FieldValue::Bool(value) => Some(Cow::Borrowed(if *value { "true" } else { "false" })), - FieldValue::Uuid(value) => Some(Cow::Owned(value.to_string())) + FieldValue::Uuid(value) => Some(Cow::Owned(value.to_string())), + FieldValue::Duration(value) => Some(Cow::Owned(duration_to_string(*value))), + FieldValue::Ip(value) => Some(Cow::Owned(value.to_string())), + #[cfg(feature = "serde_json")] + FieldValue::Json(_) => None } } diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index 388da07..cb4fef0 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -1,12 +1,15 @@ -use std::{borrow::Cow, collections::BTreeMap, fmt::Write}; +use std::{borrow::Cow, collections::BTreeMap, fmt::Write, net::IpAddr}; use http::StatusCode; use serde::Serialize; +#[cfg(feature = "serde_json")] +use serde_json::Value as JsonValue; use sha2::{Digest, Sha256}; use super::core::ErrorResponse; use crate::{ - AppCode, AppError, AppErrorKind, FieldRedaction, FieldValue, MessageEditPolicy, Metadata + AppCode, AppError, AppErrorKind, FieldRedaction, FieldValue, MessageEditPolicy, Metadata, + app_error::duration_to_string }; /// Canonical mapping for a public [`AppCode`]. @@ -313,8 +316,22 @@ pub enum ProblemMetadataValue { I64(i64), /// Unsigned 64-bit integer. U64(u64), + /// Floating-point number. + F64(f64), /// Boolean flag serialized as `true`/`false`. - Bool(bool) + Bool(bool), + /// Duration represented as seconds plus nanoseconds remainder. + Duration { + /// Whole seconds component of the duration. + secs: u64, + /// Additional nanoseconds (always less than one second). + nanos: u32 + }, + /// IP address (v4 or v6). + Ip(IpAddr), + /// Structured JSON payload (requires the `serde_json` feature). + #[cfg(feature = "serde_json")] + Json(JsonValue) } impl From for ProblemMetadataValue { @@ -323,8 +340,16 @@ impl From for ProblemMetadataValue { FieldValue::Str(value) => Self::String(value), FieldValue::I64(value) => Self::I64(value), FieldValue::U64(value) => Self::U64(value), + FieldValue::F64(value) => Self::F64(value), FieldValue::Bool(value) => Self::Bool(value), - FieldValue::Uuid(value) => Self::String(Cow::Owned(value.to_string())) + FieldValue::Uuid(value) => Self::String(Cow::Owned(value.to_string())), + FieldValue::Duration(value) => Self::Duration { + secs: value.as_secs(), + nanos: value.subsec_nanos() + }, + FieldValue::Ip(value) => Self::Ip(value), + #[cfg(feature = "serde_json")] + FieldValue::Json(value) => Self::Json(value) } } } @@ -335,8 +360,16 @@ impl From<&FieldValue> for ProblemMetadataValue { FieldValue::Str(value) => Self::String(value.clone()), FieldValue::I64(value) => Self::I64(*value), FieldValue::U64(value) => Self::U64(*value), + FieldValue::F64(value) => Self::F64(*value), FieldValue::Bool(value) => Self::Bool(*value), - FieldValue::Uuid(value) => Self::String(Cow::Owned(value.to_string())) + FieldValue::Uuid(value) => Self::String(Cow::Owned(value.to_string())), + FieldValue::Duration(value) => Self::Duration { + secs: value.as_secs(), + nanos: value.subsec_nanos() + }, + FieldValue::Ip(value) => Self::Ip(*value), + #[cfg(feature = "serde_json")] + FieldValue::Json(value) => Self::Json(value.clone()) } } } @@ -454,6 +487,7 @@ fn hash_field_value(value: &FieldValue) -> String { let string = value.to_string(); hasher.update(string.as_bytes()); } + FieldValue::F64(value) => hasher.update(value.to_le_bytes()), FieldValue::Bool(value) => { if *value { hasher.update(b"true"); @@ -465,6 +499,20 @@ fn hash_field_value(value: &FieldValue) -> String { let string = value.to_string(); hasher.update(string.as_bytes()); } + FieldValue::Duration(value) => { + hasher.update(value.as_secs().to_le_bytes()); + hasher.update(value.subsec_nanos().to_le_bytes()); + } + FieldValue::Ip(value) => match value { + IpAddr::V4(addr) => hasher.update(addr.octets()), + IpAddr::V6(addr) => hasher.update(addr.octets()) + }, + #[cfg(feature = "serde_json")] + FieldValue::Json(value) => { + if let Ok(serialized) = serde_json::to_vec(value) { + hasher.update(&serialized); + } + } } let digest = hasher.finalize(); let mut hex = String::with_capacity(digest.len() * 2); @@ -479,7 +527,14 @@ fn mask_last4_field_value(value: &FieldValue) -> Option { FieldValue::Str(value) => Some(mask_last4(value.as_ref())), FieldValue::I64(value) => Some(mask_last4(&value.to_string())), FieldValue::U64(value) => Some(mask_last4(&value.to_string())), + FieldValue::F64(value) => Some(mask_last4(&value.to_string())), FieldValue::Uuid(value) => Some(mask_last4(&value.to_string())), + FieldValue::Duration(value) => Some(mask_last4(&duration_to_string(*value))), + FieldValue::Ip(value) => Some(mask_last4(&value.to_string())), + #[cfg(feature = "serde_json")] + FieldValue::Json(value) => serde_json::to_string(value) + .ok() + .map(|text| mask_last4(&text)), FieldValue::Bool(_) => None } } @@ -843,7 +898,11 @@ pub fn mapping_for_code(code: AppCode) -> CodeMapping { #[cfg(test)] mod tests { - use std::fmt::Write; + use std::{ + fmt::Write, + net::{IpAddr, Ipv4Addr}, + time::Duration + }; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -869,6 +928,55 @@ mod tests { assert!(!metadata.is_empty()); } + #[test] + fn metadata_preserves_extended_field_types() { + let mut err = AppError::internal("oops"); + err = err.with_field(crate::field::f64("ratio", 0.25)); + err = err.with_field(crate::field::duration( + "elapsed", + Duration::from_millis(1500) + )); + err = err.with_field(crate::field::ip( + "peer", + IpAddr::from(Ipv4Addr::new(10, 0, 0, 42)) + )); + #[cfg(feature = "serde_json")] + { + err = err.with_field(crate::field::json( + "payload", + serde_json::json!({ "status": "ok" }) + )); + } + + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + + let ratio = metadata.0.get("ratio").expect("ratio metadata"); + assert!(matches!( + ratio, + ProblemMetadataValue::F64(value) if (*value - 0.25).abs() < f64::EPSILON + )); + + let duration = metadata.0.get("elapsed").expect("elapsed metadata"); + assert!(matches!( + duration, + ProblemMetadataValue::Duration { secs, nanos } + if *secs == 1 && *nanos == 500_000_000 + )); + + let ip = metadata.0.get("peer").expect("peer metadata"); + assert!(matches!(ip, ProblemMetadataValue::Ip(addr) if addr.is_ipv4())); + + #[cfg(feature = "serde_json")] + { + let payload = metadata.0.get("payload").expect("payload metadata"); + assert!(matches!( + payload, + ProblemMetadataValue::Json(value) if value["status"] == "ok" + )); + } + } + #[test] fn redacted_metadata_uses_placeholder() { let err = AppError::internal("oops").with_field(crate::field::str("password", "secret"));