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 route pattern or `unknown`.
101    pub fn route(&self) -> &str {
102        &self.route
103    }
104
105    /// Returns the traceparent value when one is available.
106    pub fn traceparent(&self) -> Option<&str> {
107        self.traceparent.as_deref()
108    }
109
110    /// Returns the request id when one is available.
111    pub fn request_id(&self) -> Option<&str> {
112        self.request_id.as_deref()
113    }
114
115    /// Returns the trace id when one is available.
116    pub fn trace_id(&self) -> Option<&str> {
117        self.trace_id.as_deref()
118    }
119
120    /// Returns the span id when one is available.
121    pub fn span_id(&self) -> Option<&str> {
122        self.span_id.as_deref()
123    }
124
125    /// Converts the context into stable logging fields.
126    #[cfg(feature = "core")]
127    pub fn into_log_fields(self) -> crate::core::logging::LogFields {
128        use crate::core::logging::LogFields;
129
130        let mut fields = LogFields::new(self.service)
131            .with_transport(self.transport)
132            .with_route(self.route)
133            .with_method(self.method);
134        if let Some(request_id) = self.request_id {
135            fields = fields.with_request_id(request_id);
136        }
137        if let Some(trace_id) = self.trace_id {
138            fields = fields.with_trace_id(trace_id);
139        }
140        if let Some(span_id) = self.span_id {
141            fields = fields.with_span_id(span_id);
142        }
143        if let Some(status) = self.status {
144            fields = fields.with_status(status);
145        }
146        fields
147    }
148
149    /// Returns all populated fields as key/value pairs.
150    pub fn as_pairs(&self) -> Vec<(String, String)> {
151        let mut pairs = vec![("service".to_string(), self.service.clone())];
152        push_optional(&mut pairs, "transport", Some(self.transport));
153        push_optional(&mut pairs, "route", Some(&self.route));
154        push_optional(&mut pairs, "method", Some(&self.method));
155        push_optional(&mut pairs, "request_id", self.request_id.as_deref());
156        push_optional(&mut pairs, "trace_id", self.trace_id.as_deref());
157        push_optional(&mut pairs, "span_id", self.span_id.as_deref());
158        push_optional(&mut pairs, "status", self.status.as_deref());
159        pairs
160    }
161
162    fn with_request_id(mut self, request_id: Option<String>) -> Self {
163        self.request_id = request_id;
164        self
165    }
166
167    fn with_traceparent(mut self, traceparent: Option<String>) -> Self {
168        if let Some(value) = traceparent {
169            self.trace_id = trace_id_from_traceparent(&value).map(ToOwned::to_owned);
170            self.span_id = span_id_from_traceparent(&value).map(ToOwned::to_owned);
171            self.traceparent = Some(value);
172        }
173        self
174    }
175
176    fn with_current_span_context(mut self) -> Self {
177        if let Some(trace_id) = current_trace_id() {
178            self.trace_id = Some(trace_id);
179        }
180        if let Some(span_id) = current_span_id() {
181            self.span_id = Some(span_id);
182        }
183        self
184    }
185}
186
187fn push_optional(pairs: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
188    if let Some(value) = value
189        && !value.is_empty()
190    {
191        pairs.push((key.to_string(), value.to_string()));
192    }
193}