use crate::protobuf::Encoder;
use crate::resource::{
Attributes, Resource, Value, attributes_json, encode_attributes, scope, scope_json, unix_nanos,
};
use crate::trace::SpanContext;
use rustlavel_core::Json;
use rustlavel_core::events::Event;
use rustlavel_core::log::Level;
use std::time::SystemTime;
#[derive(Debug, Clone)]
pub struct LogSettings {
pub endpoint: String,
pub min_level: Level,
pub max_queue: usize,
}
#[derive(Debug, Clone)]
pub struct LogRecord {
pub time: SystemTime,
pub level: Level,
pub body: String,
pub attributes: Attributes,
pub trace: Option<SpanContext>,
}
impl LogRecord {
pub fn from_event(event: &Event, trace: Option<SpanContext>) -> Option<LogRecord> {
if event.kind != "log" {
return None;
}
let level = event
.field("level")
.and_then(Json::as_str)
.and_then(Level::parse)
.unwrap_or(Level::Info);
let body = event.field("message").and_then(Json::as_str).unwrap_or_default().to_string();
let attributes = event
.fields
.iter()
.filter(|(key, _)| key.as_str() != "level" && key.as_str() != "message")
.map(|(key, value)| (key.clone(), Value::from_json(value)))
.collect();
Some(LogRecord { time: event.at, level, body, attributes, trace })
}
fn encode(&self) -> Encoder {
let mut record = Encoder::new();
record.fixed64(1, unix_nanos(self.time));
record.enumeration(2, severity_number(self.level));
record.string(3, severity_text(self.level));
record.message(5, &Value::String(self.body.clone()).encode());
encode_attributes(&mut record, 6, &self.attributes);
if let Some(trace) = &self.trace {
record.fixed32(8, u32::from(trace.sampled));
record.bytes_field(9, trace.trace_id.as_bytes());
record.bytes_field(10, trace.span_id.as_bytes());
}
record.fixed64(11, unix_nanos(self.time));
record
}
fn to_json(&self) -> Json {
let mut fields = vec![
("timeUnixNano", Json::from(unix_nanos(self.time).to_string())),
("observedTimeUnixNano", Json::from(unix_nanos(self.time).to_string())),
("severityNumber", Json::Number(f64::from(severity_number(self.level)))),
("severityText", Json::from(severity_text(self.level))),
("body", Value::String(self.body.clone()).to_json()),
("attributes", attributes_json(&self.attributes)),
];
if let Some(trace) = &self.trace {
fields.push(("flags", Json::Number(f64::from(u32::from(trace.sampled)))));
fields.push(("traceId", Json::from(trace.trace_id.to_hex())));
fields.push(("spanId", Json::from(trace.span_id.to_hex())));
}
Json::object(fields)
}
}
fn severity_number(level: Level) -> i32 {
match level {
Level::Debug => 5,
Level::Info => 9,
Level::Warn => 13,
Level::Error => 17,
}
}
fn severity_text(level: Level) -> &'static str {
match level {
Level::Debug => "DEBUG",
Level::Info => "INFO",
Level::Warn => "WARN",
Level::Error => "ERROR",
}
}
pub fn export_request(resource: &Resource, records: &[LogRecord]) -> Vec<u8> {
let mut scope_logs = Encoder::new();
scope_logs.message(1, &scope());
for record in records {
scope_logs.message(2, &record.encode());
}
let mut resource_logs = Encoder::new();
resource_logs.message(1, &resource.encode());
resource_logs.message(2, &scope_logs);
let mut request = Encoder::new();
request.message(1, &resource_logs);
request.into_bytes()
}
pub fn export_request_json(resource: &Resource, records: &[LogRecord]) -> Json {
Json::object([(
"resourceLogs",
Json::Array(vec![Json::object([
("resource", resource.to_json()),
(
"scopeLogs",
Json::Array(vec![Json::object([
("scope", scope_json()),
("logRecords", Json::Array(records.iter().map(LogRecord::to_json).collect())),
])]),
),
])]),
)])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trace::{SpanContext, SpanId, TraceId};
fn event(level: &str, message: &str) -> Event {
Event::new("log").with("level", level).with("message", message)
}
#[test]
fn only_log_events_become_records() {
assert!(LogRecord::from_event(&Event::new("db.query"), None).is_none());
let record = LogRecord::from_event(&event("warn", "careful"), None).unwrap();
assert_eq!((record.level, record.body.as_str()), (Level::Warn, "careful"));
}
#[test]
fn extra_fields_become_attributes_and_level_and_message_do_not() {
let event = Event::new("log")
.with("level", "info")
.with("message", "m")
.with("user_id", 7)
.with("route", "/x");
let record = LogRecord::from_event(&event, None).unwrap();
let keys: Vec<&str> = record.attributes.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, vec!["route", "user_id"]);
}
#[test]
fn severity_follows_the_specification_bands() {
assert_eq!(
[Level::Debug, Level::Info, Level::Warn, Level::Error].map(severity_number),
[5, 9, 13, 17]
);
assert_eq!(severity_text(Level::Warn), "WARN");
}
#[test]
fn a_record_inside_a_trace_carries_its_ids_in_both_encodings() {
let context = SpanContext::root();
let record = LogRecord::from_event(&event("info", "in a request"), Some(context)).unwrap();
let json = export_request_json(&Resource::new("svc"), std::slice::from_ref(&record)).to_string();
assert!(json.contains(&format!("\"traceId\":\"{}\"", context.trace_id.to_hex())), "{json}");
assert!(json.contains(&format!("\"spanId\":\"{}\"", context.span_id.to_hex())), "{json}");
assert!(json.contains("\"severityText\":\"INFO\""));
assert!(json.contains("\"flags\":1"), "a sampled trace's records say so: {json}");
assert!(json.contains("observedTimeUnixNano"), "{json}");
assert!(json.contains("\"stringValue\":\"in a request\""));
let bytes = export_request(&Resource::new("svc"), &[record]);
let hay = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(hay(context.trace_id.as_bytes()) && hay(context.span_id.as_bytes()));
}
#[test]
fn a_record_outside_any_trace_has_no_ids() {
let record = LogRecord::from_event(&event("info", "boot"), None).unwrap();
let json = export_request_json(&Resource::new("svc"), &[record]).to_string();
assert!(!json.contains("traceId") && !json.contains("spanId"), "{json}");
}
#[test]
fn the_request_nests_resource_scope_records_in_that_order() {
let bytes = export_request(&Resource::new("svc"), &[]);
assert_eq!(bytes[0], 0x0a, "tag of field 1, length-delimited");
let _ = (TraceId::random(), SpanId::random());
}
}