logforth_layout_google_cloud_logging/
lib.rs

1// Copyright 2024 FastLabs Developers
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Layout for Google Cloud Structured Logging.
16
17#![cfg_attr(docsrs, feature(doc_cfg))]
18
19use std::collections::BTreeMap;
20use std::collections::BTreeSet;
21
22use logforth_core::Diagnostic;
23use logforth_core::Error;
24use logforth_core::kv::Key;
25use logforth_core::kv::Value;
26use logforth_core::kv::Visitor;
27use logforth_core::layout::Layout;
28use logforth_core::record::Record;
29use serde::Serialize;
30
31/// A layout for Google Cloud Structured Logging.
32///
33/// See the [Google documentation](https://cloud.google.com/logging/docs/structured-logging) for more
34/// information about the structure of the format.
35///
36/// Example format:
37///
38/// ```json
39/// {"severity":"INFO","timestamp":"2025-04-02T10:34:33.225602Z","message":"Hello label value!","logging.googleapis.com/labels":{"label1":"this is a label value"},"logging.googleapis.com/trace":"projects/project-id/traces/612b91406b684ece2c4137ce0f3fd668", "logging.googleapis.com/sourceLocation":{"file":"examples/google_cloud_logging","line":64,"function":"main"}}
40/// ```
41///
42/// If the trace project ID is set, a few keys are treated specially:
43/// - `trace_id`: Combined with trace project ID, set as the `logging.googleapis.com/trace` field.
44/// - `span_id`: Set as the `logging.googleapis.com/spanId` field.
45/// - `trace_sampled`: Set as the `logging.googleapis.com/trace_sampled` field.
46///
47/// Information may be stored either in the payload, or as labels. The payload allows a structured
48/// value to be stored, but is not indexed by default. Labels are indexed by default, but can only
49/// store strings.
50///
51/// # Examples
52///
53/// ```
54/// use logforth_layout_google_cloud_logging::GoogleCloudLoggingLayout;
55///
56/// let layout = GoogleCloudLoggingLayout::default();
57/// ```
58#[derive(Debug, Clone)]
59pub struct GoogleCloudLoggingLayout {
60    trace_project_id: Option<String>,
61    label_keys: BTreeSet<String>,
62
63    // Heuristic keys to extract trace, spanId and traceSampled info from diagnostics.
64    // These are currently hardcoded but may be customizable in the future.
65    trace_keys: BTreeSet<String>,
66    span_id_keys: BTreeSet<String>,
67    trace_sampled_keys: BTreeSet<String>,
68}
69
70impl Default for GoogleCloudLoggingLayout {
71    fn default() -> Self {
72        Self {
73            trace_project_id: None,
74            label_keys: BTreeSet::new(),
75
76            trace_keys: BTreeSet::from(["trace_id".to_string()]),
77            span_id_keys: BTreeSet::from(["span_id".to_string()]),
78            trace_sampled_keys: BTreeSet::from([
79                "sampled".to_string(),
80                "trace_sampled".to_string(),
81            ]),
82        }
83    }
84}
85
86impl GoogleCloudLoggingLayout {
87    /// Set the trace project ID for traces.
88    ///
89    /// If set, the trace_id, span_id, and trace_sampled fields will be set in the log record, in
90    /// such a way that they can be linked to traces in the Google Cloud Trace service.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use logforth_layout_google_cloud_logging::GoogleCloudLoggingLayout;
96    ///
97    /// let layout = GoogleCloudLoggingLayout::default().trace_project_id("project-id");
98    /// ```
99    pub fn trace_project_id(mut self, project_id: impl Into<String>) -> Self {
100        self.trace_project_id = Some(project_id.into());
101        self
102    }
103
104    /// Extend the set of keys that should be treated as labels.
105    ///
106    /// Any key found in a log entry, and referenced here, will be stored in the labels field rather
107    /// than the payload. Labels are indexed by default, but can only store strings.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use logforth_layout_google_cloud_logging::GoogleCloudLoggingLayout;
113    ///
114    /// let layout = GoogleCloudLoggingLayout::default().label_keys(["label1", "label2"]);
115    /// ```
116    pub fn label_keys(mut self, label_keys: impl IntoIterator<Item = impl Into<String>>) -> Self {
117        let label_keys = label_keys.into_iter().map(Into::into);
118        self.label_keys.extend(label_keys);
119        self
120    }
121}
122
123struct KvCollector<'a> {
124    layout: &'a GoogleCloudLoggingLayout,
125
126    payload_fields: BTreeMap<String, serde_json::Value>,
127    labels: BTreeMap<String, serde_json::Value>,
128    trace: Option<String>,
129    span_id: Option<String>,
130    trace_sampled: Option<bool>,
131}
132
133impl Visitor for KvCollector<'_> {
134    fn visit(&mut self, key: Key, value: Value) -> Result<(), Error> {
135        let key = key.as_str();
136
137        if let Some(trace_project_id) = self.layout.trace_project_id.as_ref() {
138            if self.trace.is_none() && self.layout.trace_keys.contains(key) {
139                self.trace = Some(format!("projects/{trace_project_id}/traces/{value}"));
140                return Ok(());
141            }
142
143            if self.span_id.is_none() && self.layout.span_id_keys.contains(key) {
144                self.span_id = Some(value.to_string());
145                return Ok(());
146            }
147
148            if self.trace_sampled.is_none() && self.layout.trace_sampled_keys.contains(key) {
149                self.trace_sampled = value.to_bool();
150                return Ok(());
151            }
152        }
153
154        let value = match serde_json::to_value(&value) {
155            Ok(value) => value,
156            Err(_) => value.to_string().into(),
157        };
158
159        if self.layout.label_keys.contains(key) {
160            self.labels.insert(key.to_owned(), value);
161        } else {
162            self.payload_fields.insert(key.to_owned(), value);
163        }
164
165        Ok(())
166    }
167}
168
169#[derive(Debug, Clone, Serialize)]
170struct SourceLocation<'a> {
171    #[serde(skip_serializing_if = "Option::is_none")]
172    file: Option<&'a str>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    line: Option<u32>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    function: Option<&'a str>,
177}
178
179#[derive(Debug, Clone, Serialize)]
180struct RecordLine<'a> {
181    #[serde(flatten)]
182    extra_fields: BTreeMap<String, serde_json::Value>,
183    severity: &'a str,
184    timestamp: jiff::Timestamp,
185    message: &'a str,
186    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
187    #[serde(rename = "logging.googleapis.com/labels")]
188    labels: BTreeMap<String, serde_json::Value>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    #[serde(rename = "logging.googleapis.com/trace")]
191    trace: Option<String>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    #[serde(rename = "logging.googleapis.com/spanId")]
194    span_id: Option<String>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    #[serde(rename = "logging.googleapis.com/trace_sampled")]
197    trace_sampled: Option<bool>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    #[serde(rename = "logging.googleapis.com/sourceLocation")]
200    source_location: Option<SourceLocation<'a>>,
201}
202
203impl Layout for GoogleCloudLoggingLayout {
204    fn format(&self, record: &Record, diags: &[Box<dyn Diagnostic>]) -> Result<Vec<u8>, Error> {
205        // SAFETY: jiff::Timestamp::try_from only fails if the time is out of range, which is
206        // very unlikely if the system clock is correct.
207        let timestamp = jiff::Timestamp::try_from(record.time()).unwrap();
208
209        let mut visitor = KvCollector {
210            layout: self,
211            payload_fields: BTreeMap::new(),
212            labels: BTreeMap::new(),
213            trace: None,
214            span_id: None,
215            trace_sampled: None,
216        };
217
218        record.key_values().visit(&mut visitor)?;
219        for d in diags {
220            d.visit(&mut visitor)?;
221        }
222
223        let record_line = RecordLine {
224            extra_fields: visitor.payload_fields,
225            timestamp,
226            severity: record.level().as_str(),
227            message: record.payload(),
228            labels: visitor.labels,
229            trace: visitor.trace,
230            span_id: visitor.span_id,
231            trace_sampled: visitor.trace_sampled,
232            source_location: Some(SourceLocation {
233                file: record.file(),
234                line: record.line(),
235                function: record.module_path(),
236            }),
237        };
238
239        // SAFETY: RecordLine is serializable.
240        Ok(serde_json::to_vec(&record_line).unwrap())
241    }
242}