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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions README.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/app_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
200 changes: 193 additions & 7 deletions src/app_error/metadata.rs
Original file line number Diff line number Diff line change
@@ -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`].
Expand All @@ -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`].
Expand All @@ -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 {
Expand All @@ -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<TrimmedFraction>) {
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 {
Expand Down Expand Up @@ -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};
Expand All @@ -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 {
Expand All @@ -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() {
Expand All @@ -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)]);
Expand Down Expand Up @@ -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");
}
}
9 changes: 7 additions & 2 deletions src/convert/tonic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -142,8 +142,13 @@ fn metadata_value_to_ascii(value: &FieldValue) -> Option<Cow<'_, str>> {
}
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
}
}

Expand Down
Loading
Loading