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
6 changes: 5 additions & 1 deletion crates/pii-redaction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,11 @@ metric-schema marks use schema-aware sanitization instead: required measurement
fields and numeric analytics remain valid for metric export, while descriptions
and string attribute values are redacted.
Strings become `[REDACTED]`, numbers become `0`, booleans become `false`, and
nulls, keys, arrays, and object shape are retained.
nulls, keys, arrays, and object shape are retained. On every mark, the preset
preserves the reserved `nemo_relay.log.severity` metadata field in canonical
form when it contains a supported severity. For opaque custom marks that use
`redact_all_leaves`, unsupported severity values remain redacted with the
other string leaves.
Known Relay marks are sanitized semantically so their structural and analytical
fields remain usable. This choice affects canonical event fields before
subscriber fan-out; exporter-owned resource attributes are outside this
Expand Down
34 changes: 30 additions & 4 deletions crates/pii-redaction/src/trajectory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use std::sync::Arc;
use serde_json::Value as Json;

use nemo_relay::api::event::{
CategoryProfile, Event, METRIC_DATA_SCHEMA_NAME, METRIC_DATA_SCHEMA_VERSION, MetricEnvelope,
CategoryProfile, Event, LOG_SEVERITY_METADATA_KEY, LogSeverity, METRIC_DATA_SCHEMA_NAME,
METRIC_DATA_SCHEMA_VERSION, MetricEnvelope,
};
use nemo_relay::codec::request::AnnotatedLlmRequest;
use nemo_relay::codec::response::AnnotatedLlmResponse;
Expand Down Expand Up @@ -130,6 +131,7 @@ impl TrajectorySanitizer {
event: &Event,
mut fields: nemo_relay::api::event::EventSanitizeFields,
) -> nemo_relay::api::event::EventSanitizeFields {
let log_severity = valid_mark_log_severity(event, fields.metadata.as_ref());
if is_relay_metric_mark(event) {
fields.data = fields
.data
Expand All @@ -140,7 +142,7 @@ impl TrajectorySanitizer {
fields.category_profile = fields
.category_profile
.and_then(|profile| sanitize_category_profile(profile, &self.replacement));
return fields;
return restore_log_severity(fields, log_severity);
}

let category = event.category().map(|category| category.as_str());
Expand All @@ -162,7 +164,7 @@ impl TrajectorySanitizer {
.category_profile
.and_then(|profile| redact_custom_category_profile(profile, self));
}
return fields;
return restore_log_severity(fields, log_severity);
}

if !specialized_scope {
Expand All @@ -180,7 +182,7 @@ impl TrajectorySanitizer {
fields.category_profile = fields
.category_profile
.and_then(|profile| sanitize_category_profile(profile, &self.replacement));
fields
restore_log_severity(fields, log_severity)
}

/// Redact optional metric text without modifying required export fields.
Expand All @@ -202,6 +204,30 @@ impl TrajectorySanitizer {
}
}

fn valid_mark_log_severity(event: &Event, metadata: Option<&Json>) -> Option<LogSeverity> {
if !matches!(event, Event::Mark(_)) {
return None;
}
metadata
.and_then(Json::as_object)
.and_then(|metadata| metadata.get(LOG_SEVERITY_METADATA_KEY))
.and_then(Json::as_str)
.and_then(|value| value.parse::<LogSeverity>().ok())
}

fn restore_log_severity(
mut fields: nemo_relay::api::event::EventSanitizeFields,
severity: Option<LogSeverity>,
) -> nemo_relay::api::event::EventSanitizeFields {
if let (Some(severity), Some(Json::Object(metadata))) = (severity, fields.metadata.as_mut()) {
metadata.insert(
LOG_SEVERITY_METADATA_KEY.to_string(),
Json::String(severity.as_str().to_string()),
);
}
fields
}

/// Return whether an event carries Relay's typed metric schema.
pub(crate) fn is_relay_metric_mark(event: &Event) -> bool {
matches!(event, Event::Mark(_))
Expand Down
60 changes: 58 additions & 2 deletions crates/pii-redaction/tests/unit/component_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
use super::*;
use crate::api::event::{
BaseEvent, CategoryProfile, DataSchema, Event, EventCategory, EventSanitizeFields,
METRIC_DATA_SCHEMA_NAME, METRIC_DATA_SCHEMA_VERSION, MarkEvent, MetricEnvelope, ScopeCategory,
ScopeEvent,
LOG_SEVERITY_METADATA_KEY, LogSeverity, METRIC_DATA_SCHEMA_NAME, METRIC_DATA_SCHEMA_VERSION,
MarkEvent, MetricEnvelope, ScopeCategory, ScopeEvent,
};
use crate::api::llm::{
LlmCallExecuteParams, LlmCallParams, LlmRequest, LlmStreamCallExecuteParams, llm_call,
Expand Down Expand Up @@ -1135,6 +1135,54 @@ async fn trajectory_custom_mark_policy_is_explicit_and_shape_preserving() {
assert_eq!(profile.extra["opaque"]["label"], "[REDACTED]");
}

#[tokio::test]
async fn trajectory_custom_mark_preserves_only_valid_log_severity() {
let event = Event::Mark(MarkEvent::new(
BaseEvent::builder().name("neutral.plugin.log").build(),
Some(EventCategory::custom()),
None,
));
let callback =
crate::builtin::event_sanitize_callback(trajectory_backend(None, "redact_all_leaves"));

let sanitized = callback(
Arc::new(event.clone()),
EventSanitizeFields {
data: Some(json!({"message": "private"})),
category_profile: None,
metadata: Some(json!({
LOG_SEVERITY_METADATA_KEY: "warning",
"owner": "private owner"
})),
},
)
.await
.unwrap();
assert_eq!(sanitized.data, Some(json!({"message": "[REDACTED]"})));
assert_eq!(
sanitized.metadata,
Some(json!({
LOG_SEVERITY_METADATA_KEY: "warn",
"owner": "[REDACTED]"
}))
);

let sanitized = callback(
Arc::new(event),
EventSanitizeFields {
data: None,
category_profile: None,
metadata: Some(json!({LOG_SEVERITY_METADATA_KEY: "private severity"})),
},
)
.await
.unwrap();
assert_eq!(
sanitized.metadata,
Some(json!({LOG_SEVERITY_METADATA_KEY: "[REDACTED]"}))
);
}

#[tokio::test]
async fn trajectory_metric_marks_preserve_typed_measurements_and_redact_text() {
let data = json!({
Expand Down Expand Up @@ -2708,6 +2756,7 @@ fn sanitized_trajectory_content_never_reaches_subscribers_or_exporters() {
.name("hermes.checkpoint")
.data(json!({"content": raw_context, "email": raw_pii, "score": 0.95}))
.metadata(json!({"reviewer": raw_pii}))
.severity(LogSeverity::Warn)
.build(),
)
.unwrap();
Expand Down Expand Up @@ -2786,6 +2835,13 @@ fn sanitized_trajectory_content_never_reaches_subscribers_or_exporters() {
.unwrap();
assert_eq!(custom_mark.data().unwrap()["content"], "[REDACTED]");
assert_eq!(custom_mark.data().unwrap()["score"], 0);
assert_eq!(
custom_mark.metadata().unwrap(),
&json!({
LOG_SEVERITY_METADATA_KEY: "warn",
"reviewer": "[REDACTED]"
})
);

deregister_subscriber("pii-regression-subscriber").unwrap();
atof.deregister("pii-regression-atof").unwrap();
Expand Down
5 changes: 5 additions & 0 deletions docs/configure-plugins/pii-redaction/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,11 @@ fields. That preset does not accept `target_paths`. Its
`custom_mark_payload_policy` applies only to opaque marks in the `custom`
category; it does not control Relay typed metric envelopes.

On every mark, the preset preserves the reserved `nemo_relay.log.severity`
metadata field in canonical form when it contains a supported severity. For
opaque custom marks using `redact_all_leaves`, unsupported severity values and
all other string leaves remain redacted.

## Action Semantics

### `remove`
Expand Down
Loading