Skip to main content

shaperail_runtime/observability/
logging.rs

1use std::collections::HashSet;
2
3use shaperail_core::ResourceDefinition;
4use tracing_subscriber::fmt::format::FmtSpan;
5use tracing_subscriber::layer::SubscriberExt;
6use tracing_subscriber::util::SubscriberInitExt;
7use tracing_subscriber::EnvFilter;
8
9/// Initializes structured JSON logging via the `tracing` crate.
10///
11/// - Outputs structured JSON to stdout (one line per event).
12/// - Respects `RUST_LOG` env var for filtering (defaults to `info`).
13/// - Attaches `request_id` to every log line via span fields.
14pub fn init_logging() {
15    let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
16
17    tracing_subscriber::registry()
18        .with(env_filter)
19        .with(
20            tracing_subscriber::fmt::layer()
21                .json()
22                .with_target(true)
23                .with_thread_ids(false)
24                .with_span_events(FmtSpan::CLOSE)
25                .flatten_event(true),
26        )
27        .init();
28}
29
30/// Collects the set of field names marked `sensitive: true` across all resources.
31pub fn sensitive_fields(resources: &[ResourceDefinition]) -> HashSet<String> {
32    let mut fields = HashSet::new();
33    for resource in resources {
34        for (name, schema) in &resource.schema {
35            if schema.sensitive {
36                fields.insert(name.clone());
37            }
38        }
39    }
40    fields
41}
42
43/// Redacts sensitive fields from a JSON value in-place.
44///
45/// Any key matching a sensitive field name has its value replaced with `"[REDACTED]"`.
46pub fn redact_sensitive(
47    value: &serde_json::Value,
48    sensitive: &HashSet<String>,
49) -> serde_json::Value {
50    match value {
51        serde_json::Value::Object(map) => {
52            let mut redacted = serde_json::Map::new();
53            for (key, val) in map {
54                if sensitive.contains(key) {
55                    redacted.insert(
56                        key.clone(),
57                        serde_json::Value::String("[REDACTED]".to_string()),
58                    );
59                } else {
60                    redacted.insert(key.clone(), redact_sensitive(val, sensitive));
61                }
62            }
63            serde_json::Value::Object(redacted)
64        }
65        serde_json::Value::Array(arr) => {
66            serde_json::Value::Array(arr.iter().map(|v| redact_sensitive(v, sensitive)).collect())
67        }
68        other => other.clone(),
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn sensitive_fields_collected() {
78        use indexmap::IndexMap;
79        use shaperail_core::{FieldSchema, FieldType};
80
81        let mut schema = IndexMap::new();
82        schema.insert(
83            "email".to_string(),
84            FieldSchema {
85                field_type: FieldType::String,
86                primary: false,
87                generated: false,
88                required: true,
89                unique: false,
90                nullable: false,
91                reference: None,
92                min: None,
93                max: None,
94                format: None,
95                values: None,
96                default: None,
97                sensitive: true,
98                search: false,
99                items: None,
100            },
101        );
102        schema.insert(
103            "name".to_string(),
104            FieldSchema {
105                field_type: FieldType::String,
106                primary: false,
107                generated: false,
108                required: true,
109                unique: false,
110                nullable: false,
111                reference: None,
112                min: None,
113                max: None,
114                format: None,
115                values: None,
116                default: None,
117                sensitive: false,
118                search: false,
119                items: None,
120            },
121        );
122
123        let resources = vec![ResourceDefinition {
124            resource: "users".to_string(),
125            version: 1,
126            db: None,
127            tenant_key: None,
128            schema,
129            endpoints: None,
130            relations: None,
131            indexes: None,
132        }];
133
134        let fields = sensitive_fields(&resources);
135        assert!(fields.contains("email"));
136        assert!(!fields.contains("name"));
137    }
138
139    #[test]
140    fn redact_sensitive_values() {
141        let mut sensitive = HashSet::new();
142        sensitive.insert("password".to_string());
143        sensitive.insert("ssn".to_string());
144
145        let value = serde_json::json!({
146            "name": "Alice",
147            "password": "secret123",
148            "ssn": "123-45-6789",
149            "nested": {
150                "password": "also_secret"
151            }
152        });
153
154        let redacted = redact_sensitive(&value, &sensitive);
155        assert_eq!(redacted["name"], "Alice");
156        assert_eq!(redacted["password"], "[REDACTED]");
157        assert_eq!(redacted["ssn"], "[REDACTED]");
158        assert_eq!(redacted["nested"]["password"], "[REDACTED]");
159    }
160
161    #[test]
162    fn redact_handles_arrays() {
163        let mut sensitive = HashSet::new();
164        sensitive.insert("secret".to_string());
165
166        let value = serde_json::json!([
167            {"secret": "a", "public": "b"},
168            {"secret": "c", "public": "d"},
169        ]);
170
171        let redacted = redact_sensitive(&value, &sensitive);
172        let arr = redacted.as_array().unwrap();
173        assert_eq!(arr[0]["secret"], "[REDACTED]");
174        assert_eq!(arr[0]["public"], "b");
175        assert_eq!(arr[1]["secret"], "[REDACTED]");
176    }
177}