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 for logging and telemetry. Redaction is not tied to any logging framework.
Table of Contents
- Getting started
- Design principles
- How Sensitive works
- How SensitiveDisplay works
- NotSensitive and NotSensitiveDisplay
- Wrapper types
- Integrations
- Logging safety
- Choosing what to use
- Reference
Getting started
There are two derive macros for types with sensitive data. Which one to use depends on whether you need the redacted result as a structured value or as a string.
Use Sensitive when you need the redacted value as a structured type. E.g. .redact() returns a User with redacted fields, not a string. The result can be serialized to JSON, passed to slog, inspected via valuable in tracing, or consumed by anything that works with typed data.
Use SensitiveDisplay when you need the redacted value as a formatted string. .redacted_display() returns a string with sensitive parts replaced. This is the natural fit for error messages, display output, flat log lines, and any context that expects text.
Quick examples
Structured (Sensitive), logged as JSON:
use Sensitive;
let user = User ;
let redacted = user.clone.redact;
assert_eq!;
assert_eq!;
// slog: automatic redaction, logged as structured JSON
info!;
// → {"name":"alice","email":"al***@example.com"}
String (SensitiveDisplay), logged as text:
use SensitiveDisplay;
let err = InvalidCredentials ;
assert_eq!;
What each derive generates
| Derive | Output | Debug |
Logging |
|---|---|---|---|
Sensitive |
Same type with redacted leaves (via RedactableWithMapper) |
✅ (redacted) | ✅ |
SensitiveDisplay |
Redacted string (via RedactableWithFormatter) |
✅ (redacted) | ✅ |
- Both generate a conditional
Debugimpl: redacted output in production, actual values in test builds (cfg(test)orfeature = "testing"). This means all field types must implementDebug. Opt out with#[sensitive(skip_debug)]. - Both generate
slog::Value+SlogRedacted(requiresslogfeature) andTracingRedacted(requirestracingfeature).Sensitiveemits structured JSON via slog (requiresSerialize).SensitiveDisplayemits the redacted display string. SensitiverequiresClonesince.redact()consumesself.SensitiveDisplayworks by reference, so noCloneis needed.
Design principles
The library follows three principles:
- Redaction should be opt-in. No data is redacted unless you explicitly mark it with
#[sensitive(Policy)]. Unannotated fields pass through unchanged. You choose what to protect and how. - Traversal should be automatic. Nested containers are walked recursively without manual intervention. For
Sensitive, this happens viaRedactableWithMapper. ForSensitiveDisplay, viaRedactableWithFormatter. - Both paths should share the same annotation model. Whether you use
SensitiveorSensitiveDisplay, the workflow is identical: unannotated fields pass through, containers delegate to their trait, and#[sensitive(Policy)]applies redaction.
How Sensitive works
Sensitive generates traversal code by implementing the RedactableWithMapper trait. Containers are just scaffolding: they get walked recursively until a leaf is reached. Leaves are where things actually happen:
- Unannotated leaves pass through unchanged.
- Annotated leaves (
#[sensitive(Policy)]) are where redaction is applied.
| Field kind | What happens |
|---|---|
Containers (structs/enums deriving Sensitive) |
Traversal walks into them recursively, visiting each field |
Standard leaves (String, primitives, Option, Vec, etc.) |
Built-in RedactableWithMapper implementation that performs no redaction; returned unchanged |
Annotated leaves (#[sensitive(Policy)]) |
The macro generates transformation code that applies the policy, bypassing the normal passthrough |
Explicit passthrough (#[not_sensitive]) |
Skips the RedactableWithMapper requirement entirely; the field is copied as-is with no redaction. Use for types that don't have a built-in implementation |
Why do standard leaves implement RedactableWithMapper?
Every field in a Sensitive type must implement RedactableWithMapper. But redaction should be opt-in: if you don't annotate a field, nothing should happen to it. Standard leaves like String and u32 square this circle by implementing RedactableWithMapper as a no-op. They satisfy the trait bound, but they don't transform anything. You only annotate what you actually want to protect.
The following types all have this built-in no-op implementation:
- Scalars:
bool,char,i8..i128,isize,u8..u128,usize,f32,f64,NonZeroI8..NonZeroUsize - Strings:
String,Cow<str> - Containers (delegate to inner values):
Option,Vec,Box,Arc,Rc,RefCell,Cell,Result,HashMap,BTreeMap,HashSet,BTreeSet - Other:
Duration,Instant,SystemTime,Ordering,PhantomData,IpAddr,Ipv4Addr,Ipv6Addr,SocketAddr
let outer = Outer ;
let redacted = outer.redact;
assert_eq!; // unchanged
assert_eq!; // unchanged
assert_eq!; // unchanged
assert_eq!; // walked and redacted
assert_eq!; // policy applied
What if a field doesn't implement RedactableWithMapper?
If a field type does not implement RedactableWithMapper, you get a compilation error. To fix this:
-
Local types: derive
Sensitiveon the type so it participates in traversal:// now implements RedactableWithMapper -
Foreign types: use
#[not_sensitive]to skip the field:#[not_sensitive]is the simplest escape hatch. Alternatively, the library provides dedicated wrapper types covered in Wrapper types for foreign types.
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 RedactableWithMapper::redact_with passthrough:
#[sensitive(Secret)]on scalars: replaces the value with a default (0, false,'*')#[sensitive(Secret)]on strings: replaces with"[REDACTED]"#[sensitive(Policy)]on strings: applies the policy's redaction rules
⚠️ Qualified primitive paths don't work with #[sensitive(Secret)]. The macro recognizes scalars by their bare names (u32, bool, char). Qualified paths like std::primitive::u32 are not recognized and will produce a compile error. Always use the bare name.
How the Sensitive macro processes each field
flowchart TD
F["For each field"] --> A{"Annotated with<br/>#[sensitive(Policy)]?"}
A -- Yes --> T{"Field type?"}
T -- "String-like<br/>(String, Cow, Option<String>, etc.)" --> B["Apply text redaction policy<br/>e.g. Email → al***@example.com"]
T -- "Scalar<br/>(only #[sensitive(Secret)])" --> C["Replace with default<br/>u32 → 0, bool → false, char → *"]
A -- No --> D{"Annotated with<br/>#[not_sensitive]?"}
D -- Yes --> E["Copy as-is<br/>no trait required"]
D -- No --> G{"Implements<br/>RedactableWithMapper?"}
G -- "Yes, container<br/>(derives Sensitive)" --> I["Recurse into its fields"]
G -- "Yes, standard leaf<br/>(String, u32, Option, etc.)" --> J["Passthrough unchanged"]
G -- No --> K["Compile error"]
How SensitiveDisplay works
SensitiveDisplay generates formatting code by implementing the RedactableWithFormatter trait. Unlike Sensitive, which walks every field and produces a redacted copy of the same type, SensitiveDisplay is template-driven: only fields referenced in the display template are formatted. Fields absent from the template are ignored entirely.
It works by reference (no Clone needed) and produces a string:
- Unannotated fields in the template are formatted unchanged.
- Annotated fields (
#[sensitive(Policy)]) have redaction applied before formatting. - Fields not in the template are not formatted at all.
| Field kind | What happens |
|---|---|
Nested types (structs/enums deriving SensitiveDisplay) |
Uses their RedactableWithFormatter to produce a redacted substring |
Standard scalars (String, primitives, Option, Vec, etc.) |
Built-in RedactableWithFormatter implementation; formatted unchanged |
Annotated fields (#[sensitive(Policy)]) |
The macro generates formatting code that applies the policy |
Explicit passthrough (#[not_sensitive]) |
Renders via raw Display (or Debug if {:?}). Skips the RedactableWithFormatter requirement. Use for types without a built-in implementation |
; // Does NOT derive SensitiveDisplay
let err = UserError ;
assert_eq!; // scalars unchanged
let err = AuthFailed ;
assert_eq!; // policy applied
let err = Nested ;
assert_eq!; // nested redaction
Template syntax
The display template comes from one of two sources:
#[error("...")] attribute (thiserror-style):
Doc comment (same syntax as displaydoc, but parsed by the macro itself):
Both support named placeholders ({field_name}), positional placeholders ({0}, {1}), and debug formatting ({field:?}).
Why do scalars implement RedactableWithFormatter?
The same design principle applies: redaction should be opt-in. Every field referenced in a template must implement RedactableWithFormatter, but if you don't annotate a field, nothing should happen to it. Standard scalars like String and u32 implement RedactableWithFormatter as a no-op that formats the value unchanged. You only annotate what you actually want to protect.
The built-in types are the same as for RedactableWithMapper (see the full list). All scalars, strings, containers, and time types have passthrough implementations for both traits.
let event = UserInfo ;
assert_eq!;
What if a field doesn't implement RedactableWithFormatter?
If a template references a field whose type does not implement RedactableWithFormatter, you get a compilation error. To fix this:
-
Local types: derive
SensitiveDisplayon the type so it participates in redacted formatting:// Now DatabaseError implements RedactableWithFormatter -
Foreign types: use
#[not_sensitive]to render via rawDisplayinstead:#[not_sensitive]is the simplest escape hatch. See Wrapper types for foreign types for more patterns.
The #[sensitive(Policy)] attribute in templates
#[sensitive(Policy)] marks a field as sensitive and applies a redaction policy. The behavior is the same as in Sensitive, but the output is formatted into the template string:
#[sensitive(Secret)]on strings: replaces with"[REDACTED]"#[sensitive(Secret)]on scalars: replaces with the default value (0,false,'*')#[sensitive(Policy)]on strings: applies the policy's redaction rules
let event = Login ;
assert_eq!;
How the SensitiveDisplay macro processes each field
flowchart TD
F["For each field<br/>in the template"] --> A{"Annotated with<br/>#[sensitive(Policy)]?"}
A -- Yes --> T{"Field type?"}
T -- "String-like<br/>(String, Cow, Option<String>, etc.)" --> B["Format with redaction policy<br/>e.g. Email → al***@example.com"]
T -- "Scalar<br/>(only #[sensitive(Secret)])" --> C["Format default value<br/>u32 → 0, bool → false, char → *"]
A -- No --> D{"Annotated with<br/>#[not_sensitive]?"}
D -- Yes --> E["Format via raw Display<br/>no trait required"]
D -- No --> G{"Implements<br/>RedactableWithFormatter?"}
G -- "Yes, nested type<br/>(derives SensitiveDisplay)" --> I["Format via fmt_redacted<br/>(redacted substring)"]
G -- "Yes, standard scalar<br/>(String, u32, Option, etc.)" --> J["Format unchanged"]
G -- No --> K["Compile error"]
NotSensitive and NotSensitiveDisplay
Types with no sensitive data still need to participate in the redaction system for two reasons:
-
Composition: every field in a
Sensitivetype must implementRedactableWithMapper, and every field in aSensitiveDisplaytemplate must implementRedactableWithFormatter. Non-sensitive field types need to satisfy these bounds. -
Logging safety: the
SlogRedactedandTracingRedactedmarker traits (see Logging safety) let you enforce that only certified types pass through your logging pipeline. Non-sensitive types need these markers to be loggable alongside sensitive ones.
NotSensitive and NotSensitiveDisplay solve both problems. They generate the required traits as no-op passthroughs and provide full logging integration. The choice between them follows the same sink-driven logic: NotSensitive for the structured path, NotSensitiveDisplay for the string path.
NotSensitive
NotSensitive is for types with no sensitive data that need to work inside Sensitive containers:
use ;
NotSensitive generates:
RedactableWithMapper: no-op passthrough (the type has no sensitive data)slog::ValueandSlogRedacted: serializes the value directly as structured JSON, same format asSensitivebut without redaction (whenslogfeature is enabled; requiresSerializeon the type)TracingRedacted: whentracingfeature is enabled
NotSensitiveDisplay
NotSensitiveDisplay is for types with no sensitive data that have a Display impl:
use NotSensitiveDisplay;
/// Retry using backoff
NotSensitiveDisplay generates:
RedactableWithMapper: no-op passthrough (allows use insideSensitivecontainers)RedactableWithFormatter: delegates toDisplay::fmt(allows use insideSensitiveDisplaycontainers)slog::ValueandSlogRedacted: whenslogfeature is enabledTracingRedacted: whentracingfeature is enabled
This cross-path compatibility makes NotSensitiveDisplay uniquely versatile. It is the only derive that works as a field in both Sensitive and SensitiveDisplay containers.
NotSensitiveDisplay works naturally with displaydoc or similar crates that derive Display:
use NotSensitiveDisplay;
// Now RetryDecision has Display (from displaydoc), RedactableWithFormatter, slog::Value, etc.
What all four derives generate
| Derive | RedactableWithMapper |
RedactableWithFormatter |
Debug |
|---|---|---|---|
Sensitive |
✅ | - | ✅ (redacted) |
SensitiveDisplay |
- | ✅ | ✅ (redacted) |
NotSensitive |
✅ | - | - |
NotSensitiveDisplay |
✅ | ✅ | - |
About Debug:
SensitiveandSensitiveDisplaygenerate a conditional impl: redacted in production, actual values incfg(test)orfeature = "testing". Opt out with#[sensitive(skip_debug)].NotSensitiveandNotSensitiveDisplaydo not overrideDebug. There is nothing to redact. Add#[derive(Debug)]separately when you need it.
Wrapper types
The library provides two wrapper types that give values a direct relationship with the redaction system:
SensitiveValue<T, P>- Wraps a value of type
Tand associates it with a redaction policyP - Implements
Debugwith redacted output - Does not implement
Display(prevents accidental raw formatting) - Implements
slog::Value+SlogRedacted(requiresslogfeature) andTracingRedacted(requirestracingfeature) - Provides
.redacted()for the redacted form and.expose()for raw access
- Wraps a value of type
NotSensitiveValue<T>- Wraps a non-sensitive type to satisfy
RedactableWithMapperbounds - Passes the value through unchanged
- Wraps a non-sensitive type to satisfy
Use cases
Wrapper types exist for two purposes:
Foreign types
You can't derive macros on types defined in other crates, and the orphan rule prevents you from implementing the internal traits they need (RedactableWithMapper, PolicyApplicable) because neither the trait nor the type is local. Wrappers provide those implementations for you. You only need to implement SensitiveWithPolicy<P>, which the orphan rule allows because your policy type is local.
For sensitive foreign types, implement SensitiveWithPolicy<P> with a custom policy (the orphan rule requires the policy type to be local) and wrap with SensitiveValue:
use ;
// Imagine this comes from a payments SDK.
// It exposes accessors but no redaction support.
For non-sensitive foreign types, wrap with NotSensitiveValue:
use ;
// (pretend this is from another crate)
Field-level redaction awareness
With #[sensitive(P)] attributes, fields are still bare types at runtime. A String is a String, and nothing stops you from accessing or formatting it unredacted. SensitiveValue<T, P> changes the runtime type itself: the field carries its policy, its Debug shows the redacted form, and Display is deliberately not implemented so accidental formatting won't compile. Each field can be redacted, logged, or inspected independently, without going through the parent container:
let user = User ;
// ✅ Safe: Debug shows the policy-redacted value, not the raw email
info!;
// ✅ Safe: explicit call for redacted form
info!;
// ⚠️ Intentional: .expose() for raw access (code review catches this)
let raw = user.email.expose;
Compare with #[sensitive(P)] attributes, where the field is a bare type at runtime:
#[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 policy-redacted value |
| Serialization | Shows raw value | Shows raw value |
| slog/tracing safety | ✅ Via container | ✅ Direct |
* The Sensitive and SensitiveDisplay derives generate Debug impls that show [REDACTED]
for sensitive data (disabled in test mode via cfg(test) or feature = "testing").
⚠️ Things to keep in mind:
NotSensitiveValuehas no role here- It carries no policy and provides no redaction awareness.
- For local non-sensitive types, just use
#[derive(NotSensitive)].
- Serialization is not protected
- Both
#[sensitive(P)]andSensitiveValueserialize to raw values. - This is intentional (APIs, databases, queues need the real data).
- If you need redacted serialization, call
.redact()before serializing.
- Both
- Wrappers are leaf-only
- Neither wrapper walks nested fields or applies inner
#[sensitive(...)]annotations. - In practice this is not a limitation: types that derive
Sensitivealready implementRedactableWithMapperand don't need wrapping.
- Neither wrapper walks nested fields or applies inner
Integrations
slog
The slog feature enables automatic redaction. Just log your values and they're redacted:
[]
= { = "0.5", = ["slog"] }
Containers: the Sensitive derive generates slog::Value automatically:
let event = PaymentEvent ;
// Just log it - slog::Value impl handles redaction automatically
info!;
// Logged JSON: {"customer_email":"al***@example.com","card_number":"************1234","amount":9999}
Leaf wrappers: SensitiveValue<T, P> also implements slog::Value:
let api_token: = from;
// Also automatic - SensitiveValue has its own slog::Value impl
info!;
// Logged: "*********-key"
Both work because they implement slog::Value. Containers get it via the derive macro, wrappers via a manual implementation. No explicit conversion needed. Sensitive emits structured JSON; SensitiveDisplay emits the redacted display string.
tracing
For structured logging with tracing, use the valuable integration:
[]
= { = "0.5", = ["tracing-valuable"] }
use TracingValuableExt;
let event = AuthEvent ;
// Redacts and logs as structured data - subscriber can traverse containers
info!;
// 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 TracingRedactedExt;
let api_key: = from;
let user_email: = from;
info!;
// 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.
Logging safety
For most use cases, the slog and tracing integrations handle safety automatically. This section covers how to enforce redaction at compile time and how to build custom logging pipelines.
Enforcing redaction at compile time
SlogRedacted and TracingRedacted are marker traits that certify a type is safe to log through a specific sink. They guarantee the sink adapter routes through the redacted path rather than exposing raw values. All four derive macros implement them automatically when the corresponding feature is enabled, as does SensitiveValue<T, P>.
Use these traits as bounds in your own logging macros to make unredacted logging a compile error:
use SlogRedacted;
// ✅ Works: Sensitive-derived types implement SlogRedacted
slog_safe!;
// ✅ Works: SensitiveValue implements SlogRedacted
slog_safe!; // SensitiveValue<String, Token>
// ❌ Won't compile: raw String doesn't implement SlogRedacted
slog_safe!;
The same pattern works for tracing:
use TracingRedacted;
ToRedactedOutput for custom pipelines
If you're not using slog or tracing, ToRedactedOutput is the single logging-safe bound. It produces a RedactedOutput: either Text(String) or Json(serde_json::Value) (requires json feature).
| Situation | Method | Returns |
|---|---|---|
| Structured container → redacted text | .redacted_output() |
RedactedOutput::Text |
| Structured container → redacted JSON | .redacted_json() (requires json feature) |
RedactedOutput::Json |
| Display type → redacted text | .redacted_display() or .to_redacted_output() |
RedactedOutput::Text |
| Non-sensitive (delegate to framework) | .not_sensitive() |
NotSensitive<&Self> |
| Non-sensitive (explicit Display) | .not_sensitive_display() |
NotSensitiveDisplay<&T> |
| Non-sensitive (explicit Debug) | .not_sensitive_debug() |
NotSensitiveDebug<&T> |
| Non-sensitive (explicit JSON) | .not_sensitive_json() (requires json feature) |
NotSensitiveJson<&T> |
Choosing what to use
This section brings together the decisions covered throughout the README into a single reference.
Which derive macro?
flowchart TD
A{"Does the type<br/>contain sensitive data?"} -- Yes --> B{"What output?"}
A -- No --> C{"Does it need to satisfy<br/>redaction trait bounds?"}
B -- "Structured value<br/>(JSON, slog, valuable)" --> D["derive Sensitive"]
B -- "Formatted string<br/>(errors, display, flat logs)" --> E["derive SensitiveDisplay"]
C -- "Yes, local type" --> F{"Which container path?"}
C -- "Yes, foreign type" --> G["NotSensitiveValue<T> wrapper<br/>or #[not_sensitive] attribute"]
C -- No --> H["No derive needed"]
F -- "Sensitive containers only" --> I["derive NotSensitive"]
F -- "SensitiveDisplay containers<br/>or both" --> J["derive NotSensitiveDisplay"]
How to handle each field
flowchart TD
A{"Is the field<br/>sensitive?"} -- Yes --> B{"Is the type<br/>foreign?"}
A -- No --> C{"Is the type<br/>foreign?"}
B -- Yes --> D["SensitiveValue<T, P><br/>+ impl SensitiveWithPolicy"]
B -- No --> E{"Need field-level<br/>Debug/logging safety?"}
E -- Yes --> F["SensitiveValue<T, P> wrapper"]
E -- "No (most common)" --> G["#[sensitive(Policy)] attribute"]
C -- Yes --> H["#[not_sensitive] attribute<br/>or NotSensitiveValue<T>"]
C -- No --> I["No annotation needed"]
How to log safely
| Situation | Use |
|---|---|
| slog | Log containers directly: slog::info!(logger, "msg"; "key" => &value) |
| tracing (structured) | .tracing_redacted_valuable() on containers |
| tracing (individual values) | .tracing_redacted() on SensitiveValue wrappers |
| Custom logging pipeline | ToRedactedOutput trait bound (details) |
| Compile-time enforcement | SlogRedacted/TracingRedacted bounds in macros (details) |
Reference
Supported types
#[sensitive(Policy)] works on string-like types: String, Cow<'_, str>, and wrappers around them like Option<String>. &str is not supported for Sensitive; use owned strings or Cow.
#[sensitive(Secret)] also works on scalars: integers are replaced with 0, floats with 0.0, bool with false, char with '*'.
Containers (Option, Vec, HashMap, etc.) are walked automatically. Policy annotations apply through them. Map keys are formatted with Debug and are not redacted.
serde_json::Value (requires json feature) is treated as an opaque leaf that fully redacts to Value::String("[REDACTED]"), since its dynamic structure could contain anything sensitive.
For the full list of types with built-in passthrough implementations, see Why do standard leaves implement RedactableWithMapper?.
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(Secret)]. For custom types, use the SensitiveValue<T, Policy> wrapper instead.
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.
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 |
|---|---|---|
Secret |
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 ;
;