redactable 0.3.2

Automatic redaction of sensitive data in structs for safe logging and debugging
Documentation

Redactable

redactable is a redaction library for Rust. It lets you mark sensitive data in your structs and enums and produce a safe, redacted version of the same type. Logging and telemetry are the most common use cases, but redaction is not tied to any logging framework.

Table of Contents

Core traits

  • RedactableContainer: composite types (structs, enums) that are traversed field-by-field
  • RedactableLeaf: terminal values that can be converted to/from a string for redaction
  • RedactionPolicy: types that define how a leaf is transformed (full redaction, keep last N chars, etc.)

Design philosophy

  • Traversal is automatic: nested containers are handled automatically. For Sensitive, they're walked via RedactableContainer. For SensitiveDisplay, they're formatted via RedactableDisplay.
  • Redaction is opt-in: leaf values (scalars, strings) pass through unchanged unless explicitly marked with #[sensitive(Policy)]. Redaction only happens where you ask for it.
  • Consistent annotation workflow: both Sensitive and SensitiveDisplay follow the same pattern—unannotated scalars pass through, unannotated containers are handled via their trait, and #[sensitive(Policy)] applies redaction.
  • Types are preserved: Sensitive's .redact() returns the same type, not a string or wrapper.

How it works

The Sensitive derive macro generates traversal code. For each field, it calls RedactableContainer::redact_with. This uniform interface is what makes everything compose.

Field kind What happens
Containers (structs/enums deriving Sensitive) Traversal walks into them recursively, visiting each field
Unannotated leaves (String, primitives, etc.) These implement RedactableContainer as a passthrough - they return themselves unchanged
Annotated leaves (#[sensitive(Policy)]) The macro generates transformation code that applies the policy, bypassing the normal RedactableContainer::redact_with call
Explicit passthrough (#[not_sensitive]) Field is left unchanged without requiring RedactableContainer — use for foreign types
#[derive(Clone, Sensitive)]
struct User {
    address: Address,       // container → walks into it
    name: String,           // leaf, no annotation → passthrough (unchanged)
    #[sensitive(Token)]
    api_key: String,        // leaf, annotated → policy applied (redacted)
}

This is why every field must implement RedactableContainer: containers need it for traversal, and leaves provide passthrough implementations that satisfy the requirement without doing anything.

SensitiveDisplay follows the same principle but uses RedactableDisplay instead: nested types format via their fmt_redacted() method, and scalars pass through unchanged. See SensitiveDisplay in depth for details.

Walkthrough

Trait bounds on containers

As described in How it works, every field must implement RedactableContainer. Here's what that looks like in practice:

#[derive(Clone, Sensitive)]
struct Address {
    city: String,
}

#[derive(Clone, Sensitive)]
struct User {
    address: Address,  // ✅ Address implements RedactableContainer (from Sensitive derive)
}

If a field's type does not implement RedactableContainer, you get a compilation error:

struct Account {  // Does NOT derive Sensitive
    password: String,
}

#[derive(Clone, Sensitive)]
struct Session {
    account: Account,  // ❌ ERROR: Account does not implement RedactableContainer
}

Blanket implementations

Two kinds of types get RedactableContainer for free.

Standard leaf types

String, primitives (u32, bool, etc.) implement RedactableContainer as a passthrough - they return themselves unchanged. This is why unannotated leaves compile and are left as-is:

#[derive(Clone, Sensitive)]
struct Profile {
    name: String,  // passthrough, unchanged
    age: u32,      // passthrough, unchanged
}

let profile = Profile { name: "alice".into(), age: 30 };
let redacted = profile.redact();
assert_eq!(redacted.name, "alice");
assert_eq!(redacted.age, 30);

Standard container types

Option, Vec, Box, Arc, etc. implement RedactableContainer by calling redact_with on their inner value(s). They do not change how the inner value is treated: the inner type (and any #[sensitive(...)] on the leaf value) decides whether it is a leaf, a nested container, or classified. Some examples:

  • Option<String> still treats the String as a passthrough leaf
  • Option<MyStruct> still walks into MyStruct
  • #[sensitive(Default)] on an Option<String> leaf applies the policy to the string inside
#[derive(Clone, Sensitive)]
struct Inner {
    #[sensitive(Default)]
    secret: String,
}

#[derive(Clone, Sensitive)]
struct Outer {
    maybe_string: Option<String>,  // Option walks, inner String is passthrough → unchanged
    maybe_inner: Option<Inner>,    // Option walks, inner Inner is walked → secret redacted
    #[sensitive(Default)]
    secret: Option<String>,        // #[sensitive] applies policy through the Option
}

let outer = Outer {
    maybe_string: Some("visible".into()),
    maybe_inner: Some(Inner { secret: "hidden".into() }),
    secret: Some("also_hidden".into()),
};
let redacted = outer.redact();

assert_eq!(redacted.maybe_string, Some("visible".into()));      // unchanged
assert_eq!(redacted.maybe_inner.unwrap().secret, "[REDACTED]"); // walked and redacted
assert_eq!(redacted.secret, Some("[REDACTED]".into()));         // policy applied

The #[sensitive(Policy)] attribute

The #[sensitive(Policy)] attribute marks a leaf as sensitive and applies a redaction policy. When present, the derive macro generates transformation code that applies the policy directly, bypassing the normal redact_with passthrough:

  • #[sensitive(Default)] on scalars: replaces the value with a default (0, false, '*')
  • #[sensitive(Default)] on strings: replaces with "[REDACTED]"
  • #[sensitive(Policy)] on strings: applies the policy's redaction rules
#[derive(Clone, Sensitive)]
struct Login {
    username: String,           // unchanged
    #[sensitive(Default)]
    password: String,           // redacted to "[REDACTED]"
    #[sensitive(Default)]
    attempts: u32,              // redacted to 0
}

⚠️ Qualified primitive paths don't work with #[sensitive(Default)]

The derive macro decides how to handle #[sensitive(Default)] based on a syntactic check of how you wrote the type. Only bare primitive names like u32, bool, char are recognized as scalars. Qualified paths like std::primitive::u32 are not.

This matters because:

  • Unannotated leaves: Both u32 and std::primitive::u32 work identically (passthrough via RedactableContainer)
  • #[sensitive(Default)] leaves:
    • u32 → recognized as scalar → redacts to 0
    • std::primitive::u32 → not recognized → tries to use PolicyApplicablecompile error
#[derive(Clone, Sensitive)]
struct Example {
    #[sensitive(Default)]
    count: u32,                    // ✅ works: recognized as scalar, redacts to 0

    #[sensitive(Default)]
    other: std::primitive::u32,    // ❌ compile error: u32 doesn't implement PolicyApplicable
}

Workaround: Always use bare primitive names (u32, bool, etc.) when applying #[sensitive(Default)].

How RedactableLeaf fits in

When you write #[sensitive(Policy)], the generated code needs to:

  1. Extract a string from the value (to apply the policy)
  2. Reconstruct the original type from the redacted string (so you get back your original type, not String)

RedactableLeaf provides this interface:

use redactable::RedactableLeaf;

struct UserId(String);

impl RedactableLeaf for UserId {
    fn as_str(&self) -> &str { &self.0 }                        // extract string
    fn from_redacted(redacted: String) -> Self { Self(redacted) } // reconstruct type
}

String already implements RedactableLeaf, which is why #[sensitive(Token)] works on String leaves out of the box. Implement it for your own types if you want policies to work on them.

Opting out with NotSensitive

Some types you own need to satisfy Redactable bounds but have no sensitive data. Use #[derive(NotSensitive)] to generate a no-op RedactableContainer impl:

use redactable::{NotSensitive, Sensitive};

#[derive(Clone, NotSensitive)]
struct PublicMetadata {
    version: String,
    timestamp: u64,
}

#[derive(Clone, Sensitive)]
struct Config {
    metadata: PublicMetadata,  // ✅ Works because NotSensitive provides RedactableContainer
}

Wrapper types for foreign types

Two wrapper types handle types you don't own (Rust's orphan rules prevent deriving Sensitive or implementing RedactableLeaf on foreign types):

  • NotSensitiveValue<T>: Wraps T and passes through unchanged
  • SensitiveValue<T, P>: Wraps T and applies policy P when redacted

Foreign types with no sensitive data

Use NotSensitiveValue<T> to satisfy RedactableContainer bounds:

use redactable::{NotSensitiveValue, Sensitive};

struct ForeignConfig { timeout: u64 }  // (pretend this is from another crate)

#[derive(Clone, Sensitive)]
struct AppConfig {
    foreign: NotSensitiveValue<ForeignConfig>,  // Passes through unchanged
}

Foreign leaf types that need redaction

For string-like foreign types (IDs, tokens), use RedactableWithPolicy<P> with SensitiveValue<T, P>:

// ❌ ERROR: can't implement RedactableLeaf (foreign trait) for ForeignId (foreign type)
impl RedactableLeaf for other_crate::ForeignId { ... }

// ✅ OK: RedactableWithPolicy<MyPolicy> is "local enough" because MyPolicy is yours
impl RedactableWithPolicy<MyPolicy> for other_crate::ForeignId { ... }

Then wrap the leaf:

#[derive(Clone, Sensitive)]
struct Config {
    foreign_id: SensitiveValue<other_crate::ForeignId, MyPolicy>,
}

Here's a complete example:

use redactable::{RedactableWithPolicy, RedactionPolicy, SensitiveValue, TextRedactionPolicy};

#[derive(Clone)]
struct ForeignId(String);  // (pretend this comes from another crate)

// 1. Define a local policy (can reuse built-in logic)
#[derive(Clone, Copy)]
struct ForeignIdPolicy;
impl RedactionPolicy for ForeignIdPolicy {
    fn policy() -> TextRedactionPolicy {
        TextRedactionPolicy::keep_last(2)
    }
}

// 2. Implement RedactableWithPolicy for the foreign type
impl RedactableWithPolicy<ForeignIdPolicy> for ForeignId {
    fn redact_with_policy(self, policy: &TextRedactionPolicy) -> Self {
        Self(policy.apply_to(&self.0))
    }

    fn redacted_string(&self, policy: &TextRedactionPolicy) -> String {
        policy.apply_to(&self.0)
    }
}

// 3. Create a type alias for ergonomics
type SensitiveForeignId = SensitiveValue<ForeignId, ForeignIdPolicy>;

// 4. Use the alias
let wrapped = SensitiveForeignId::from(ForeignId("external".into()));

⚠️ Wrappers treat their inner type as a leaf, not a container. Neither walks nested containers - if T derives Sensitive, its internal #[sensitive(...)] annotations would not be applied. This is ok because if a type derives Sensitive it should not be wrapped.

💡 These wrappers can also be used for types you own to provide additional logging safety guarantees. See Logging with maximum security for details.

Sensitive vs SensitiveDisplay

There are two derive macros for redaction. Pick the one that matches your constraints:

Sensitive SensitiveDisplay
Output Same type with redacted leaves Redacted string
Ownership Consumes self (clone if you need the original) Borrows self
Traverses containers Yes (walks all fields) No (only template placeholders)
Unannotated scalars Passthrough Passthrough
Unannotated containers Walked via RedactableContainer Formatted via RedactableDisplay
Best for Structured data Display strings, non-Clone types

Sensitive (structured redaction)

Use Sensitive when you can consume the value (or clone it if you need the original). Nested containers are traversed automatically; leaves are only redacted when annotated with #[sensitive(Policy)].

use redactable::Sensitive;

#[derive(Clone, Sensitive)]
struct LoginAttempt {
    user: String,                // unchanged (no annotation)
    #[sensitive(Default)]
    password: String,            // redacted to "[REDACTED]"
}

let attempt = LoginAttempt {
    user: "alice".into(),
    password: "hunter2".into(),
};
let redacted = attempt.redact();
assert_eq!(redacted.user, "alice");
assert_eq!(redacted.password, "[REDACTED]");

SensitiveDisplay (string formatting)

Use SensitiveDisplay when you need a redacted string representation without Clone. It formats from a template and uses RedactableDisplay for unannotated placeholders. Common scalar-like types implement RedactableDisplay as passthrough.

use redactable::SensitiveDisplay;

#[derive(SensitiveDisplay)]
enum LoginError {
    #[error("login failed for {user} {password}")]
    Invalid {
        user: String,               // passthrough by default
        #[sensitive(Default)]       // redacted to "[REDACTED]"
        password: String,
    },
}

let err = LoginError::Invalid {
    user: "alice".into(),
    password: "hunter2".into(),
};
// err.redacted_display() → "login failed for alice [REDACTED]"

See SensitiveDisplay in depth for template syntax and field annotations.

Nested SensitiveDisplay types are redacted automatically without extra annotations:

use redactable::{Default as RedactableDefault, SensitiveDisplay};

#[derive(SensitiveDisplay)]
enum InnerError {
    #[error("db password {password}")]
    BadPassword {
        #[sensitive(RedactableDefault)]
        password: String,
    },
}

#[derive(SensitiveDisplay)]
enum OuterError {
    #[error("request failed: {source}")]
    RequestFailed { source: InnerError },
}

let err = OuterError::RequestFailed {
    source: InnerError::BadPassword {
        password: "secret".into(),
    },
};
// err.redacted_display() → "request failed: db password [REDACTED]"

SensitiveDisplay in depth

SensitiveDisplay derives RedactableDisplay, which provides fmt_redacted() and redacted_display(). Unlike Sensitive, it produces a string rather than a redacted copy of the same type.

The annotation workflow mirrors Sensitive:

  • Unannotated scalars → passthrough (unchanged)
  • Unannotated nested types → use their RedactableDisplay implementation
  • #[sensitive(Policy)] → apply redaction policy

Template syntax

The display template comes from one of two sources:

1. #[error("...")] attribute (thiserror-style):

#[derive(SensitiveDisplay)]
enum ApiError {
    #[error("auth failed for {user}")]
    AuthFailed { user: String },
}

2. Doc comment (displaydoc-style):

#[derive(SensitiveDisplay)]
enum ApiError {
    /// auth failed for {user}
    AuthFailed { user: String },
}

Both support:

  • Named placeholders: {field_name}
  • Positional placeholders: {0}, {1}
  • Debug formatting: {field:?}

Field annotations

Unannotated placeholders use RedactableDisplay:

Annotation Behavior
(none) Uses RedactableDisplay: scalars pass through unchanged; nested SensitiveDisplay types are redacted
#[not_sensitive] Renders with raw Display (or Debug if {:?}) — use for types without RedactableDisplay
#[sensitive(Default)] Scalars → default value; strings → "[REDACTED]"
#[sensitive(Policy)] Applies the policy's redaction rules

This matches Sensitive behavior: scalars pass through, nested containers use their redaction trait. Both Sensitive and SensitiveDisplay support all these annotations.

Unannotated fields that do not implement RedactableDisplay produce a compile error:

struct ExternalContext;

impl std::fmt::Display for ExternalContext {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("external")
    }
}

#[derive(SensitiveDisplay)]
enum LoginError {
    #[error("context {ctx}")]
    Failed {
        ctx: ExternalContext,  // ❌ ERROR: does not implement RedactableDisplay
    },
}

This prevents accidental exposure when adding new fields while still making nested redaction ergonomic.

Decision guide

Which derive macro?

Situation Use
Structured data you can consume (or clone) #[derive(Sensitive)]
Types you only want to borrow #[derive(SensitiveDisplay)]
Type with no sensitive data #[derive(NotSensitive)]

Error types are a common case: use Sensitive if your error type implements Clone, otherwise use SensitiveDisplay.

How to handle foreign types?

Situation Use
Foreign type, no sensitive data #[not_sensitive] attribute or NotSensitiveValue<T> wrapper
Foreign type, needs redaction SensitiveValue<T, Policy> + RedactableWithPolicy

How to produce logging output?

Situation Use
Container → redacted text .redacted_output()
Container → redacted JSON .redacted_json() (requires json feature)
Non-sensitive value .not_sensitive() / .not_sensitive_debug() / .not_sensitive_json()
SensitiveDisplay type .redacted_display() or .to_redacted_output()

Logging output (explicit boundary)

ToRedactedOutput is the single logging-safe bound. It produces a RedactedOutput:

  • RedactedOutput::Text(String)
  • RedactedOutput::Json(serde_json::Value) (requires the json feature)

⚠️ The Json variant uses serde_json::Value, which integrates well with slog's structured logging. For tracing, the Json variant is converted to a string since tracing's Value trait is sealed.

Several wrappers produce RedactedOutput:

  • SensitiveValue<T, Policy> (Text)
  • RedactedOutputRef / .redacted_output() (Text)
  • RedactedJsonRef / .redacted_json() (Json, json feature)
  • NotSensitiveDisplay / .not_sensitive() (Text)
  • NotSensitiveDebug / .not_sensitive_debug() (Text)
  • NotSensitiveJson / .not_sensitive_json() (Json, json feature)

Wrapper safety at the boundary

Types that guarantee redaction for a sink implement the sink marker traits (SlogRedacted, TracingRedacted). In practice:

  • Guaranteed redaction (by definition or adapter): SensitiveValue<T, Policy>, RedactedOutput, RedactedOutputRef, RedactedJsonRef (json), plus sink-specific wrappers like slog::RedactedJson and tracing::RedactedValuable (with tracing-valuable)
  • Derived types: Sensitive and SensitiveDisplay implement the marker traits when the sink feature is enabled
  • Explicitly non-sensitive: NotSensitiveDisplay, NotSensitiveDebug, NotSensitiveJson (you are asserting safety)
  • Not a guarantee: raw String/scalars and passthrough RedactableDisplay types
use redactable::{
    NotSensitiveDebugExt, NotSensitiveExt, NotSensitiveJsonExt, RedactedJsonExt, RedactedOutput,
    RedactedOutputExt, RedactableLeaf, SensitiveValue, Sensitive, Default, ToRedactedOutput,
};

#[derive(Clone)]
struct ExternalId(String);

impl RedactableLeaf for ExternalId {
    fn as_str(&self) -> &str { self.0.as_str() }
    fn from_redacted(redacted: String) -> Self { Self(redacted) }
}

#[derive(Clone, Sensitive)]
struct Event {
    id: SensitiveValue<ExternalId, Default>,
    status: String,
}

fn log_redacted<T: ToRedactedOutput>(value: &T) {
    match value.to_redacted_output() {
        RedactedOutput::Text(text) => println!("{}", text),
        #[cfg(feature = "json")]
        RedactedOutput::Json(json) => println!("{}", json),
    }
}

let event = Event {
    id: SensitiveValue::from(ExternalId("abc".into())),
    status: "ok".into(),
};

log_redacted(&event.id);
log_redacted(&event.status.not_sensitive());
log_redacted(&event.status.not_sensitive_debug());
#[cfg(feature = "json")]
log_redacted(&event.status.not_sensitive_json());
log_redacted(&event.redacted_output());
#[cfg(feature = "json")]
log_redacted(&event.redacted_json());

Notes:

  • redacted_output() uses Debug formatting on the redacted value; redacted_json() provides structured output when JSON is available
  • This crate does not override Display, so bypassing ToRedactedOutput and logging raw values directly can still leak data
  • For stronger guarantees, route all logging through helpers that require T: ToRedactedOutput

Integrations

slog

The slog feature enables automatic redaction - just log your values and they're redacted:

[dependencies]
redactable = { version = "0.2.3", features = ["slog"] }

Containers - the Sensitive derive generates slog::Value automatically:

#[derive(Clone, Sensitive, Serialize)]
struct PaymentEvent {
    #[sensitive(Email)]
    customer_email: String,
    #[sensitive(CreditCard)]
    card_number: String,
    amount: u64,
}

let event = PaymentEvent {
    customer_email: "alice@example.com".into(),
    card_number: "4111111111111234".into(),
    amount: 9999,
};

// Just log it - slog::Value impl handles redaction automatically
slog::info!(logger, "payment"; "event" => &event);
// Logged JSON: {"customer_email":"al***@example.com","card_number":"************1234","amount":9999}

Leaf wrappers - SensitiveValue<T, P> also implements slog::Value:

let api_token: SensitiveValue<String, Token> = SensitiveValue::from("sk-secret-key".into());

// Also automatic - SensitiveValue has its own slog::Value impl
slog::info!(logger, "auth"; "token" => &api_token);
// Logged: "*********-key"

Both work because they implement slog::Value - containers via the derive macro, wrappers via a manual implementation. No explicit conversion needed. SensitiveDisplay types also derive slog::Value when the feature is enabled, emitting the redacted display string.

tracing

For structured logging with tracing, use the valuable integration:

[dependencies]
redactable = { version = "0.2.3", features = ["tracing-valuable"] }
use redactable::tracing::TracingValuableExt;

#[derive(Clone, Sensitive, valuable::Valuable)]
struct AuthEvent {
    #[sensitive(Token)]
    api_key: String,
    #[sensitive(Email)]
    user_email: String,
    action: String,
}

let event = AuthEvent {
    api_key: "sk-secret-key-12345".into(),
    user_email: "alice@example.com".into(),
    action: "login".into(),
};

// Redacts and logs as structured data - subscriber can traverse containers
tracing::info!(event = event.tracing_redacted_valuable());
// Logged: {api_key: "***************2345", user_email: "al***@example.com", action: "login"}

Unlike slog where slog::Value can be implemented automatically via the derive macro, tracing's Value trait is sealed. The valuable crate provides the structured data path - .tracing_redacted_valuable() redacts first, then wraps for valuable inspection.

For individual values (without valuable):

use redactable::tracing::TracingRedactedExt;

let api_key: SensitiveValue<String, Token> = SensitiveValue::from("sk-secret-key-12345".into());
let user_email: SensitiveValue<String, Email> = SensitiveValue::from("alice@example.com".into());

tracing::info!(
    api_key = api_key.tracing_redacted(),
    user_email = user_email.tracing_redacted(),
    action = "login"
);
// Logged: api_key="***************2345" user_email="al***@example.com" action="login"

⚠️ Note: The valuable integration in tracing is still marked as unstable and requires a compatible subscriber.

Sink-specific safety traits

SlogRedacted and TracingRedacted are marker traits that certify a type's output is redacted for a specific sink. They indicate that the sink adapter uses the redacted path; they do not validate policy choices.

They live in redactable::slog and redactable::tracing because the adapters differ (slog::Value JSON vs tracing display/valuable). A type might be safe for one sink and not the other.

The traits are implemented only next to the sink adapters (derive-generated impls and specific wrappers), not as blanket impls for raw types or ToRedactedOutput. See Wrapper safety at the boundary for the covered wrappers.

Logging with maximum security

For high-security domains (finance, healthcare, compliance-sensitive systems), you need guarantees that sensitive data can't be accidentally logged. This section explains the safety guarantees the library provides and how to leverage them.

The logging footgun

With #[sensitive(P)] attributes, the field is still the bare type at runtime:

#[derive(Clone, Sensitive)]
struct User {
    #[sensitive(Pii)]
    email: String,  // At runtime, this is just a String
}

let user = User { email: "alice@example.com".into() };

// ❌ Nothing stops you from logging the field directly
log::info!("Email: {}", user.email);  // Logs "alice@example.com" unredacted!

This is the core problem: #[sensitive(P)] marks intent but doesn't change the runtime type.

Built-in safety with slog and tracing

The library provides automatic safety when you use the slog or tracing integrations correctly. Types deriving Sensitive or SensitiveDisplay automatically implement slog::Value and the SlogRedacted/TracingRedacted marker traits.

slog - Just log containers directly:

#[derive(Clone, Sensitive, Serialize)]
struct User {
    #[sensitive(Pii)]
    email: String,
}

let user = User { email: "alice@example.com".into() };

// ✅ Safe: slog::Value impl auto-redacts before logging
slog::info!(logger, "user logged in"; "user" => &user);
// Logged: {"email":"al***@example.com"}

tracing - Use the extension traits:

use redactable::tracing::TracingValuableExt;

// ✅ Safe: redacts before logging as structured data
tracing::info!(user = user.tracing_redacted_valuable());

The footgun only happens when you bypass these integrations by logging individual fields directly (user.email instead of &user).

Enforcing safety with trait bounds

The library provides marker traits that certify a type's output is redacted for a specific sink:

  • SlogRedacted - implemented by types safe to log via slog
  • TracingRedacted - implemented by types safe to log via tracing

These traits are implemented for:

  • Types deriving Sensitive or SensitiveDisplay (when the feature is enabled)
  • SensitiveValue<T, P> wrappers
  • RedactedOutput, RedactedOutputRef, RedactedJsonRef
  • NotSensitiveDisplay, NotSensitiveDebug, NotSensitiveJson (you assert safety)

Use these traits as bounds to enforce safety in your own logging macros:

use redactable::slog::SlogRedacted;

// Macro that only accepts types certified as slog-safe
macro_rules! slog_safe {
    ($logger:expr, $msg:literal; $($key:literal => $value:expr),* $(,)?) => {{
        // The trait bound is enforced by this function call
        fn assert_slog_safe<T: SlogRedacted + slog::Value>(_: &T) {}
        $(assert_slog_safe(&$value);)*
        slog::info!($logger, $msg; $($key => &$value),*);
    }};
}

// ✅ Works: Sensitive-derived types implement SlogRedacted
slog_safe!(logger, "user logged in"; "user" => &user);

// ✅ Works: SensitiveValue implements SlogRedacted  
slog_safe!(logger, "auth"; "token" => &api_token);  // SensitiveValue<String, Token>

// ❌ Won't compile: raw String doesn't implement SlogRedacted
slog_safe!(logger, "user"; "email" => &user.email);

For tracing:

use redactable::tracing::TracingRedacted;

macro_rules! trace_safe {
    ($($key:ident = $value:expr),* $(,)?) => {{
        fn assert_tracing_safe<T: TracingRedacted>(_: &T) {}
        $(assert_tracing_safe(&$value);)*
        tracing::info!($($key = tracing::field::debug(&$value)),*);
    }};
}

Alternative: SensitiveValue<T, P> wrappers

If you want field-level protection even outside the logging integrations, use SensitiveValue<T, P> wrappers instead of #[sensitive(P)] attributes:

#[derive(Clone, Sensitive)]
struct User {
    email: SensitiveValue<String, Pii>,  // The value IS a wrapper, not a bare String
}

let user = User { email: SensitiveValue::from("alice@example.com".into()) };

// ✅ Safe: Debug shows "[REDACTED]"
log::info!("Email: {:?}", user.email);

// ✅ Safe: explicit call for redacted form
log::info!("Email: {}", user.email.redacted());

// ⚠️ Intentional: .expose() for raw access (code review catches this)
let raw = user.email.expose();

Trade-offs: attributes vs wrappers

#[sensitive(P)] SensitiveValue<T, P>
Ergonomics ✅ Work with actual types ❌ Need .expose() everywhere
Display ({}) Shows raw value ✅ Not implemented (won't compile)
Debug ({:?}) ✅ Shows [REDACTED]* ✅ Shows [REDACTED]
Serialization Shows raw value Shows raw value
slog/tracing safety ✅ Via container ✅ Direct

* The Sensitive derive generates a Debug impl that shows [REDACTED] for sensitive fields (disabled in test mode via cfg(test) or feature = "testing").

⚠️ Neither approach protects serialization. Both serialize to raw values. This is intentional: serialization is used for API responses, database persistence, message queues, etc. If you need redacted serialization, call .redact() before serializing.

Choosing an approach

  • Use the slog/tracing integrations - Log containers via &user (slog) or .tracing_redacted_valuable() (tracing). Safety is automatic.
  • Use SlogRedacted/TracingRedacted bounds - Enforce safety in your own helpers. Only certified types compile.
  • Use SensitiveValue<T, P> wrappers - When you need field-level protection outside logging, or can't control the logging layer.
  • Use #[sensitive(P)] attributes - Most ergonomic. Safe when logging containers through the integrations.

Reference

Trait map

Domain layer (what is sensitive):

Trait Purpose Implemented By
RedactableContainer Walkable containers Structs/enums deriving Sensitive, NotSensitiveValue<T>
RedactableLeaf String-like leaves String, Cow<str>, custom newtypes

Policy layer (how to redact):

Trait Purpose Implemented By
RedactionPolicy Maps policy marker -> redaction Your custom policies
TextRedactionPolicy Concrete string transformations Built-ins (Full/Keep/Mask)

Application layer (redaction machinery):

Trait Purpose Implemented By
PolicyApplicable Applies policy through wrappers String, Option, Vec, etc.
Redactable User-facing .redact() Auto-implemented for RedactableContainer
RedactableWithPolicy Policy-aware leaf redaction RedactableLeaf types and external types
ToRedactedOutput Logging output boundary RedactedOutput, SensitiveValue<T,P>, RedactedOutputRef, RedactedJsonRef, NotSensitive*, RedactableDisplay
RedactableMapper Internal traversal #[doc(hidden)]

Types:

Type Purpose
RedactedOutput Enum for logging output: Text(String) or Json(serde_json::Value)
SensitiveValue<T, P> Wrapper that applies policy P to leaf type T
NotSensitiveValue<T> Wrapper that passes T through unchanged

Display/logging layer:

Trait Purpose Implemented By
RedactableDisplay Redacted string formatting SensitiveDisplay derive, scalars (passthrough), containers (delegate to contents)
SlogRedacted slog redaction safety marker Derived types and safe slog wrappers
TracingRedacted tracing redaction safety marker Derived types and safe tracing wrappers
SlogRedactedExt slog structured JSON logging Types implementing Redactable + Debug + Serialize
TracingRedactedExt tracing display string logging Types implementing ToRedactedOutput
TracingValuableExt tracing structured logging via valuable Types implementing Redactable + Clone + Valuable

Supported types

Leaves (implement RedactableLeaf):

  • String, Cow<'_, str>
  • Custom newtypes (implement RedactableLeaf yourself)
  • Note: &str is not supported for Sensitive; use owned strings or Cow

Scalars (with #[sensitive(Default)]):

  • Integers → 0, floats → 0.0, boolfalse, char'*'

Scalars (implement RedactableDisplay as passthrough):

  • String, str, bool, char, integers, floats, Cow<str>, PhantomData, ()
  • Feature-gated: chrono types, time types, Uuid

Containers (implement RedactableContainer and RedactableDisplay):

  • Option<T>, Vec<T>, VecDeque<T>, Box<T>, Arc<T>, Rc<T>, Result<T, E>, slices [T]
  • HashMap, BTreeMap, HashSet, BTreeSet
  • Cell<T>, RefCell<T>
  • For Sensitive: walked automatically; policy annotations apply through them
  • For SensitiveDisplay: formatted via RedactableDisplay, delegating to inner types
  • Map keys are formatted with Debug and are not redacted

External types: NotSensitiveValue<T> for passthrough, SensitiveValue<T, Policy> with RedactableWithPolicy for redaction.

Precedence and edge cases

#[sensitive(Policy)] on strings works with String and Cow<str> (and their wrappers like Option<String>). Scalars can only use #[sensitive(Default)]. For custom types, use the SensitiveValue<T, Policy> wrapper instead.

A type can implement both RedactableLeaf and derive Sensitive. This is useful when you want the option to either traverse the type's containers or redact it as a unit depending on context. Which trait is used depends on how the value is declared:

  • Bare type (unannotated): uses RedactableContainer, containers are traversed
  • SensitiveValue<T, Policy> wrapper: uses RedactableLeaf, redacted as a unit

Unannotated containers whose type derives Sensitive are still walked. If a nested type has #[sensitive(Policy)] annotations on its leaves, those are applied even when the outer container is unannotated.

Implementing RedactableLeaf on a struct or enum makes it a terminal value. Its fields will not be traversed or individually redacted. This is useful when you want to redact the entire value as a unit, but nested #[sensitive(Policy)] annotations inside that type are ignored when it's used as a leaf.

Sets can collapse after redaction. HashSet/BTreeSet are redacted element-by-element and then collected back into a set. If redaction makes elements equal (e.g., multiple values redact to "[REDACTED]"), the resulting set may shrink. If cardinality matters, prefer a Vec.

Built-in policies

Policy Use for Example output
Default Scalars or generic redaction 0 / false / '*' / [REDACTED]
Token API keys ...f456 (last 4)
Email Email addresses al***@example.com
CreditCard Card numbers ...1234 (last 4)
Pii Generic PII (names, addresses) ...oe (last 2)
PhoneNumber Phone numbers ...4567 (last 4)
IpAddress IP addresses ....100 (last 4)
BlockchainAddress Wallet addresses ...abcdef (last 6)

Custom policies

use redactable::{RedactionPolicy, TextRedactionPolicy};

#[derive(Clone, Copy)]
struct InternalId;

impl RedactionPolicy for InternalId {
    fn policy() -> TextRedactionPolicy {
        TextRedactionPolicy::keep_last(2)
    }
}

License

Licensed under the MIT license (LICENSE.md or opensource.org/licenses/MIT).