Skip to main content

rs_zero/observability/
correlation.rs

1use http::HeaderMap;
2
3use crate::observability::{
4    current_span_id, current_trace_id, request_id_from_headers, span_id_from_traceparent,
5    trace_id_from_traceparent, traceparent_from_headers,
6};
7
8/// Stable, low-cardinality correlation fields for logs, spans, and metrics.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct CorrelationContext {
11    service: String,
12    transport: &'static str,
13    route: String,
14    method: String,
15    request_id: Option<String>,
16    traceparent: Option<String>,
17    trace_id: Option<String>,
18    span_id: Option<String>,
19    status: Option<String>,
20}
21
22impl CorrelationContext {
23    /// Builds HTTP correlation from headers and a route pattern.
24    ///
25    /// `route` must be a framework route pattern such as `/users/:id`; raw
26    /// paths are intentionally not accepted to avoid high-cardinality logs.
27    pub fn from_http_headers(
28        service: Option<&str>,
29        method: impl Into<String>,
30        route: Option<&str>,
31        headers: &HeaderMap,
32    ) -> Self {
33        let traceparent = traceparent_from_headers(headers);
34        Self::new(
35            service.unwrap_or("unknown"),
36            "http",
37            route.unwrap_or("unknown"),
38            method,
39        )
40        .with_request_id(request_id_from_headers(headers))
41        .with_traceparent(traceparent)
42        .with_current_span_context()
43    }
44
45    /// Builds gRPC correlation from tonic metadata and an RPC method pattern.
46    #[cfg(feature = "rpc")]
47    pub fn from_rpc_metadata(
48        service: impl Into<String>,
49        method: impl Into<String>,
50        metadata: &tonic::metadata::MetadataMap,
51    ) -> Self {
52        let method = method.into();
53        let traceparent = crate::observability::traceparent_from_metadata(metadata);
54        Self::new(service, "grpc", method.clone(), method)
55            .with_request_id(crate::observability::request_id_from_metadata(metadata))
56            .with_traceparent(traceparent)
57            .with_current_span_context()
58    }
59
60    /// Builds correlation from explicit RPC request and trace context.
61    pub fn from_rpc_parts(
62        service: impl Into<String>,
63        method: impl Into<String>,
64        request_id: Option<&str>,
65        traceparent: Option<&str>,
66    ) -> Self {
67        let method = method.into();
68        Self::new(service, "grpc", method.clone(), method)
69            .with_request_id(request_id.map(ToOwned::to_owned))
70            .with_traceparent(traceparent.map(ToOwned::to_owned))
71            .with_current_span_context()
72    }
73
74    /// Creates a low-cardinality context from explicit parts.
75    pub fn new(
76        service: impl Into<String>,
77        transport: &'static str,
78        route: impl Into<String>,
79        method: impl Into<String>,
80    ) -> Self {
81        Self {
82            service: service.into(),
83            transport,
84            route: route.into(),
85            method: method.into(),
86            request_id: None,
87            traceparent: None,
88            trace_id: None,
89            span_id: None,
90            status: None,
91        }
92    }
93
94    /// Sets status or code.
95    pub fn with_status(mut self, status: impl Into<String>) -> Self {
96        self.status = Some(status.into());
97        self
98    }
99
100    /// Returns the service name.
101    pub fn service(&self) -> &str {
102        &self.service
103    }
104
105    /// Returns the transport name.
106    pub fn transport(&self) -> &'static str {
107        self.transport
108    }
109
110    /// Returns the route pattern or `unknown`.
111    pub fn route(&self) -> &str {
112        &self.route
113    }
114
115    /// Returns the HTTP or RPC method.
116    pub fn method(&self) -> &str {
117        &self.method
118    }
119
120    /// Returns the traceparent value when one is available.
121    pub fn traceparent(&self) -> Option<&str> {
122        self.traceparent.as_deref()
123    }
124
125    /// Returns the request id when one is available.
126    pub fn request_id(&self) -> Option<&str> {
127        self.request_id.as_deref()
128    }
129
130    /// Returns the trace id when one is available.
131    pub fn trace_id(&self) -> Option<&str> {
132        self.trace_id.as_deref()
133    }
134
135    /// Returns the span id when one is available.
136    pub fn span_id(&self) -> Option<&str> {
137        self.span_id.as_deref()
138    }
139
140    /// Converts the context into stable logging fields.
141    #[cfg(feature = "core")]
142    pub fn into_log_fields(self) -> crate::core::logging::LogFields {
143        use crate::core::logging::LogFields;
144
145        let mut fields = LogFields::new(self.service)
146            .with_transport(self.transport)
147            .with_route(self.route)
148            .with_method(self.method);
149        if let Some(request_id) = self.request_id {
150            fields = fields.with_request_id(request_id);
151        }
152        if let Some(trace_id) = self.trace_id {
153            fields = fields.with_trace_id(trace_id);
154        }
155        if let Some(span_id) = self.span_id {
156            fields = fields.with_span_id(span_id);
157        }
158        if let Some(status) = self.status {
159            fields = fields.with_status(status);
160        }
161        fields
162    }
163
164    /// Returns all populated fields as key/value pairs.
165    pub fn as_pairs(&self) -> Vec<(String, String)> {
166        let mut pairs = vec![("service".to_string(), self.service.clone())];
167        push_optional(&mut pairs, "transport", Some(self.transport));
168        push_optional(&mut pairs, "route", Some(&self.route));
169        push_optional(&mut pairs, "method", Some(&self.method));
170        push_optional(&mut pairs, "request_id", self.request_id.as_deref());
171        push_optional(&mut pairs, "trace_id", self.trace_id.as_deref());
172        push_optional(&mut pairs, "span_id", self.span_id.as_deref());
173        push_optional(&mut pairs, "status", self.status.as_deref());
174        pairs
175    }
176
177    fn with_request_id(mut self, request_id: Option<String>) -> Self {
178        self.request_id = request_id;
179        self
180    }
181
182    fn with_traceparent(mut self, traceparent: Option<String>) -> Self {
183        if let Some(value) = traceparent {
184            self.trace_id = trace_id_from_traceparent(&value).map(ToOwned::to_owned);
185            self.span_id = span_id_from_traceparent(&value).map(ToOwned::to_owned);
186            self.traceparent = Some(value);
187        }
188        self
189    }
190
191    fn with_current_span_context(mut self) -> Self {
192        if let Some(trace_id) = current_trace_id() {
193            self.trace_id = Some(trace_id);
194        }
195        if let Some(span_id) = current_span_id() {
196            self.span_id = Some(span_id);
197        }
198        self
199    }
200}
201
202fn push_optional(pairs: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
203    if let Some(value) = value
204        && !value.is_empty()
205    {
206        pairs.push((key.to_string(), value.to_string()));
207    }
208}