Skip to content

Commit 11ca31b

Browse files
authored
Merge pull request #128 from RAprogramm/eye-of-ra/-.md
Add richer metadata field types and transports support
2 parents 18ccdc8 + 15db832 commit 11ca31b

File tree

9 files changed

+337
-25
lines changed

9 files changed

+337
-25
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file.
33

44
## [Unreleased]
55

6+
## [0.21.2] - 2025-10-10
7+
8+
### Added
9+
- Expanded `Metadata` field coverage with float, duration, IP address and optional JSON values, complete with typed builders, doctests
10+
and unit tests covering the new cases.
11+
12+
### Changed
13+
- Enriched RFC7807 and gRPC adapters to propagate the new metadata types, hashing/masking them consistently across redaction policies.
14+
- Documented the broader telemetry surface in the README so adopters discover the additional structured field builders.
15+
616
## [0.21.1] - 2025-10-09
717

818
### Fixed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "masterror"
3-
version = "0.21.1"
3+
version = "0.21.2"
44
rust-version = "1.90"
55
edition = "2024"
66
license = "MIT OR Apache-2.0"

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ of redaction and metadata.
2929
- **Native derives.** `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`,
3030
`#[masterror(...)]` and `#[provide]` wire custom types into `AppError` while
3131
forwarding sources, backtraces, telemetry providers and redaction policy.
32-
- **Typed telemetry.** `Metadata` stores structured key/value context with
33-
per-field redaction controls and builders in `field::*`, so logs stay
34-
structured without manual `String` maps.
32+
- **Typed telemetry.** `Metadata` stores structured key/value context (strings,
33+
integers, floats, durations, IP addresses and optional JSON) with per-field
34+
redaction controls and builders in `field::*`, so logs stay structured without
35+
manual `String` maps.
3536
- **Transport adapters.** Optional features expose Actix/Axum responders,
3637
`tonic::Status` conversions, WASM/browser logging and OpenAPI schema
3738
generation without contaminating the lean default build.
@@ -73,9 +74,9 @@ The build script keeps the full feature snippet below in sync with
7374

7475
~~~toml
7576
[dependencies]
76-
masterror = { version = "0.21.1", default-features = false }
77+
masterror = { version = "0.21.2", default-features = false }
7778
# or with features:
78-
# masterror = { version = "0.21.1", features = [
79+
# masterror = { version = "0.21.2", features = [
7980
# "axum", "actix", "openapi", "serde_json",
8081
# "tracing", "metrics", "backtrace", "sqlx",
8182
# "sqlx-migrate", "reqwest", "redis", "validator",

README.template.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ of redaction and metadata.
2929
- **Native derives.** `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`,
3030
`#[masterror(...)]` and `#[provide]` wire custom types into `AppError` while
3131
forwarding sources, backtraces, telemetry providers and redaction policy.
32-
- **Typed telemetry.** `Metadata` stores structured key/value context with
33-
per-field redaction controls and builders in `field::*`, so logs stay
34-
structured without manual `String` maps.
32+
- **Typed telemetry.** `Metadata` stores structured key/value context (strings,
33+
integers, floats, durations, IP addresses and optional JSON) with per-field
34+
redaction controls and builders in `field::*`, so logs stay structured without
35+
manual `String` maps.
3536
- **Transport adapters.** Optional features expose Actix/Axum responders,
3637
`tonic::Status` conversions, WASM/browser logging and OpenAPI schema
3738
generation without contaminating the lean default build.

src/app_error.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ pub use core::{AppError, AppResult, Error, MessageEditPolicy};
7272
pub(crate) use core::{reset_backtrace_preference, set_backtrace_preference_override};
7373

7474
pub use context::Context;
75+
pub(crate) use metadata::duration_to_string;
7576
pub use metadata::{Field, FieldRedaction, FieldValue, Metadata, field};
7677

7778
#[cfg(test)]

src/app_error/metadata.rs

Lines changed: 193 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use std::{
22
borrow::Cow,
33
collections::BTreeMap,
4-
fmt::{Display, Formatter, Result as FmtResult}
4+
fmt::{Display, Formatter, Result as FmtResult, Write},
5+
net::IpAddr,
6+
time::Duration
57
};
68

79
/// Redaction policy associated with a metadata [`Field`].
@@ -18,6 +20,8 @@ pub enum FieldRedaction {
1820
Last4
1921
}
2022

23+
#[cfg(feature = "serde_json")]
24+
use serde_json::Value as JsonValue;
2125
use uuid::Uuid;
2226

2327
/// Value stored inside [`Metadata`].
@@ -34,10 +38,19 @@ pub enum FieldValue {
3438
I64(i64),
3539
/// Unsigned 64-bit integer.
3640
U64(u64),
41+
/// Floating-point value.
42+
F64(f64),
3743
/// Boolean flag.
3844
Bool(bool),
3945
/// UUID represented with the canonical binary type.
40-
Uuid(Uuid)
46+
Uuid(Uuid),
47+
/// Elapsed duration captured with nanosecond precision.
48+
Duration(Duration),
49+
/// IP address (v4 or v6).
50+
Ip(IpAddr),
51+
/// Structured JSON payload (requires the `serde_json` feature).
52+
#[cfg(feature = "serde_json")]
53+
Json(JsonValue)
4154
}
4255

4356
impl Display for FieldValue {
@@ -46,12 +59,82 @@ impl Display for FieldValue {
4659
Self::Str(value) => Display::fmt(value, f),
4760
Self::I64(value) => Display::fmt(value, f),
4861
Self::U64(value) => Display::fmt(value, f),
62+
Self::F64(value) => Display::fmt(value, f),
4963
Self::Bool(value) => Display::fmt(value, f),
50-
Self::Uuid(value) => Display::fmt(value, f)
64+
Self::Uuid(value) => Display::fmt(value, f),
65+
Self::Duration(value) => format_duration(*value, f),
66+
Self::Ip(value) => Display::fmt(value, f),
67+
#[cfg(feature = "serde_json")]
68+
Self::Json(value) => Display::fmt(value, f)
5169
}
5270
}
5371
}
5472

73+
#[derive(Clone, Copy)]
74+
struct TrimmedFraction {
75+
value: u32,
76+
width: u8
77+
}
78+
79+
fn duration_parts(duration: Duration) -> (u64, Option<TrimmedFraction>) {
80+
let secs = duration.as_secs();
81+
let nanos = duration.subsec_nanos();
82+
if nanos == 0 {
83+
return (secs, None);
84+
}
85+
86+
let mut fraction = nanos;
87+
let mut width = 9u8;
88+
loop {
89+
let divided = fraction / 10;
90+
if divided * 10 != fraction {
91+
break;
92+
}
93+
fraction = divided;
94+
width -= 1;
95+
}
96+
97+
(
98+
secs,
99+
Some(TrimmedFraction {
100+
value: fraction,
101+
width
102+
})
103+
)
104+
}
105+
106+
fn format_duration(duration: Duration, f: &mut Formatter<'_>) -> FmtResult {
107+
let (secs, fraction) = duration_parts(duration);
108+
if let Some(fraction) = fraction {
109+
write!(
110+
f,
111+
"{}.{:0width$}s",
112+
secs,
113+
fraction.value,
114+
width = fraction.width as usize
115+
)
116+
} else {
117+
write!(f, "{}s", secs)
118+
}
119+
}
120+
121+
pub(crate) fn duration_to_string(duration: Duration) -> String {
122+
let (secs, fraction) = duration_parts(duration);
123+
let mut output = String::new();
124+
if let Some(fraction) = fraction {
125+
let _ = write!(
126+
&mut output,
127+
"{}.{:0width$}s",
128+
secs,
129+
fraction.value,
130+
width = fraction.width as usize
131+
);
132+
} else {
133+
let _ = write!(&mut output, "{}s", secs);
134+
}
135+
output
136+
}
137+
55138
/// Single metadata field – name plus value.
56139
#[derive(Clone, Debug, PartialEq)]
57140
pub struct Field {
@@ -288,8 +371,10 @@ impl IntoIterator for Metadata {
288371

289372
/// Factories for [`Field`] values.
290373
pub mod field {
291-
use std::borrow::Cow;
374+
use std::{borrow::Cow, net::IpAddr, time::Duration};
292375

376+
#[cfg(feature = "serde_json")]
377+
use serde_json::Value as JsonValue;
293378
use uuid::Uuid;
294379

295380
use super::{Field, FieldValue};
@@ -312,6 +397,19 @@ pub mod field {
312397
Field::new(name, FieldValue::U64(value))
313398
}
314399

400+
/// Build an `f64` metadata field.
401+
///
402+
/// ```
403+
/// use masterror::{field, FieldValue};
404+
///
405+
/// let (_, value, _) = field::f64("ratio", 0.5).into_parts();
406+
/// assert!(matches!(value, FieldValue::F64(ratio) if ratio.to_bits() == 0.5f64.to_bits()));
407+
/// ```
408+
#[must_use]
409+
pub fn f64(name: &'static str, value: f64) -> Field {
410+
Field::new(name, FieldValue::F64(value))
411+
}
412+
315413
/// Build a boolean metadata field.
316414
#[must_use]
317415
pub fn bool(name: &'static str, value: bool) -> Field {
@@ -323,15 +421,66 @@ pub mod field {
323421
pub fn uuid(name: &'static str, value: Uuid) -> Field {
324422
Field::new(name, FieldValue::Uuid(value))
325423
}
424+
425+
/// Build a duration metadata field.
426+
///
427+
/// ```
428+
/// use std::time::Duration;
429+
/// use masterror::{field, FieldValue};
430+
///
431+
/// let (_, value, _) = field::duration("elapsed", Duration::from_millis(1500)).into_parts();
432+
/// assert!(matches!(value, FieldValue::Duration(duration) if duration == Duration::from_millis(1500)));
433+
/// ```
434+
#[must_use]
435+
pub fn duration(name: &'static str, value: Duration) -> Field {
436+
Field::new(name, FieldValue::Duration(value))
437+
}
438+
439+
/// Build an IP address metadata field.
440+
///
441+
/// ```
442+
/// use std::net::{IpAddr, Ipv4Addr};
443+
/// use masterror::{field, FieldValue};
444+
///
445+
/// let (_, value, _) = field::ip("peer", IpAddr::from(Ipv4Addr::LOCALHOST)).into_parts();
446+
/// assert!(matches!(value, FieldValue::Ip(addr) if addr.is_ipv4()));
447+
/// ```
448+
#[must_use]
449+
pub fn ip(name: &'static str, value: IpAddr) -> Field {
450+
Field::new(name, FieldValue::Ip(value))
451+
}
452+
453+
/// Build a JSON metadata field (requires the `serde_json` feature).
454+
///
455+
/// ```
456+
/// # #[cfg(feature = "serde_json")]
457+
/// # {
458+
/// use masterror::{field, FieldValue};
459+
///
460+
/// let (_, value, _) = field::json("payload", serde_json::json!({"ok": true})).into_parts();
461+
/// assert!(matches!(value, FieldValue::Json(payload) if payload["ok"].as_bool() == Some(true)));
462+
/// # }
463+
/// ```
464+
#[cfg(feature = "serde_json")]
465+
#[must_use]
466+
pub fn json(name: &'static str, value: JsonValue) -> Field {
467+
Field::new(name, FieldValue::Json(value))
468+
}
326469
}
327470

328471
#[cfg(test)]
329472
mod tests {
330-
use std::borrow::Cow;
331-
473+
use std::{
474+
borrow::Cow,
475+
net::{IpAddr, Ipv4Addr},
476+
time::Duration
477+
};
478+
479+
#[cfg(feature = "serde_json")]
480+
use serde_json::json;
332481
use uuid::Uuid;
333482

334-
use super::{FieldRedaction, FieldValue, Metadata, field};
483+
use super::{FieldRedaction, FieldValue, Metadata, duration_to_string, field};
335484

336485
#[test]
337486
fn metadata_roundtrip() {
@@ -358,6 +507,37 @@ mod tests {
358507
assert_eq!(collected[1].0, "trace_id");
359508
}
360509

510+
#[test]
511+
fn metadata_supports_extended_field_types() {
512+
let meta = Metadata::from_fields([
513+
field::f64("ratio", 0.25),
514+
field::duration("elapsed", Duration::from_millis(1500)),
515+
field::ip("peer", IpAddr::from(Ipv4Addr::new(192, 168, 0, 1)))
516+
]);
517+
518+
assert!(meta.get("ratio").is_some_and(
519+
|value| matches!(value, FieldValue::F64(ratio) if ratio.to_bits() == 0.25f64.to_bits())
520+
));
521+
assert_eq!(
522+
meta.get("elapsed"),
523+
Some(&FieldValue::Duration(Duration::from_millis(1500)))
524+
);
525+
assert_eq!(
526+
meta.get("peer"),
527+
Some(&FieldValue::Ip(IpAddr::from(Ipv4Addr::new(192, 168, 0, 1))))
528+
);
529+
}
530+
531+
#[cfg(feature = "serde_json")]
532+
#[test]
533+
fn metadata_supports_json_fields() {
534+
let meta = Metadata::from_fields([field::json("payload", json!({ "status": "ok" }))]);
535+
assert!(meta.get("payload").is_some_and(|value| matches!(
536+
value,
537+
FieldValue::Json(payload) if payload["status"] == "ok"
538+
)));
539+
}
540+
361541
#[test]
362542
fn inserting_field_replaces_previous_value() {
363543
let mut meta = Metadata::from_fields([field::i64("count", 1)]);
@@ -389,4 +569,10 @@ mod tests {
389569
assert_eq!(owned_value, field.value().clone());
390570
assert_eq!(redaction, field.redaction());
391571
}
572+
573+
#[test]
574+
fn duration_to_string_trims_trailing_zeroes() {
575+
let text = duration_to_string(Duration::from_micros(1500));
576+
assert_eq!(text, "0.0015s");
577+
}
392578
}

src/convert/tonic.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use tonic::{
3030
use crate::CODE_MAPPINGS;
3131
use crate::{
3232
AppErrorKind, Error, FieldRedaction, FieldValue, MessageEditPolicy, Metadata, RetryAdvice,
33-
mapping_for_code
33+
app_error::duration_to_string, mapping_for_code
3434
};
3535

3636
/// Error alias retained for backwards compatibility with 0.20 conversions.
@@ -142,8 +142,13 @@ fn metadata_value_to_ascii(value: &FieldValue) -> Option<Cow<'_, str>> {
142142
}
143143
FieldValue::I64(value) => Some(Cow::Owned(value.to_string())),
144144
FieldValue::U64(value) => Some(Cow::Owned(value.to_string())),
145+
FieldValue::F64(value) => Some(Cow::Owned(value.to_string())),
145146
FieldValue::Bool(value) => Some(Cow::Borrowed(if *value { "true" } else { "false" })),
146-
FieldValue::Uuid(value) => Some(Cow::Owned(value.to_string()))
147+
FieldValue::Uuid(value) => Some(Cow::Owned(value.to_string())),
148+
FieldValue::Duration(value) => Some(Cow::Owned(duration_to_string(*value))),
149+
FieldValue::Ip(value) => Some(Cow::Owned(value.to_string())),
150+
#[cfg(feature = "serde_json")]
151+
FieldValue::Json(_) => None
147152
}
148153
}
149154

0 commit comments

Comments
 (0)