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 traceparent = crate::observability::traceparent_from_metadata(metadata);
53        Self::new(service, "grpc", "rpc", method)
54            .with_request_id(crate::observability::request_id_from_metadata(metadata))
55            .with_traceparent(traceparent)
56            .with_current_span_context()
57    }
58
59    /// Builds correlation from explicit RPC request and trace context.
60    pub fn from_rpc_parts(
61        service: impl Into<String>,
62        method: impl Into<String>,
63        request_id: Option<&str>,
64        traceparent: Option<&str>,
65    ) -> Self {
66        Self::new(service, "grpc", "rpc", method)
67            .with_request_id(request_id.map(ToOwned::to_owned))
68            .with_traceparent(traceparent.map(ToOwned::to_owned))
69            .with_current_span_context()
70    }
71
72    /// Creates a low-cardinality context from explicit parts.
73    pub fn new(
74        service: impl Into<String>,
75        transport: &'static str,
76        route: impl Into<String>,
77        method: impl Into<String>,
78    ) -> Self {
79        Self {
80            service: service.into(),
81            transport,
82            route: route.into(),
83            method: method.into(),
84            request_id: None,
85            traceparent: None,
86            trace_id: None,
87            span_id: None,
88            status: None,
89        }
90    }
91
92    /// Sets status or code.
93    pub fn with_status(mut self, status: impl Into<String>) -> Self {
94        self.status = Some(status.into());
95        self
96    }
97
98    /// Returns the route pattern or `unknown`.
99    pub fn route(&self) -> &str {
100        &self.route
101    }
102
103    /// Returns the traceparent value when one is available.
104    pub fn traceparent(&self) -> Option<&str> {
105        self.traceparent.as_deref()
106    }
107
108    /// Returns the request id when one is available.
109    pub fn request_id(&self) -> Option<&str> {
110        self.request_id.as_deref()
111    }
112
113    /// Returns the trace id when one is available.
114    pub fn trace_id(&self) -> Option<&str> {
115        self.trace_id.as_deref()
116    }
117
118    /// Returns the span id when one is available.
119    pub fn span_id(&self) -> Option<&str> {
120        self.span_id.as_deref()
121    }
122
123    /// Converts the context into stable logging fields.
124    #[cfg(feature = "core")]
125    pub fn into_log_fields(self) -> crate::core::logging::LogFields {
126        use crate::core::logging::LogFields;
127
128        let mut fields = LogFields::new(self.service)
129            .with_transport(self.transport)
130            .with_route(self.route)
131            .with_method(self.method);
132        if let Some(request_id) = self.request_id {
133            fields = fields.with_request_id(request_id);
134        }
135        if let Some(trace_id) = self.trace_id {
136            fields = fields.with_trace_id(trace_id);
137        }
138        if let Some(span_id) = self.span_id {
139            fields = fields.with_span_id(span_id);
140        }
141        if let Some(status) = self.status {
142            fields = fields.with_status(status);
143        }
144        fields
145    }
146
147    /// Returns all populated fields as key/value pairs.
148    pub fn as_pairs(&self) -> Vec<(String, String)> {
149        let mut pairs = vec![("service".to_string(), self.service.clone())];
150        push_optional(&mut pairs, "transport", Some(self.transport));
151        push_optional(&mut pairs, "route", Some(&self.route));
152        push_optional(&mut pairs, "method", Some(&self.method));
153        push_optional(&mut pairs, "request_id", self.request_id.as_deref());
154        push_optional(&mut pairs, "trace_id", self.trace_id.as_deref());
155        push_optional(&mut pairs, "span_id", self.span_id.as_deref());
156        push_optional(&mut pairs, "status", self.status.as_deref());
157        pairs
158    }
159
160    fn with_request_id(mut self, request_id: Option<String>) -> Self {
161        self.request_id = request_id;
162        self
163    }
164
165    fn with_traceparent(mut self, traceparent: Option<String>) -> Self {
166        if let Some(value) = traceparent {
167            self.trace_id = trace_id_from_traceparent(&value).map(ToOwned::to_owned);
168            self.span_id = span_id_from_traceparent(&value).map(ToOwned::to_owned);
169            self.traceparent = Some(value);
170        }
171        self
172    }
173
174    fn with_current_span_context(mut self) -> Self {
175        if let Some(trace_id) = current_trace_id() {
176            self.trace_id = Some(trace_id);
177        }
178        if let Some(span_id) = current_span_id() {
179            self.span_id = Some(span_id);
180        }
181        self
182    }
183}
184
185fn push_optional(pairs: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
186    if let Some(value) = value
187        && !value.is_empty()
188    {
189        pairs.push((key.to_string(), value.to_string()));
190    }
191}