#![cfg_attr(docsrs, feature(doc_cfg))]
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use logforth_core::Diagnostic;
use logforth_core::Error;
use logforth_core::kv::Key;
use logforth_core::kv::Value;
use logforth_core::kv::Visitor;
use logforth_core::layout::Layout;
use logforth_core::record::Record;
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct GoogleCloudLoggingLayout {
trace_project_id: Option<String>,
label_keys: BTreeSet<String>,
trace_keys: BTreeSet<String>,
span_id_keys: BTreeSet<String>,
trace_sampled_keys: BTreeSet<String>,
}
impl Default for GoogleCloudLoggingLayout {
fn default() -> Self {
Self {
trace_project_id: None,
label_keys: BTreeSet::new(),
trace_keys: BTreeSet::from(["trace_id".to_string()]),
span_id_keys: BTreeSet::from(["span_id".to_string()]),
trace_sampled_keys: BTreeSet::from([
"sampled".to_string(),
"trace_sampled".to_string(),
]),
}
}
}
impl GoogleCloudLoggingLayout {
pub fn trace_project_id(mut self, project_id: impl Into<String>) -> Self {
self.trace_project_id = Some(project_id.into());
self
}
pub fn label_keys(mut self, label_keys: impl IntoIterator<Item = impl Into<String>>) -> Self {
let label_keys = label_keys.into_iter().map(Into::into);
self.label_keys.extend(label_keys);
self
}
}
struct KvCollector<'a> {
layout: &'a GoogleCloudLoggingLayout,
payload_fields: BTreeMap<String, serde_json::Value>,
labels: BTreeMap<String, serde_json::Value>,
trace: Option<String>,
span_id: Option<String>,
trace_sampled: Option<bool>,
}
impl Visitor for KvCollector<'_> {
fn visit(&mut self, key: Key, value: Value) -> Result<(), Error> {
let key = key.as_str();
if let Some(trace_project_id) = self.layout.trace_project_id.as_ref() {
if self.trace.is_none() && self.layout.trace_keys.contains(key) {
self.trace = Some(format!("projects/{trace_project_id}/traces/{value}"));
return Ok(());
}
if self.span_id.is_none() && self.layout.span_id_keys.contains(key) {
self.span_id = Some(value.to_string());
return Ok(());
}
if self.trace_sampled.is_none() && self.layout.trace_sampled_keys.contains(key) {
self.trace_sampled = value.to_bool();
return Ok(());
}
}
let value = match serde_json::to_value(&value) {
Ok(value) => value,
Err(_) => value.to_string().into(),
};
if self.layout.label_keys.contains(key) {
self.labels.insert(key.to_owned(), value);
} else {
self.payload_fields.insert(key.to_owned(), value);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize)]
struct SourceLocation<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
file: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
function: Option<&'a str>,
}
#[derive(Debug, Clone, Serialize)]
struct RecordLine<'a> {
#[serde(flatten)]
extra_fields: BTreeMap<String, serde_json::Value>,
severity: &'a str,
timestamp: jiff::Timestamp,
message: &'a str,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
#[serde(rename = "logging.googleapis.com/labels")]
labels: BTreeMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "logging.googleapis.com/trace")]
trace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "logging.googleapis.com/spanId")]
span_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "logging.googleapis.com/trace_sampled")]
trace_sampled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "logging.googleapis.com/sourceLocation")]
source_location: Option<SourceLocation<'a>>,
}
impl Layout for GoogleCloudLoggingLayout {
fn format(&self, record: &Record, diags: &[Box<dyn Diagnostic>]) -> Result<Vec<u8>, Error> {
let timestamp = jiff::Timestamp::try_from(record.time()).unwrap();
let mut visitor = KvCollector {
layout: self,
payload_fields: BTreeMap::new(),
labels: BTreeMap::new(),
trace: None,
span_id: None,
trace_sampled: None,
};
record.key_values().visit(&mut visitor)?;
for d in diags {
d.visit(&mut visitor)?;
}
let record_line = RecordLine {
extra_fields: visitor.payload_fields,
timestamp,
severity: record.level().name(),
message: record.payload(),
labels: visitor.labels,
trace: visitor.trace,
span_id: visitor.span_id,
trace_sampled: visitor.trace_sampled,
source_location: Some(SourceLocation {
file: record.file(),
line: record.line(),
function: record.module_path(),
}),
};
Ok(serde_json::to_vec(&record_line).unwrap())
}
}