наблюдаемость: завершить базовый контур Community
CI / Rust Checks (push) Failing after 4m28s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped

Добавить структурированные журналы, метрики, трассировку и безопасный канал критических ошибок. Усилить границы рантайма, тесты, проверку зависимостей и сценарии развёртывания.
This commit is contained in:
2026-07-31 01:01:14 +03:00
parent 99bd05c145
commit 0e8f1ca03a
160 changed files with 13506 additions and 1499 deletions
@@ -0,0 +1,42 @@
use crank_observability::RequestId;
use uuid::Version;
#[test]
fn preserves_valid_opaque_request_id() {
let request_id = RequestId::resolve(Some("req_test-123/abc"));
assert_eq!(request_id.as_str(), "req_test-123/abc");
}
#[test]
fn replaces_missing_and_invalid_values_with_uuid_v7() {
for candidate in [
None,
Some(""),
Some("bad value"),
Some(" leading"),
Some("trailing "),
Some("bad,value"),
Some("bad;value"),
Some("я"),
] {
let request_id = RequestId::resolve(candidate);
let parsed = uuid::Uuid::parse_str(request_id.as_str()).expect("generated UUID");
assert_eq!(parsed.get_version(), Some(Version::SortRand));
}
}
#[test]
fn rejects_values_over_the_shared_limit() {
let oversized = "x".repeat(RequestId::MAX_LEN + 1);
let request_id = RequestId::resolve(Some(&oversized));
assert_ne!(request_id.as_str(), oversized);
assert_eq!(
uuid::Uuid::parse_str(request_id.as_str())
.expect("generated UUID")
.get_version(),
Some(Version::SortRand)
);
}
@@ -0,0 +1,33 @@
use crank_observability::{CriticalErrorCategory, SentryConfig, SentryConfigError};
#[test]
fn missing_or_blank_dsn_disables_critical_error_channel() {
assert!(!SentryConfig::parse(None).expect("missing DSN").enabled());
assert!(!SentryConfig::parse(Some("")).expect("empty DSN").enabled());
assert!(
!SentryConfig::parse(Some(" "))
.expect("blank DSN")
.enabled()
);
}
#[test]
fn invalid_explicit_dsn_is_rejected_without_echoing_the_value() {
let secret_value = "not-a-dsn?token=control-secret";
let error = SentryConfig::parse(Some(secret_value)).expect_err("invalid DSN must fail");
assert!(matches!(error, SentryConfigError::InvalidDsn));
assert!(!error.to_string().contains(secret_value));
assert!(!error.to_string().contains("control-secret"));
}
#[test]
fn critical_error_categories_are_closed_and_stable() {
assert_eq!(CriticalErrorCategory::Panic.as_str(), "panic");
assert_eq!(CriticalErrorCategory::Startup.as_str(), "startup");
assert_eq!(CriticalErrorCategory::Internal.as_str(), "internal");
assert_eq!(
CriticalErrorCategory::DataIntegrity.as_str(),
"data_integrity"
);
}
@@ -0,0 +1,92 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use axum::{
Router,
body::{Body, to_bytes},
http::{Request, StatusCode},
middleware,
routing::get,
};
use crank_observability::{
MetricsConfig, ObservabilityConfig, RedactionLimits, ServiceIdentity, record_http_request,
};
use tower::ServiceExt;
#[tokio::test]
async fn http_metrics_use_matched_routes_and_closed_labels() {
let identity =
ServiceIdentity::try_new("metrics-test", "0.3.1", "test").expect("valid identity");
let lifecycle = crank_observability::init(ObservabilityConfig::new(
identity,
"off",
RedactionLimits::default(),
))
.expect("observability lifecycle");
let config = MetricsConfig::new(
true,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9464),
None,
)
.expect("loopback metrics");
let metrics = lifecycle.metrics_surface(config).router();
let app = Router::new()
.route(
"/documents/{document_id}",
get(|| async { StatusCode::NO_CONTENT }),
)
.layer(middleware::from_fn(record_http_request));
let sensitive_path_segment = "customer-secret-document-id";
for index in 0..100 {
let response = app
.clone()
.oneshot(
Request::get(format!("/documents/{sensitive_path_segment}-{index}"))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
let response = app
.oneshot(
Request::get("/unknown/customer-controlled-path")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = metrics
.oneshot(
Request::get("/metrics")
.body(Body::empty())
.expect("request"),
)
.await
.expect("metrics response");
let body = to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("bounded metrics body");
let body = String::from_utf8(body.to_vec()).expect("utf-8 metrics");
assert!(body.contains("crank_http_requests_total"));
assert!(body.contains("route=\"/documents/{document_id}\""));
assert!(body.contains("method=\"GET\""));
assert!(body.contains("status_class=\"2xx\""));
assert!(body.contains("crank_http_request_duration_seconds_bucket"));
assert!(!body.contains(sensitive_path_segment));
assert_eq!(
body.lines()
.filter(|line| {
line.starts_with("crank_http_requests_total{")
&& line.contains("route=\"/documents/{document_id}\"")
})
.count(),
1,
"different entity ids must not create additional series"
);
}
@@ -0,0 +1,15 @@
use crank_observability::{
OperationalIncident, operational_incident_total, record_operational_incident,
};
#[test]
fn history_loss_counter_has_no_dynamic_dimensions() {
let before = operational_incident_total(OperationalIncident::InvocationHistoryLost);
record_operational_incident(OperationalIncident::InvocationHistoryLost);
assert!(
operational_incident_total(OperationalIncident::InvocationHistoryLost) > before,
"the closed incident counter must increase"
);
}
@@ -0,0 +1,392 @@
use std::{
io,
sync::{Arc, Mutex},
};
use crank_observability::{
ObservabilityConfig, RedactionLimits, ServiceIdentity, build_subscriber, safe_json,
};
use serde_json::Value;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone, Default)]
struct SharedWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl SharedWriter {
fn output(&self) -> String {
String::from_utf8(self.buffer.lock().expect("test writer lock").clone())
.expect("log output must be UTF-8")
}
}
impl<'a> MakeWriter<'a> for SharedWriter {
type Writer = SharedWriterGuard;
fn make_writer(&'a self) -> Self::Writer {
SharedWriterGuard {
buffer: Arc::clone(&self.buffer),
}
}
}
struct SharedWriterGuard {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl io::Write for SharedWriterGuard {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.buffer
.lock()
.map_err(|_| io::Error::other("test writer lock poisoned"))?
.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn capture(service: &'static str, emit: impl FnOnce()) -> Vec<Value> {
capture_with_limits(service, RedactionLimits::default(), emit)
}
fn capture_with_limits(
service: &'static str,
limits: RedactionLimits,
emit: impl FnOnce(),
) -> Vec<Value> {
let writer = SharedWriter::default();
let config = ObservabilityConfig::new(
ServiceIdentity::try_new(service, "0.3.1", "test").expect("valid test identity"),
"info",
limits,
);
let subscriber =
build_subscriber(config, writer.clone()).expect("test subscriber must be built");
tracing::subscriber::with_default(subscriber, emit);
writer
.output()
.lines()
.map(|line| {
assert!(!line.contains('\u{1b}'), "ANSI is forbidden: {line}");
serde_json::from_str(line).expect("every line must be one JSON object")
})
.collect()
}
#[test]
fn schema_contract_is_identical_for_both_services() {
let outputs = ["admin-api", "mcp-server"].map(|service| {
capture(service, || {
tracing::info!(
name: "service.started",
target: "crank::startup",
port = 3101_u64,
"service started"
);
})
});
for (service, events) in ["admin-api", "mcp-server"].into_iter().zip(outputs.iter()) {
assert_eq!(events.len(), 1);
let event = &events[0];
assert_eq!(event["service"], service);
assert_eq!(event["version"], "0.3.1");
assert_eq!(event["environment"], "test");
assert_eq!(event["level"], "INFO");
assert_eq!(event["target"], "crank::startup");
assert_eq!(event["event"], "service.started");
assert!(event["fields"].is_object());
assert_eq!(event["fields"]["port"], 3101);
assert_eq!(event["fields"]["message"], "service started");
let timestamp = event["timestamp"].as_str().expect("timestamp string");
let parsed =
OffsetDateTime::parse(timestamp, &Rfc3339).expect("timestamp must be RFC 3339");
assert_eq!(parsed.offset(), time::UtcOffset::UTC);
}
let first_keys: Vec<_> = outputs[0][0]
.as_object()
.expect("object")
.keys()
.cloned()
.collect();
let second_keys: Vec<_> = outputs[1][0]
.as_object()
.expect("object")
.keys()
.cloned()
.collect();
assert_eq!(first_keys, second_keys);
}
#[test]
fn correlation_fields_are_distinct_and_only_present_when_recorded() {
let present = capture("admin-api", || {
tracing::info!(
name: "admin.request.completed",
request_id = "req-123",
trace_id = "trace-456"
);
});
assert_eq!(present[0]["request_id"], "req-123");
assert_eq!(present[0]["trace_id"], "trace-456");
assert!(present[0]["fields"].get("request_id").is_none());
assert!(present[0]["fields"].get("trace_id").is_none());
let absent = capture("mcp-server", || {
tracing::info!(name: "mcp.request.completed", status = 200_u64);
});
assert!(absent[0].get("request_id").is_none());
assert!(absent[0].get("trace_id").is_none());
}
#[test]
fn correlation_fields_preserve_scalar_display_values_before_field_limits() {
let limits = RedactionLimits {
max_object_fields: 1,
..RedactionLimits::default()
};
let request_id = "123";
let events = capture_with_limits("admin-api", limits, || {
tracing::info!(
name: "admin.request.completed",
alpha = "field that consumes the object budget",
request_id = %request_id,
trace_id = true,
);
});
assert_eq!(events[0]["request_id"], "123");
assert_eq!(events[0]["trace_id"], "true");
}
#[test]
fn empty_correlation_fields_are_omitted() {
let events = capture("admin-api", || {
tracing::info!(
name: "admin.request.completed",
request_id = "",
trace_id = ""
);
});
assert!(events[0].get("request_id").is_none());
assert!(events[0].get("trace_id").is_none());
}
#[test]
fn formatter_redacts_fields_before_serialization() {
let context = safe_json(
&serde_json::json!({
"nested": {
"access_token": "nested-canary-secret",
"endpoint": "https://example.test/private?key=nested-canary-secret"
}
}),
RedactionLimits::default(),
)
.expect("safe nested context");
let events = capture("admin-api", || {
tracing::warn!(
name: "admin.request.rejected",
password = "canary-secret",
url = "https://example.test/path?token=canary-secret",
safe_fields = %context,
unsafe_context = ?serde_json::json!({"password": "debug-canary-secret"}),
error_code = "invalid_request"
);
});
let serialized = serde_json::to_string(&events[0]).expect("event JSON");
assert_eq!(events[0]["fields"]["password"], "[REDACTED]");
assert_eq!(events[0]["fields"]["url"], "https://example.test/path");
assert_eq!(
events[0]["fields"]["safe_fields"]["nested"]["access_token"],
"[REDACTED]"
);
assert_eq!(
events[0]["fields"]["safe_fields"]["nested"]["endpoint"],
"https://example.test/private"
);
assert_eq!(events[0]["fields"]["unsafe_context"], "[REDACTED]");
assert_eq!(events[0]["fields"]["error_code"], "invalid_request");
assert!(!serialized.contains("canary-secret"));
}
#[test]
fn arbitrary_debug_text_is_never_written_verbatim() {
struct Credentials;
impl std::fmt::Debug for Credentials {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("Credentials { password: \"debug-canary-secret\" }")
}
}
let events = capture("admin-api", || {
tracing::warn!(
name: "admin.debug.inspected",
details = ?Credentials
);
});
let serialized = serde_json::to_string(&events[0]).expect("event JSON");
assert_eq!(events[0]["fields"]["details"], "[REDACTED]");
assert!(!serialized.contains("debug-canary-secret"));
}
#[test]
fn compound_sensitive_event_fields_are_redacted() {
let events = capture("admin-api", || {
tracing::warn!(
name: "admin.request.rejected",
client_api_key = "client-canary-secret",
authorization_header = "Bearer auth-canary-secret",
response_body = "response-canary-secret",
tool_arguments = "argument-canary-secret",
query_params = "query-canary-secret",
);
});
let serialized = serde_json::to_string(&events[0]).expect("event JSON");
for key in [
"client_api_key",
"authorization_header",
"response_body",
"tool_arguments",
"query_params",
] {
assert_eq!(events[0]["fields"][key], "[REDACTED]");
}
assert!(!serialized.contains("canary-secret"));
}
#[test]
fn oversized_event_falls_back_to_valid_bounded_json() {
let limits = RedactionLimits {
max_event_bytes: 512,
..RedactionLimits::default()
};
let events = capture_with_limits("admin-api", limits, || {
tracing::info!(
name: "admin.payload.inspected",
description = %"x".repeat(1024)
);
});
let serialized = serde_json::to_vec(&events[0]).expect("bounded event JSON");
assert!(serialized.len() < limits.max_event_bytes);
assert_eq!(events[0]["fields"]["truncated"], true);
}
#[test]
fn safe_json_honours_the_total_event_budget() {
let limits = RedactionLimits {
max_event_bytes: 512,
..RedactionLimits::default()
};
let serialized = safe_json(
&serde_json::json!({"description": "x".repeat(4096)}),
limits,
)
.expect("safe JSON must remain serializable");
assert!(serialized.len() <= limits.max_event_bytes);
assert_eq!(
serde_json::from_str::<Value>(&serialized).expect("valid JSON")["truncated"],
true
);
}
#[test]
fn subscriber_rejects_limits_that_cannot_hold_an_event() {
let config = ObservabilityConfig::new(
ServiceIdentity::try_new("admin-api", "0.3.1", "test").expect("valid identity"),
"info",
RedactionLimits {
max_event_bytes: 16,
..RedactionLimits::default()
},
);
assert!(build_subscriber(config, SharedWriter::default()).is_err());
}
#[test]
fn minimum_event_budget_handles_maximum_identity_labels() {
let writer = SharedWriter::default();
let config = ObservabilityConfig::new(
ServiceIdentity::try_new("s".repeat(64), "v".repeat(64), "e".repeat(64))
.expect("maximum identity labels are valid"),
"info",
RedactionLimits {
max_event_bytes: 512,
..RedactionLimits::default()
},
);
let subscriber =
build_subscriber(config, writer.clone()).expect("minimum valid budget must be usable");
tracing::subscriber::with_default(subscriber, || {
tracing::info!(
name: "event-name-that-is-intentionally-longer-than-the-fallback-limit",
description = %"x".repeat(4096),
);
});
let output = writer.output();
assert!(output.len() <= 512);
assert_eq!(output.lines().count(), 1);
serde_json::from_str::<Value>(output.trim_end()).expect("bounded line must remain valid JSON");
}
#[test]
fn env_filter_is_applied_and_invalid_filter_is_safe() {
let writer = SharedWriter::default();
let config = ObservabilityConfig::new(
ServiceIdentity::try_new("admin-api", "0.3.1", "test").expect("valid identity"),
"warn",
RedactionLimits::default(),
);
let subscriber =
build_subscriber(config, writer.clone()).expect("test subscriber must be built");
tracing::subscriber::with_default(subscriber, || {
tracing::info!(name: "filtered.info", "filtered");
tracing::warn!(name: "visible.warning", "visible");
});
let output = writer.output();
assert!(!output.contains("filtered.info"));
assert!(output.contains("visible.warning"));
let invalid = ObservabilityConfig::new(
ServiceIdentity::try_new("admin-api", "0.3.1", "test").expect("valid identity"),
"[not a valid filter",
RedactionLimits::default(),
);
let error = build_subscriber(invalid, SharedWriter::default())
.err()
.expect("invalid filter must fail");
assert_eq!(error.to_string(), "invalid log filter");
assert!(!error.to_string().contains("not a valid filter"));
}
#[test]
fn service_identity_rejects_empty_or_unsafe_labels() {
for (service, version, environment) in [
("", "0.3.1", "test"),
("admin api", "0.3.1", "test"),
("admin-api", "", "test"),
("admin-api", "0.3.1", "prod\nsecret"),
] {
assert!(ServiceIdentity::try_new(service, version, environment).is_err());
}
}
@@ -0,0 +1,22 @@
use crank_observability::{
ObservabilityConfig, ObservabilityInitError, RedactionLimits, ServiceIdentity, init,
};
fn config() -> ObservabilityConfig {
ObservabilityConfig::new(
ServiceIdentity::try_new("lifecycle-test", "0.3.1", "test").expect("valid test identity"),
"info",
RedactionLimits::default(),
)
}
#[test]
fn repeated_global_initialization_returns_typed_error() {
let _lifecycle = init(config()).expect("first initialization must succeed");
let error = init(config()).expect_err("second initialization must fail");
assert!(matches!(
error,
ObservabilityInitError::SubscriberAlreadyInitialized
));
}
+56
View File
@@ -0,0 +1,56 @@
use std::time::Duration;
use crank_observability::{OtlpBatchConfig, OtlpTraceConfig, OtlpTraceConfigError};
#[test]
fn absent_endpoint_disables_export_without_background_resources() {
let config = OtlpTraceConfig::try_new(
None,
None,
Duration::from_secs(10),
OtlpBatchConfig::default(),
)
.expect("missing endpoint must be valid");
assert!(!config.is_enabled());
}
#[test]
fn explicit_config_accepts_only_bounded_http_protobuf() {
let config = OtlpTraceConfig::try_new(
Some("https://collector.example.test/v1/traces".to_owned()),
Some("http/protobuf".to_owned()),
Duration::from_secs(3),
OtlpBatchConfig::try_new(256, 64, Duration::from_millis(500), Duration::from_secs(3))
.unwrap(),
)
.expect("bounded HTTP protobuf config must be valid");
assert!(config.is_enabled());
assert_eq!(config.export_timeout(), Duration::from_secs(3));
assert_eq!(config.batch().max_queue_size(), 256);
assert_eq!(config.batch().max_export_batch_size(), 64);
}
#[test]
fn invalid_values_return_safe_typed_errors() {
let secret_endpoint = "https://user:canary-secret@collector.example.test/v1/traces";
let error = OtlpTraceConfig::try_new(
Some(secret_endpoint.to_owned()),
Some("grpc".to_owned()),
Duration::ZERO,
OtlpBatchConfig::default(),
)
.expect_err("credentials in endpoint must be rejected");
assert!(matches!(
error,
OtlpTraceConfigError::InvalidEndpoint { .. }
));
assert!(!error.to_string().contains(secret_endpoint));
assert!(!error.to_string().contains("canary-secret"));
let error = OtlpBatchConfig::try_new(8, 9, Duration::from_millis(1), Duration::from_secs(1))
.expect_err("batch cannot exceed queue");
assert!(matches!(error, OtlpTraceConfigError::InvalidBatchLimits));
}
@@ -0,0 +1,134 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use crank_observability::{
DURATION_BUCKETS_SECONDS, MetricsConfig, MetricsConfigError, MetricsSurface, ServiceIdentity,
metric_schema,
};
use tower::ServiceExt;
fn identity() -> ServiceIdentity {
ServiceIdentity::try_new("admin-api", "0.3.1", "test").expect("valid identity")
}
#[test]
fn loopback_is_allowed_without_a_token() {
let config = MetricsConfig::new(
true,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9464),
None,
)
.expect("loopback metrics must be safe by default");
assert_eq!(config.bind_addr().to_string(), "127.0.0.1:9464");
assert!(!config.requires_authentication());
}
#[test]
fn non_loopback_without_a_token_is_rejected_without_secret_data() {
let error = MetricsConfig::new(
true,
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 9464),
None,
)
.expect_err("external metrics must require authentication");
assert!(matches!(
error,
MetricsConfigError::MissingTokenForExternalBind
));
assert!(!error.to_string().contains("token="));
}
#[test]
fn schema_is_closed_and_uses_fixed_duration_buckets() {
let schema = metric_schema();
let names: Vec<_> = schema.iter().map(|metric| metric.name).collect();
assert!(names.contains(&"crank_http_requests_total"));
assert!(names.contains(&"crank_http_request_duration_seconds"));
assert!(names.contains(&"crank_mcp_requests_total"));
assert!(names.contains(&"crank_tool_invocations_total"));
assert!(names.contains(&"crank_runtime_inflight"));
assert!(names.contains(&"crank_db_pool_connections"));
assert!(names.contains(&"crank_catalog_tools"));
assert!(names.contains(&"crank_invocation_history_lost_total"));
assert!(names.contains(&"crank_telemetry_export_failures_total"));
for metric in schema {
for forbidden in [
"workspace",
"agent_id",
"operation_id",
"request_id",
"url",
"error_message",
"text",
] {
assert!(
!metric.labels.contains(&forbidden),
"{} exposes forbidden label {forbidden}",
metric.name
);
}
}
assert_eq!(
DURATION_BUCKETS_SECONDS,
&[
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0
]
);
}
#[tokio::test]
async fn external_surface_protects_both_routes_and_exposes_nothing_else() {
let token = "metrics-canary-secret";
let config = MetricsConfig::new(
true,
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 9464),
Some(token.to_owned()),
)
.expect("external metrics with token");
let surface = MetricsSurface::for_test(config, identity()).expect("test metrics surface");
let app = surface.router();
for path in ["/metrics", "/health"] {
let unauthorized = app
.clone()
.oneshot(Request::get(path).body(Body::empty()).expect("request"))
.await
.expect("response");
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let authorized = app
.clone()
.oneshot(
Request::get(path)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(authorized.status(), StatusCode::OK);
let body = to_bytes(authorized.into_body(), 1024 * 1024)
.await
.expect("bounded body");
assert!(!String::from_utf8_lossy(&body).contains(token));
}
let absent = app
.oneshot(
Request::get("/api/operations")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(absent.status(), StatusCode::NOT_FOUND);
}
@@ -0,0 +1,65 @@
use axum::http::{HeaderMap, HeaderValue};
use crank_observability::{inject_current_trace_context, set_remote_trace_parent};
use opentelemetry::{global, trace::TracerProvider as _};
use opentelemetry_sdk::{propagation::TraceContextPropagator, trace::SdkTracerProvider};
use tracing::info_span;
use tracing_subscriber::layer::SubscriberExt;
const REMOTE_TRACE_ID: &str = "0af7651916cd43dd8448eb211c80319c";
#[test]
fn valid_remote_parent_is_continued_and_request_id_is_unrelated() {
with_trace_dispatch(|| {
let mut incoming = HeaderMap::new();
incoming.insert(
"traceparent",
HeaderValue::from_static("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"),
);
incoming.insert("x-request-id", HeaderValue::from_static("req-unrelated"));
let span = info_span!("http.request", request_id = "req-unrelated");
assert!(set_remote_trace_parent(&span, &incoming));
let _guard = span.enter();
let mut outgoing = HeaderMap::new();
assert!(inject_current_trace_context(&mut outgoing));
let propagated = outgoing["traceparent"].to_str().unwrap();
assert_eq!(&propagated[3..35], REMOTE_TRACE_ID);
assert!(!propagated.contains("req-unrelated"));
assert!(!outgoing.contains_key("baggage"));
});
}
#[test]
fn invalid_parent_is_ignored_and_a_new_trace_is_created() {
with_trace_dispatch(|| {
let mut incoming = HeaderMap::new();
incoming.insert(
"traceparent",
HeaderValue::from_static("canary-invalid-traceparent"),
);
let span = info_span!("mcp.request");
assert!(!set_remote_trace_parent(&span, &incoming));
let _guard = span.enter();
let mut outgoing = HeaderMap::new();
assert!(inject_current_trace_context(&mut outgoing));
let propagated = outgoing["traceparent"].to_str().unwrap();
assert!(propagated.starts_with("00-"));
assert!(!propagated.contains("canary-invalid-traceparent"));
});
}
fn with_trace_dispatch(test: impl FnOnce()) {
global::set_text_map_propagator(TraceContextPropagator::new());
let provider = SdkTracerProvider::builder().build();
let tracer = provider.tracer("propagation-test");
let subscriber =
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
let dispatch = tracing::Dispatch::new(subscriber);
let _guard = tracing::dispatcher::set_default(&dispatch);
test();
provider.shutdown().unwrap();
}
@@ -0,0 +1,206 @@
use crank_observability::{RedactionLimits, redact_value};
use serde_json::{Value, json};
const REDACTED: &str = "[REDACTED]";
const TRUNCATED: &str = "[TRUNCATED]";
#[test]
fn sensitive_keys_are_redacted_case_insensitively() {
let keys = [
"password",
"PassWord",
"api_key",
"access-token",
"authorization",
"Proxy.Authorization",
"cookie",
"set_cookie",
"payload",
"request_body",
"arguments",
"result",
"response",
];
for key in keys {
let cleaned = redact_value(&json!({ key: "canary-secret" }), RedactionLimits::default());
assert_eq!(cleaned[key], REDACTED, "key {key} was not redacted");
assert!(!cleaned.to_string().contains("canary-secret"));
}
}
#[test]
fn generated_sensitive_key_variants_are_redacted() {
for canonical in [
"password",
"api_key",
"access_key",
"secret_key",
"proxy_authorization",
"set_cookie",
"query_string",
"request_body",
] {
for separator in ["_", "-", "."] {
let variant = canonical
.split('_')
.collect::<Vec<_>>()
.join(separator)
.to_ascii_uppercase();
let cleaned = redact_value(
&json!({ variant.clone(): "canary-secret" }),
RedactionLimits::default(),
);
assert_eq!(
cleaned[&variant], REDACTED,
"key {variant} was not redacted"
);
assert!(!cleaned.to_string().contains("canary-secret"));
}
}
}
#[test]
fn compound_sensitive_key_names_are_redacted() {
for key in [
"client_api_key",
"aws_access_key_id",
"http_authorization_header",
"response_body",
"tool_arguments",
"query_params",
"secret_value",
] {
let cleaned = redact_value(&json!({ key: "canary-secret" }), RedactionLimits::default());
assert_eq!(cleaned[key], REDACTED, "key {key} was not redacted");
assert!(!cleaned.to_string().contains("canary-secret"));
}
}
#[test]
fn url_query_and_fragment_are_removed_at_every_depth() {
let input = json!({
"url": "https://example.test/path?token=canary-secret#fragment",
"nested": [{
"endpoint_uri": "https://example.test/other?q=canary-secret"
}]
});
let cleaned = redact_value(&input, RedactionLimits::default());
assert_eq!(cleaned["url"], "https://example.test/path");
assert_eq!(
cleaned["nested"][0]["endpoint_uri"],
"https://example.test/other"
);
assert!(!cleaned.to_string().contains("canary-secret"));
}
#[test]
fn url_credentials_and_compound_endpoint_fields_are_removed() {
let input = json!({
"upstream_endpoint": "https://user:canary-secret@example.test/path?token=canary-secret",
});
let cleaned = redact_value(&input, RedactionLimits::default());
assert_eq!(cleaned["upstream_endpoint"], "https://example.test/path");
assert!(!cleaned.to_string().contains("user"));
assert!(!cleaned.to_string().contains("canary-secret"));
}
#[test]
fn nested_values_and_collections_respect_all_limits() {
let limits = RedactionLimits {
max_string_bytes: 16,
max_array_items: 3,
max_object_fields: 3,
max_depth: 2,
max_event_bytes: 256,
};
let input = json!({
"long": "абвгдежзийклмнопрсту",
"array": [1, 2, 3, 4, 5],
"object": {"a": 1, "b": 2, "c": 3, "d": 4},
"nested": {"level2": {"level3": "must not survive"}}
});
let cleaned = redact_value(&input, limits);
let object = cleaned
.as_object()
.expect("cleaned root must remain an object");
assert!(object.len() <= limits.max_object_fields);
assert!(
cleaned["long"]
.as_str()
.map(|value| value.len() <= limits.max_string_bytes)
.unwrap_or(true)
);
assert!(
cleaned["array"]
.as_array()
.map(|value| value.len() <= limits.max_array_items)
.unwrap_or(true)
);
assert!(!cleaned.to_string().contains("must not survive"));
assert!(cleaned.to_string().contains(TRUNCATED));
}
#[test]
fn truncation_preserves_utf8_and_never_reveals_secret_fragments() {
let input = json!({
"secret_key": "секретное-значение",
"description": "я".repeat(2048),
});
let cleaned = redact_value(&input, RedactionLimits::default());
let serialized = serde_json::to_string(&cleaned).expect("cleaned value must be valid JSON");
assert_eq!(cleaned["secret_key"], REDACTED);
assert!(!serialized.contains("секретное"));
assert!(cleaned["description"].as_str().is_some());
}
#[test]
fn redacted_value_does_not_mutate_input() {
let input = json!({"password": "canary-secret"});
let original = input.clone();
let _ = redact_value(&input, RedactionLimits::default());
assert_eq!(input, original);
}
#[test]
fn object_keys_respect_the_string_limit() {
let limits = RedactionLimits {
max_string_bytes: 16,
..RedactionLimits::default()
};
let long_key = format!("field-{}", "x".repeat(128));
let cleaned = redact_value(&json!({ long_key: "value" }), limits);
assert!(
cleaned
.as_object()
.expect("cleaned object")
.keys()
.all(|key| key.len() <= limits.max_string_bytes)
);
}
#[test]
fn limits_have_finite_safe_defaults() {
let limits = RedactionLimits::default();
assert_eq!(limits.max_string_bytes, 1024);
assert_eq!(limits.max_array_items, 32);
assert_eq!(limits.max_object_fields, 64);
assert_eq!(limits.max_depth, 8);
assert_eq!(limits.max_event_bytes, 16 * 1024);
assert!(Value::Null.is_null());
}