ro11y 0.5.1

Lightweight Rust observability. Hand-rolled OTLP protobuf over HTTP, built on tracing.
Documentation
use crate::otlp_trace::{
    encode_any_value, encode_key_value, encode_resource, encode_scope, AnyValue, KeyValue,
};
use crate::proto::*;

// --- Data types ---

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u64)]
#[allow(dead_code)]
pub enum SeverityNumber {
    Trace = 1,
    Debug = 5,
    Info = 9,
    Warn = 13,
    Error = 17,
    Fatal = 21,
}

#[derive(Debug, Clone)]
pub struct LogData {
    pub time_unix_nano: u64,
    pub severity_number: SeverityNumber,
    pub severity_text: String,
    pub body: AnyValue,
    pub attributes: Vec<KeyValue>,
    pub trace_id: [u8; 16],
    pub span_id: [u8; 8],
}

// --- Encoding ---

/// Encode a LogRecord per OTLP proto field numbers:
/// time_unix_nano(1 fixed64), severity_number(2), severity_text(3),
/// body(5 AnyValue message), attributes(6 repeated), trace_id(9), span_id(10)
fn encode_log_record(buf: &mut Vec<u8>, log: &LogData) {
    encode_fixed64_field(buf, 1, log.time_unix_nano);
    encode_varint_field(buf, 2, log.severity_number as u64);
    encode_string_field(buf, 3, &log.severity_text);

    encode_message_field_in_place(buf, 5, |buf| {
        encode_any_value(buf, &log.body);
    });

    for kv in &log.attributes {
        encode_message_field_in_place(buf, 6, |buf| {
            encode_key_value(buf, kv);
        });
    }

    encode_bytes_field(buf, 9, &log.trace_id);
    encode_bytes_field(buf, 10, &log.span_id);
}

/// Encode a full ExportLogsServiceRequest.
///
/// Structure:
///   ExportLogsServiceRequest { resource_logs: \[ResourceLogs\] }
///     ResourceLogs { resource(1), scope_logs(2) }
///       ScopeLogs { scope(1), log_records(2) }
pub fn encode_export_logs_request(
    resource_attrs: &[KeyValue],
    scope_name: &str,
    scope_version: &str,
    logs: &[LogData],
) -> Vec<u8> {
    let mut request_buf = Vec::new();
    // ResourceLogs (field 1 of ExportLogsServiceRequest)
    encode_message_field_in_place(&mut request_buf, 1, |buf| {
        // Resource (field 1 of ResourceLogs)
        encode_message_field_in_place(buf, 1, |buf| {
            encode_resource(buf, resource_attrs);
        });
        // ScopeLogs (field 2 of ResourceLogs)
        encode_message_field_in_place(buf, 2, |buf| {
            // InstrumentationScope (field 1 of ScopeLogs)
            encode_message_field_in_place(buf, 1, |buf| {
                encode_scope(buf, scope_name, scope_version);
            });
            // LogRecords (field 2 of ScopeLogs, repeated)
            for log in logs {
                encode_message_field_in_place(buf, 2, |buf| {
                    encode_log_record(buf, log);
                });
            }
        });
    });
    request_buf
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_log() -> LogData {
        LogData {
            time_unix_nano: 1_000_000_000,
            severity_number: SeverityNumber::Info,
            severity_text: "INFO".to_string(),
            body: AnyValue::String("hello world".to_string()),
            attributes: vec![KeyValue {
                key: "service.name".to_string(),
                value: AnyValue::String("test-svc".to_string()),
            }],
            trace_id: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
            span_id: [1, 2, 3, 4, 5, 6, 7, 8],
        }
    }

    #[test]
    fn encode_log_record_contains_time() {
        let log = test_log();
        let mut buf = Vec::new();
        encode_log_record(&mut buf, &log);

        assert_eq!(buf[0], 0x09);
        let time_bytes = &buf[1..9];
        let time = u64::from_le_bytes(time_bytes.try_into().unwrap());
        assert_eq!(time, 1_000_000_000);
    }

    #[test]
    fn encode_log_record_contains_severity() {
        let log = test_log();
        let mut buf = Vec::new();
        encode_log_record(&mut buf, &log);

        assert_eq!(buf[9], 0x10);
        assert_eq!(buf[10], 0x09);
    }

    #[test]
    fn encode_log_record_contains_body() {
        let log = test_log();
        let mut buf = Vec::new();
        encode_log_record(&mut buf, &log);

        let body_bytes = b"hello world";
        let found = buf.windows(body_bytes.len()).any(|w| w == body_bytes);
        assert!(found, "log body not found in encoded bytes");
    }

    #[test]
    fn encode_log_record_contains_trace_id() {
        let log = test_log();
        let mut buf = Vec::new();
        encode_log_record(&mut buf, &log);

        let trace_id = &log.trace_id;
        let found = buf.windows(trace_id.len()).any(|w| w == trace_id);
        assert!(found, "trace_id not found in encoded bytes");
    }

    #[test]
    fn encode_export_logs_request_is_nonempty() {
        let attrs = vec![KeyValue {
            key: "service.name".to_string(),
            value: AnyValue::String("test-svc".to_string()),
        }];
        let logs = vec![test_log()];

        let bytes = encode_export_logs_request(&attrs, "pz-o11y", "0.1.0", &logs);
        assert!(!bytes.is_empty());
        assert_eq!(bytes[0], 0x0A);
    }
}