From 5e8b531ecff8f628ff648add53774d40540229f3 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 16:28:12 +0100 Subject: [PATCH 01/12] Add liblogjet shared library for C/C++ integration --- liblogjet/Cargo.toml | 14 + liblogjet/include/liblogjet.h | 55 ++++ liblogjet/src/lib.rs | 547 ++++++++++++++++++++++++++++++++++ 3 files changed, 616 insertions(+) create mode 100644 liblogjet/Cargo.toml create mode 100644 liblogjet/include/liblogjet.h create mode 100644 liblogjet/src/lib.rs diff --git a/liblogjet/Cargo.toml b/liblogjet/Cargo.toml new file mode 100644 index 0000000..9f0109b --- /dev/null +++ b/liblogjet/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "liblogjet" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +opentelemetry-proto = { version = "0.28", features = ["gen-tonic", "logs"] } +prost = "0.13" +tokio = { version = "1", features = ["rt-multi-thread", "time"] } +tonic = { version = "0.12", features = ["transport"] } diff --git a/liblogjet/include/liblogjet.h b/liblogjet/include/liblogjet.h new file mode 100644 index 0000000..5c89443 --- /dev/null +++ b/liblogjet/include/liblogjet.h @@ -0,0 +1,55 @@ +#ifndef LIBLOGJET_H +#define LIBLOGJET_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct lj_logger lj_logger; + +enum { + LJ_SEVERITY_TRACE = 1, + LJ_SEVERITY_DEBUG = 5, + LJ_SEVERITY_INFO = 9, + LJ_SEVERITY_WARN = 13, + LJ_SEVERITY_ERROR = 17, + LJ_SEVERITY_FATAL = 21 +}; + +typedef struct lj_attribute { + /* OTLP LogRecord attribute key */ + const char *key; + /* OTLP LogRecord attribute value as UTF-8 string */ + const char *value; +} lj_attribute; + +typedef struct lj_log_record { + /* OTel time_unix_nano */ + uint64_t timestamp_unix_ns; + /* OTel severity_number, for example LJ_SEVERITY_INFO */ + int32_t severity_number; + /* OTel severity_text, for example "INFO"; may be NULL */ + const char *severity_text; + /* OTel body string */ + const char *body; + /* Arbitrary OTLP LogRecord string attributes */ + const struct lj_attribute *attributes; + size_t attributes_len; +} lj_log_record; + +const char *lj_version(void); +const char *lj_error_message(void); +lj_logger *lj_logger_new_http(const char *endpoint, const char *service_name, uint64_t timeout_ms); +lj_logger *lj_logger_new_grpc(const char *endpoint, const char *service_name, uint64_t timeout_ms); +void lj_logger_free(lj_logger *logger); +bool lj_logger_log(lj_logger *logger, const lj_log_record *record); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/liblogjet/src/lib.rs b/liblogjet/src/lib.rs new file mode 100644 index 0000000..95d3139 --- /dev/null +++ b/liblogjet/src/lib.rs @@ -0,0 +1,547 @@ +use std::cell::RefCell; +use std::ffi::{CStr, CString, c_char}; +use std::io::{self, Read, Write}; +use std::net::TcpStream; +use std::ptr; +use std::time::Duration; + +use opentelemetry_proto::tonic::collector::logs::v1::{ExportLogsServiceRequest, logs_service_client::LogsServiceClient}; +use opentelemetry_proto::tonic::common::v1::any_value::Value; +use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope, KeyValue}; +use opentelemetry_proto::tonic::logs::v1::LogRecord; +use opentelemetry_proto::tonic::logs::v1::SeverityNumber; +use opentelemetry_proto::tonic::logs::v1::{ResourceLogs, ScopeLogs}; +use opentelemetry_proto::tonic::resource::v1::Resource; +use prost::Message; +use tokio::runtime::Runtime; +use tonic::Request; + +thread_local! { + static LAST_ERROR: RefCell = RefCell::new(cstring_lossy("ok")); +} + +#[repr(C)] +pub struct lj_attribute { + key: *const c_char, + value: *const c_char, +} + +#[repr(C)] +pub struct lj_log_record { + timestamp_unix_ns: u64, + severity_number: i32, + severity_text: *const c_char, + body: *const c_char, + attributes: *const lj_attribute, + attributes_len: usize, +} + +pub struct LjLogger { + transport: Transport, + service_name: String, + timeout: Duration, +} + +struct LogRecordInput { + timestamp_unix_ns: u64, + severity_number: i32, + severity_text: Option, + body: String, + attributes: Vec<(String, String)>, +} + +#[derive(Debug, Clone)] +struct HttpEndpoint { + authority: String, + path: String, +} + +#[derive(Debug, Clone)] +struct GrpcEndpoint { + url: String, +} + +enum Transport { + Http(HttpEndpoint), + Grpc { endpoint: GrpcEndpoint, runtime: Runtime }, +} + +impl HttpEndpoint { + fn parse(input: &str) -> io::Result { + let value = input.strip_prefix("http://").unwrap_or(input).trim(); + if value.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "endpoint must not be empty")); + } + + let (authority, path) = match value.find('/') { + Some(index) => (&value[..index], &value[index..]), + None => (value, "/v1/logs"), + }; + if authority.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "endpoint authority must not be empty")); + } + + Ok(Self { authority: authority.to_string(), path: normalize_path(path) }) + } +} + +impl LjLogger { + fn new_http(endpoint: &str, service_name: &str, timeout_ms: u64) -> io::Result { + let timeout = Duration::from_millis(timeout_ms.max(1)); + Ok(Self { transport: Transport::Http(HttpEndpoint::parse(endpoint)?), service_name: service_name.to_string(), timeout }) + } + + fn new_grpc(endpoint: &str, service_name: &str, timeout_ms: u64) -> io::Result { + let timeout = Duration::from_millis(timeout_ms.max(1)); + let runtime = Runtime::new().map_err(io::Error::other)?; + Ok(Self { transport: Transport::Grpc { endpoint: GrpcEndpoint::parse(endpoint)?, runtime }, service_name: service_name.to_string(), timeout }) + } + + fn log(&self, record: LogRecordInput) -> io::Result<()> { + let batch = build_logs_request(&self.service_name, record, self.transport_name()); + match &self.transport { + Transport::Http(endpoint) => post_otlp_http(endpoint, self.timeout, &batch.encode_to_vec()), + Transport::Grpc { endpoint, runtime } => runtime.block_on(post_otlp_grpc(endpoint, self.timeout, batch)), + } + } + + fn transport_name(&self) -> &'static str { + match self.transport { + Transport::Http(_) => "otlp-http", + Transport::Grpc { .. } => "otlp-grpc", + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn lj_version() -> *const c_char { + concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr().cast() +} + +#[unsafe(no_mangle)] +pub extern "C" fn lj_error_message() -> *const c_char { + LAST_ERROR.with(|cell| cell.borrow().as_ptr()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn lj_logger_new_http(endpoint: *const c_char, service_name: *const c_char, timeout_ms: u64) -> *mut LjLogger { + ffi_new(|| { + let endpoint = required_cstr(endpoint, "endpoint")?; + let service_name = required_cstr(service_name, "service_name")?; + let logger = LjLogger::new_http(&endpoint, &service_name, timeout_ms)?; + Ok(Box::into_raw(Box::new(logger))) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn lj_logger_new_grpc(endpoint: *const c_char, service_name: *const c_char, timeout_ms: u64) -> *mut LjLogger { + ffi_new(|| { + let endpoint = required_cstr(endpoint, "endpoint")?; + let service_name = required_cstr(service_name, "service_name")?; + let logger = LjLogger::new_grpc(&endpoint, &service_name, timeout_ms)?; + Ok(Box::into_raw(Box::new(logger))) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn lj_logger_free(logger: *mut LjLogger) { + if logger.is_null() { + return; + } + let _ = std::panic::catch_unwind(|| { + // SAFETY: ownership comes from Box::into_raw in lj_logger_new_http. + unsafe { + drop(Box::from_raw(logger)); + } + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn lj_logger_log(logger: *mut LjLogger, record: *const lj_log_record) -> bool { + ffi_bool(|| { + if logger.is_null() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "logger must not be null")); + } + if record.is_null() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "record must not be null")); + } + + // SAFETY: caller guarantees a valid pointer for the duration of this call. + let logger = unsafe { &*logger }; + // SAFETY: caller guarantees a valid pointer for the duration of this call. + let record = unsafe { &*record }; + logger.log(parse_record(record)?)?; + Ok(()) + }) +} + +fn build_logs_request(service_name: &str, record: LogRecordInput, transport_name: &str) -> ExportLogsServiceRequest { + let severity_text = record.severity_text.unwrap_or_else(|| default_severity_text(record.severity_number).to_string()); + + let mut attributes = vec![string_attr("liblogjet.transport", transport_name), string_attr("liblogjet.runtime", "cpp-ffi")]; + attributes.extend(record.attributes.into_iter().map(|(key, value)| string_attr(&key, &value))); + + ExportLogsServiceRequest { + resource_logs: vec![ResourceLogs { + resource: Some(Resource { attributes: vec![string_attr("service.name", service_name)], dropped_attributes_count: 0 }), + scope_logs: vec![ScopeLogs { + scope: Some(InstrumentationScope { + name: "liblogjet".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + attributes: Vec::new(), + dropped_attributes_count: 0, + }), + log_records: vec![LogRecord { + time_unix_nano: record.timestamp_unix_ns, + observed_time_unix_nano: record.timestamp_unix_ns, + severity_number: normalized_severity(record.severity_number), + severity_text, + body: Some(AnyValue { value: Some(Value::StringValue(record.body)) }), + attributes, + dropped_attributes_count: 0, + flags: 0, + trace_id: Vec::new(), + span_id: Vec::new(), + event_name: "log".to_string(), + }], + schema_url: String::new(), + }], + schema_url: String::new(), + }], + } +} + +fn post_otlp_http(endpoint: &HttpEndpoint, timeout: Duration, body: &[u8]) -> io::Result<()> { + let mut stream = TcpStream::connect(&endpoint.authority)?; + stream.set_write_timeout(Some(timeout))?; + stream.set_read_timeout(Some(timeout))?; + + write!( + stream, + "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/x-protobuf\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + endpoint.path, + endpoint.authority, + body.len() + )?; + stream.write_all(body)?; + stream.flush()?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") { + return Err(io::Error::other(format!("collector returned non-200 response: {}", response.lines().next().unwrap_or("unknown response")))); + } + Ok(()) +} + +impl GrpcEndpoint { + fn parse(input: &str) -> io::Result { + let value = input.trim(); + if value.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "endpoint must not be empty")); + } + let url = if value.starts_with("http://") || value.starts_with("https://") { value.to_string() } else { format!("http://{value}") }; + Ok(Self { url }) + } +} + +async fn post_otlp_grpc(endpoint: &GrpcEndpoint, timeout: Duration, batch: ExportLogsServiceRequest) -> io::Result<()> { + let channel = tonic::transport::Endpoint::from_shared(endpoint.url.clone()) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string()))? + .connect_timeout(timeout) + .timeout(timeout) + .connect() + .await + .map_err(io::Error::other)?; + let mut client = LogsServiceClient::new(channel); + client.export(Request::new(batch)).await.map_err(io::Error::other)?; + Ok(()) +} + +fn parse_record(record: &lj_log_record) -> io::Result { + let body = required_cstr(record.body, "record.body")?; + let severity_text = optional_cstr(record.severity_text, "record.severity_text")?; + let mut attributes = Vec::with_capacity(record.attributes_len); + + if record.attributes_len > 0 { + if record.attributes.is_null() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "record.attributes is null while attributes_len is non-zero")); + } + + // SAFETY: pointer validity is checked above and length is provided by the caller. + let slice = unsafe { std::slice::from_raw_parts(record.attributes, record.attributes_len) }; + for attr in slice { + attributes.push((required_cstr(attr.key, "attribute.key")?, required_cstr(attr.value, "attribute.value")?)); + } + } + + Ok(LogRecordInput { timestamp_unix_ns: record.timestamp_unix_ns, severity_number: record.severity_number, severity_text, body, attributes }) +} + +fn required_cstr(ptr: *const c_char, field: &str) -> io::Result { + if ptr.is_null() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{field} must not be null"))); + } + // SAFETY: pointer is checked above and treated as read-only. + let text = + unsafe { CStr::from_ptr(ptr) }.to_str().map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, format!("{field} must be valid UTF-8")))?; + Ok(text.to_string()) +} + +fn optional_cstr(ptr: *const c_char, field: &str) -> io::Result> { + if ptr.is_null() { + return Ok(None); + } + required_cstr(ptr, field).map(Some) +} + +fn normalized_severity(value: i32) -> i32 { + if value == 0 { SeverityNumber::Info as i32 } else { value } +} + +fn default_severity_text(value: i32) -> &'static str { + match normalized_severity(value) { + x if x == SeverityNumber::Trace as i32 => "TRACE", + x if x == SeverityNumber::Debug as i32 => "DEBUG", + x if x == SeverityNumber::Info as i32 => "INFO", + x if x == SeverityNumber::Warn as i32 => "WARN", + x if x == SeverityNumber::Error as i32 => "ERROR", + x if x == SeverityNumber::Fatal as i32 => "FATAL", + _ => "INFO", + } +} + +fn string_attr(key: &str, value: &str) -> KeyValue { + KeyValue { key: key.to_string(), value: Some(AnyValue { value: Some(Value::StringValue(value.to_string())) }) } +} + +fn normalize_path(path: &str) -> String { + if path.is_empty() { + "/v1/logs".to_string() + } else if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + } +} + +fn cstring_lossy(message: &str) -> CString { + let filtered = message.replace('\0', " "); + CString::new(filtered).unwrap_or_else(|_| CString::new("ffi error").expect("static string")) +} + +fn set_last_error(message: impl Into) { + let message = cstring_lossy(&message.into()); + LAST_ERROR.with(|cell| { + *cell.borrow_mut() = message; + }); +} + +fn ffi_new(func: impl FnOnce() -> io::Result<*mut T> + std::panic::UnwindSafe) -> *mut T { + match std::panic::catch_unwind(func) { + Ok(Ok(value)) => { + set_last_error("ok"); + value + } + Ok(Err(err)) => { + set_last_error(err.to_string()); + ptr::null_mut() + } + Err(_) => { + set_last_error("panic across FFI boundary"); + ptr::null_mut() + } + } +} + +fn ffi_bool(func: impl FnOnce() -> io::Result<()> + std::panic::UnwindSafe) -> bool { + match std::panic::catch_unwind(func) { + Ok(Ok(())) => { + set_last_error("ok"); + true + } + Ok(Err(err)) => { + set_last_error(err.to_string()); + false + } + Err(_) => { + set_last_error("panic across FFI boundary"); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + use std::sync::{Arc, Mutex}; + use std::thread; + use tokio::sync::oneshot; + use tonic::transport::Server; + use tonic::{Response, Status}; + + use opentelemetry_proto::tonic::collector::logs::v1::{ + ExportLogsServiceResponse, + logs_service_server::{LogsService, LogsServiceServer}, + }; + + #[test] + fn endpoint_parse_defaults_path() { + let endpoint = HttpEndpoint::parse("127.0.0.1:4318").unwrap(); + assert_eq!(endpoint.authority, "127.0.0.1:4318"); + assert_eq!(endpoint.path, "/v1/logs"); + } + + #[test] + fn grpc_endpoint_parse_defaults_scheme() { + let endpoint = GrpcEndpoint::parse("127.0.0.1:4317").unwrap(); + assert_eq!(endpoint.url, "http://127.0.0.1:4317"); + } + + #[test] + fn ffi_logger_posts_log_record() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + + let server = thread::spawn(move || -> ExportLogsServiceRequest { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream).unwrap(); + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").unwrap(); + ExportLogsServiceRequest::decode(request.as_slice()).unwrap() + }); + + let endpoint = CString::new(format!("127.0.0.1:{}", addr.port())).unwrap(); + let service = CString::new("cpp-appliance").unwrap(); + let logger = lj_logger_new_http(endpoint.as_ptr(), service.as_ptr(), 1_000); + assert!(!logger.is_null(), "ffi init failed: {}", unsafe { CStr::from_ptr(lj_error_message()).to_string_lossy() }); + + let severity_text = CString::new("INFO").unwrap(); + let body = CString::new("ffi hello").unwrap(); + let attr_key = CString::new("appliance.id").unwrap(); + let attr_value = CString::new("node-7").unwrap(); + let attributes = [lj_attribute { key: attr_key.as_ptr(), value: attr_value.as_ptr() }]; + let record = lj_log_record { + timestamp_unix_ns: 123, + severity_number: SeverityNumber::Info as i32, + severity_text: severity_text.as_ptr(), + body: body.as_ptr(), + attributes: attributes.as_ptr(), + attributes_len: attributes.len(), + }; + + assert!(lj_logger_log(logger, &record)); + lj_logger_free(logger); + + let batch = server.join().unwrap(); + let resource = &batch.resource_logs[0].resource.as_ref().unwrap().attributes; + assert!(resource.iter().any(|attr| attr.key == "service.name")); + let log_record = &batch.resource_logs[0].scope_logs[0].log_records[0]; + assert_eq!(log_record.severity_text, "INFO"); + let body = log_record.body.as_ref().and_then(|value| value.value.as_ref()); + assert!(matches!(body, Some(Value::StringValue(text)) if text == "ffi hello")); + assert!(log_record.attributes.iter().any(|attr| attr.key == "appliance.id")); + } + + #[test] + fn ffi_logger_posts_log_record_over_grpc() { + let received = Arc::new(Mutex::new(Vec::new())); + let runtime = Runtime::new().unwrap(); + let addr = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let service = TestLogsService { received: Arc::clone(&received) }; + runtime.spawn(async move { + Server::builder() + .add_service(LogsServiceServer::new(service)) + .serve_with_shutdown(addr, async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + let endpoint = CString::new(format!("127.0.0.1:{}", addr.port())).unwrap(); + let service_name = CString::new("cpp-appliance").unwrap(); + let logger = lj_logger_new_grpc(endpoint.as_ptr(), service_name.as_ptr(), 1_000); + assert!(!logger.is_null(), "ffi init failed: {}", unsafe { CStr::from_ptr(lj_error_message()).to_string_lossy() }); + + let severity_text = CString::new("INFO").unwrap(); + let body = CString::new("ffi grpc hello").unwrap(); + let attr_key = CString::new("appliance.id").unwrap(); + let attr_value = CString::new("node-9").unwrap(); + let attributes = [lj_attribute { key: attr_key.as_ptr(), value: attr_value.as_ptr() }]; + let record = lj_log_record { + timestamp_unix_ns: 456, + severity_number: SeverityNumber::Info as i32, + severity_text: severity_text.as_ptr(), + body: body.as_ptr(), + attributes: attributes.as_ptr(), + attributes_len: attributes.len(), + }; + + assert!(lj_logger_log(logger, &record)); + lj_logger_free(logger); + + runtime.block_on(async { + for _ in 0..50 { + if !received.lock().unwrap().is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }); + let _ = shutdown_tx.send(()); + + let batches = received.lock().unwrap(); + assert_eq!(batches.len(), 1); + let batch = &batches[0]; + let log_record = &batch.resource_logs[0].scope_logs[0].log_records[0]; + assert_eq!(log_record.severity_text, "INFO"); + let body = log_record.body.as_ref().and_then(|value| value.value.as_ref()); + assert!(matches!(body, Some(Value::StringValue(text)) if text == "ffi grpc hello")); + assert!(log_record.attributes.iter().any(|attr| attr.key == "appliance.id")); + assert!(log_record.attributes.iter().any(|attr| attr.key == "liblogjet.transport")); + } + + fn read_http_request(stream: &mut TcpStream) -> io::Result> { + let mut header = Vec::new(); + let mut byte = [0u8; 1]; + loop { + stream.read_exact(&mut byte)?; + header.push(byte[0]); + if header.ends_with(b"\r\n\r\n") { + break; + } + } + + let header_text = + std::str::from_utf8(&header[..header.len() - 4]).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid header"))?; + let mut content_length = None; + for line in header_text.lines().skip(1) { + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + content_length = + Some(value.trim().parse::().map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid content-length"))?); + } + } + + let mut body = vec![0u8; content_length.unwrap_or(0)]; + stream.read_exact(&mut body)?; + Ok(body) + } + + #[derive(Clone)] + struct TestLogsService { + received: Arc>>, + } + + #[tonic::async_trait] + impl LogsService for TestLogsService { + async fn export(&self, request: Request) -> Result, Status> { + self.received.lock().unwrap().push(request.into_inner()); + Ok(Response::new(ExportLogsServiceResponse { partial_success: None })) + } + } +} From 7a4d76415d48be9920d472f56c43ca8d5a95ca6f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 16:28:26 +0100 Subject: [PATCH 02/12] Add integration tests --- ljx/src/commands/view.rs | 143 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 137 insertions(+), 6 deletions(-) diff --git a/ljx/src/commands/view.rs b/ljx/src/commands/view.rs index 5ae091b..87cf95d 100644 --- a/ljx/src/commands/view.rs +++ b/ljx/src/commands/view.rs @@ -14,6 +14,7 @@ use crossterm::execute; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; use logjet::{LogjetReader, LogjetWriter, OwnedRecord, RecordType, WriterConfig}; use opentelemetry_proto::tonic::collector::logs::v1::ExportLogsServiceRequest; +use opentelemetry_proto::tonic::common::v1::AnyValue; use opentelemetry_proto::tonic::common::v1::any_value::Value; use prost::Message; use ratatui::backend::CrosstermBackend; @@ -1025,6 +1026,8 @@ fn render_modal_info_entries(detail: &DetailRecord) -> Vec<(String, String)> { let mut record_attr_count = 0usize; let mut trace_ids = 0usize; let mut span_ids = 0usize; + let mut resource_attr_entries = Vec::new(); + let mut record_attr_entries = Vec::new(); for resource_logs in &batch.resource_logs { if let Some(resource) = &resource_logs.resource { @@ -1037,6 +1040,7 @@ fn render_modal_info_entries(detail: &DetailRecord) -> Vec<(String, String)> { { service_names.push(service.clone()); } + resource_attr_entries.push(("resource".to_string(), attr.key.clone(), format_any_value(attr.value.as_ref()))); } } @@ -1061,6 +1065,9 @@ fn render_modal_info_entries(detail: &DetailRecord) -> Vec<(String, String)> { if !record.span_id.is_empty() { span_ids += 1; } + for attr in &record.attributes { + record_attr_entries.push(("record".to_string(), attr.key.clone(), format_any_value(attr.value.as_ref()))); + } } } } @@ -1081,6 +1088,12 @@ fn render_modal_info_entries(detail: &DetailRecord) -> Vec<(String, String)> { } lines.push(("resource.attrs".to_string(), resource_attr_count.to_string())); lines.push(("record.attrs".to_string(), record_attr_count.to_string())); + for (kind, key, value) in resource_attr_entries { + lines.push((format!("{kind}.{key}"), value)); + } + for (kind, key, value) in record_attr_entries { + lines.push((format!("{kind}.{key}"), value)); + } if trace_ids > 0 { lines.push(("trace_id".to_string(), format!("{trace_ids} present"))); } @@ -1093,10 +1106,78 @@ fn render_modal_info_entries(detail: &DetailRecord) -> Vec<(String, String)> { fn modal_info_line(key: &str, value: String, key_width: usize, value_width: usize) -> Line<'static> { let value = trim_single_line(&value, value_width); - Line::from(vec![ - Span::styled(format!("{key:) -> String { + let Some(value) = value else { + return "null".to_string(); + }; + match &value.value { + Some(Value::StringValue(text)) => text.clone(), + Some(Value::BoolValue(flag)) => flag.to_string(), + Some(Value::IntValue(number)) => number.to_string(), + Some(Value::DoubleValue(number)) => number.to_string(), + Some(Value::BytesValue(bytes)) => format!("<{} bytes>", bytes.len()), + Some(Value::ArrayValue(array)) => format!("", array.values.len()), + Some(Value::KvlistValue(map)) => format!("", map.values.len()), + None => "null".to_string(), + } +} + +fn is_otlp_attribute_entry(key: &str) -> bool { + (key.starts_with("resource.") && key != "resource.attrs") || (key.starts_with("record.") && key != "record.attrs") +} + +fn is_standard_otlp_attribute_entry(key: &str) -> bool { + let Some((_, attr_key)) = key.split_once('.') else { + return false; + }; + + const STANDARD_PREFIXES: &[&str] = &[ + "service.", + "telemetry.", + "host.", + "os.", + "process.", + "container.", + "k8s.", + "cloud.", + "deployment.", + "device.", + "faas.", + "enduser.", + "server.", + "client.", + "http.", + "url.", + "network.", + "net.", + "rpc.", + "db.", + "messaging.", + "exception.", + "code.", + "thread.", + "gen_ai.", + "browser.", + "user_agent.", + "aws.", + "gcp.", + "azure.", + "vcs.", + ]; + + STANDARD_PREFIXES.iter().any(|prefix| attr_key.starts_with(prefix)) } fn footer_sep() -> Span<'static> { @@ -1324,11 +1405,11 @@ fn create_temp_path() -> Result { #[cfg(test)] mod tests { - use super::{DetailRecord, EntryMeta, extract_otlp_log_message, format_summary, render_modal_message, text_preview}; + use super::{DetailRecord, EntryMeta, extract_otlp_log_message, format_summary, render_modal_info_entries, render_modal_message, text_preview}; use logjet::RecordType; use opentelemetry_proto::tonic::collector::logs::v1::ExportLogsServiceRequest; use opentelemetry_proto::tonic::common::v1::any_value::Value; - use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope}; + use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope, KeyValue}; use opentelemetry_proto::tonic::logs::v1::{LogRecord, ResourceLogs, ScopeLogs}; use opentelemetry_proto::tonic::resource::v1::Resource; use prost::Message; @@ -1397,4 +1478,54 @@ mod tests { let body = render_modal_message(&detail, false); assert_eq!(body, "hello"); } + + #[test] + fn modal_info_lists_otlp_attributes() { + let batch = ExportLogsServiceRequest { + resource_logs: vec![ResourceLogs { + resource: Some(Resource { + attributes: vec![KeyValue { + key: "service.name".to_string(), + value: Some(AnyValue { value: Some(Value::StringValue("cpp-appliance".to_string())) }), + }], + dropped_attributes_count: 0, + }), + scope_logs: vec![ScopeLogs { + scope: Some(InstrumentationScope { + name: "liblogjet".to_string(), + version: String::new(), + attributes: Vec::new(), + dropped_attributes_count: 0, + }), + log_records: vec![LogRecord { + time_unix_nano: 0, + observed_time_unix_nano: 0, + severity_number: 0, + severity_text: "INFO".to_string(), + body: Some(AnyValue { value: Some(Value::StringValue("hello from cpp".to_string())) }), + attributes: vec![KeyValue { + key: "character".to_string(), + value: Some(AnyValue { value: Some(Value::StringValue("Bender".to_string())) }), + }], + dropped_attributes_count: 0, + flags: 0, + trace_id: Vec::new(), + span_id: Vec::new(), + event_name: String::new(), + }], + schema_url: String::new(), + }], + schema_url: String::new(), + }], + }; + let payload = batch.encode_to_vec(); + let detail = DetailRecord { + meta: EntryMeta { offset: 0, record_type: RecordType::Logs, seq: 1, ts_unix_ns: 2, payload_len: payload.len() as u64 }, + payload, + }; + + let entries = render_modal_info_entries(&detail); + assert!(entries.iter().any(|(key, value)| key == "resource.service.name" && value == "cpp-appliance")); + assert!(entries.iter().any(|(key, value)| key == "record.character" && value == "Bender")); + } } From b318f6f22b2b07e6f715be53bdc9d00d118132e0 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 16:28:40 +0100 Subject: [PATCH 03/12] Add C/C++ shared library integration demo and example --- demo/README.md | 2 + demo/cpp-shared-lib/README.md | 46 ++++++++ demo/cpp-shared-lib/cpp-logger | Bin 0 -> 35792 bytes demo/cpp-shared-lib/cpp-logger.cpp | 170 +++++++++++++++++++++++++++++ demo/cpp-shared-lib/liblogjet.so | 1 + demo/cpp-shared-lib/ljd.conf | 6 + demo/cpp-shared-lib/run-demo.sh | 52 +++++++++ 7 files changed, 277 insertions(+) create mode 100644 demo/cpp-shared-lib/README.md create mode 100755 demo/cpp-shared-lib/cpp-logger create mode 100644 demo/cpp-shared-lib/cpp-logger.cpp create mode 120000 demo/cpp-shared-lib/liblogjet.so create mode 100644 demo/cpp-shared-lib/ljd.conf create mode 100755 demo/cpp-shared-lib/run-demo.sh diff --git a/demo/README.md b/demo/README.md index 69c7b14..0b8ecf4 100644 --- a/demo/README.md +++ b/demo/README.md @@ -35,6 +35,8 @@ It also contains scenario demos under subdirectories: - one replay client stalls while another keeps flowing - [`replay-handoff`](./replay-handoff) - a late replay client drains retained backlog and then continues live on the same connection +- [`cpp-shared-lib`](./cpp-shared-lib) + - a C++ process loads `liblogjet.so`, sends OTLP logs into `ljd`, and opens the result in `ljx view` - [`file-replay`](./file-replay) - replay stored `.logjet` files into a collector - [`file-tooling`](./file-tooling) diff --git a/demo/cpp-shared-lib/README.md b/demo/cpp-shared-lib/README.md new file mode 100644 index 0000000..fb2a756 --- /dev/null +++ b/demo/cpp-shared-lib/README.md @@ -0,0 +1,46 @@ +# C++ Shared Library Demo + +This demo shows one C++ process loading a Rust shared library and sending OTLP +logs into `ljd` over gRPC. + +The path is: + +`C++ appliance -> liblogjet.so -> OTLP/gRPC -> ljd -> .logjet file -> ljx view` + +## Build First + +From the project root: + +```bash +cargo build -p ljd -p ljx -p liblogjet +``` + +You also need `g++` available locally because the demo compiles the C++ +example on demand. + +## Run + +From this directory: + +```bash +./run-demo.sh +``` + +## What It Does + +The script: + +1. builds the example C++ logger +2. starts file-backed `ljd` on `127.0.0.1:4317` +3. loads `liblogjet.so` through `dlopen` +4. sends five OTLP log records from C++ +5. opens `ljx view` on the resulting `./logs/cpp-demo.logjet` + +## Notes + +- the library now supports both OTLP/HTTP and OTLP/gRPC constructors +- this demo specifically uses OTLP/gRPC +- the FFI API is intentionally small: endpoint, service name, timestamp, severity, message body, and string attributes +- those key/value pairs become OTLP `LogRecord.attributes`, which is the standard OTel metadata field for log records +- if the appliance already has JSON metadata, the better long-term shape is to flatten that JSON into separate attributes where possible; a raw JSON blob can still be sent as one string attribute when needed +- the demo uses a local symlink named `liblogjet.so` that points at Cargo's built shared object diff --git a/demo/cpp-shared-lib/cpp-logger b/demo/cpp-shared-lib/cpp-logger new file mode 100755 index 0000000000000000000000000000000000000000..db2a8eb70a30b53cb6efe3dfb537aaa518767818 GIT binary patch literal 35792 zcmeHw3v^r6neLGv#Cg2M?bJ{L5}}Y9Xwp#d{r>%s zj}+_9G4R z3doujxAFmes>(IWMS#l~j@LKy47EI+D9O(&1fCQUU$xL&m|rF6WKK3wqMZ1OrRur7 zM&?Qthl#J8iYk2|sq8bChv#!x=4L*ouT#($Bdm7tD)}3kiRArj3oPO z`rQpR4R!WJ)b3*SWS5FR>b{k$*DL6jQNjsvO35IHr97&;MAQ@gbMLmlY%RBzT{FDw zw$H_%d3fWyKV`? z$SFP^f#45Lf&US3Q{e}S(EE$ff4vC(iX!yCLL;Zr>n$SZyG8I17r_q|!CwwXPNg?d zg#P9t_&1BlxxPrdzF))-hlQL@&foDQ9sJ{2AbOc;86-KyoGXhdJ2$ifyosO>3gf{@PrGle;dJ@} z-nhqzdxJ)z-Pg9(i;uP@&nk~M7SkjCHpmD=h(FZXOF4u!_@F2ft@V7;<#Z(mU}@On z3q^gKYa`LVwq8!;tc`mk{%9D@=?(gH64;{~z21-{a0jCa53JGa+9N@u)yeQ{jXEDJ zpHf64M05uuK_c|@^!IyWdOQ)0ctb&Bz|-ph4_edU@kG%$-5d4pbfmp0>3Op3Ym=)_OukEr0<=+nQT0z^84@!ce09e5X%kkZ&E#sfRh#g^@nt z`+To0=U%%2W2irTaFe08tqBX+e`}C99T4IVCSuWqp6k76(%?POlncmc4A{o{de~2kSWcd2M6eb9ZyKvEXaOb9Qr|T>14Msqi z7zis7ZzP%s>3U2szz_XFLxFfB8lntW5lExB0+1oN6S@-C!@k%67@#Q<^&sHAMlc!y z0^)s}J-)zZPq#N1QlK(n_^ zDl0qMmjML2&fa9I0AQ|iy3L@uY7$CzD!vm;2?ubmGyurSzz;9LO;Qm1cM(?&_bHblK$Oa{u-uLGn9yc zA7SuC%7B2AR|IF0&xVCOIj@)V>0L`ns-o-@6Ih8KyouvS3-FzSzG9HqOL_?(yPM-_ zu6G?TvdnS55fSCj7M~{8c=6SkRmDtr^L(e@guhq{K|W~0)Atyec9`(Y{$3OQG82A}314l( zKWM^VZo=<1;jb{^_nGilned|~{D(~VqbB?{Cj4}9+5^)bnD)T52c|tR?SW|zOnYG3 z1JfRu_Q13UrakcA$pdd$KXgtTdc8s$DgU(x%Msf~jgpCD+R#%K2U#;G>L34%qKr>0 z_-AC+YZRgnP|bMu#fgcDT@0oLobl}O983#0qgv`{mieIN(Z0?l~# z%Q=`9X2!Eyb1*H)jAuWagK5EQJnPNDv@kQC{X`C?1)1?|OAe-mnDMML2h#$~c=p;H zObajL*||BG7F@=&mK;nAE#uigy)WBK3oK}V4yJ__v_A*af(qK7gJ~fJ?a#rqfP(ht zU|Kjq`*ScYn4tYRm=;RV{v1pTBxrvQriBr-KL^u-2-=^6X`v47moR$s`Hj?@tqSW; zz$fp>hksW9KVJYpT>u{{fPY#5KUx4kTmXN!0RCnH{M7
x(@fd8%lP8Pu70$4AA zHx|I_3*c1+a9aVqv;eLzfNKii1qJZc1@OEA_yZEwMz+}8nO=mdHnQJ#FFHCC6u=$= z?f}q{I+Iz)kXvlA%*~<{$TW-6rc8qOf^03-rryll4dB#c-FE9W!(h&hPc5Hn`DDxbmUZjbYFl5YRefz}j!h{jKZrrX zuZ@%~SO`WfHEg^0BZ}f2O}(q7-syn0V;IziCM?!325|Li>0uid{n6^)ijLGdEp>!A zn8m-r#iym~e*x~HcT0>iZ6sMmH1-)q3%41Apu$kIjE%$W`4;rcT3E$?JNo0&(WU~ zd=KYQwWprSe1l`QO!A#3zG03|0Ij8$UV0lcn6-%Df{(+-cN{zT?8^3;>3 ztdk+5jjVh6td=^a9msr0a~{)zqk3va$Z`~Vo<`gpuxMNV5<}M{U(iPGu(`=31q~w< z#p%X(>GJxEmio8HXOWV0`R(ulMJqk9v6^fwFGZa&zCw6NSbG*11vSDU8X>HH zgV1P%usS0^VRcr3!s?R(6jr}1KwywUnDB^KfWofL0u*+I1SmZ+Mw&0? znje~s5t;>=`r=s;S=HQivuUK5jEz?PJKc<@Y;gSpB*_q#Za{stvtn$(+D^c-4sQDRRRm(ZWsw_^8=xtENU!&8lrLTZdo7bh{(< zYUW4p(0yf?-Fe$nhqR%yR_hmrXTn26XD!xkT`09!AG^f1V>cHxvd&gb);L7y2OyBy zbhxYp-L~6C@0^+KYBt$5DD2umcI^^?%6e7Wb%n6&@~Q0lZO*PAS4`T)1i>zq?CKPD z?I^TsLxEinR!#bAk1%Bu*|k>y3&}33VgBkxgpt3tzCDG%-i9^YUnM5Hm>}4-knD;H zyB;jGtG~dmDwAEK!ju5nbxZ&bvWse%U0<8S?aID2gRyGf?W=>YnQO= zXrW#A7TDE~%T31Dm@p+qcAXYL71>2K%&zg-+^$bdWtTr^*SB%6k#;dbu&avf+AHkJ z6xy|q>`FgkQ!xuM$Jq%4j`2HfvR*U;Hn*6o`i|8e}$fHgLsntvvq&PCg9@wAK(v!HjA#{yo2yuzMs=ww4YSfsgm%5-nuj#CE=HP5_OdVW<4Y|fjm-%DE3OTWxb)Jy zsqSW~OC5O}!a!f&ausk5grl!2ghZlikIICrAY6^pa;nmLO|z1D_m2}3L+-L9E`nF+ zt}+|#89F;*yh6c1dODiU@K9iZNujb&G?}>tp;X1|*`P`F_lkPvM$|iaJsa$)zF*Yu zA-AG_A+O&{Ak}XW^@E~b<@LK{y<60867|)*J|^p{MSZiVZ|3!#vi>ZZJW^+CGHhF{ zJO7CSeB=_FHgs;Lb^Fb5KDwu$&)9~}`mEcRfMDsX_^@vOIMF_F%cr-yUpN%5Tgqlj zH)flF&CTD2&VgkHFk8=Yp4qHSq}J_L;>UN*)bW;~r^>W5uV8{U8(%QLwcVohVZu{p zvt-}JYy<18G_Txt55fUoP;C|28RrO#^*V;%B=A+)JN{zEe@x&V*@w=W@mC4_!t7r$ zX`GaE4hyj)U(I&p;ZF#Bb@u*0OL`1@oz7x?TV7Dq1s$o^5K5Wr zUZ;>1RR~?8&aQ$uxQf~Vc>ykOk&rh^{gW^6*rdE)p30X;)Y(HYds5yeF7MZ*8TRct zU*6tHc|!&Ah&ubbJo`3qc_|@p@O*i@Cgs%@$Rq0PJLt?w`(j*PlaM!dzP!_u^8WBz zetU^J8_umtnQ2{Vjv;`Rc+ z0r>mbJu+Kr#&-gbDLB#JfiTO_Yryl_LiXvLoh+yk?Uw9(OuxE!Nw_5Y!92J$TSC(_ z+`biZOS*m^7O^MS&-P->%EahynXdO@6wcnq!V4=vsGu-AjU3M{FlIf7Qk(PT%vZAH z!2Pxi;LJRlU;r2+ph5ub8}_>-6g9`F<_!THCE!H?j1usi0QM0ugLzXdS)OBh`1+w= zyMZq#TDM(B&Di?XgZOIOni{)(W6K>a8(Tip;yH*GJi*osmyWH3tXqg3-*(W3vyhE8 z@Kv>1hNo4vD87kjY@#)TpgF(2e7MSdD?={i!*-^NanOqI22l!RR*TZ6Osgnu$Se`1 z&WwYV#ES2A0J8hp+U(y|vk8>*&zX+_iOKAi>Rd84 zz4Uhoj!YjADDQyu%;$J{3zef(#=ujZ{S?;isHA3>ip9c(**^%JBbySXs%%u$R%HJd zmC{S=NY3?K=0Yl8$;&$Fop^=OR1v-uc+uGFFnELYFGTV>NHSkQ9eePWI+)#t_Q>)( zrt(Tt*={P|Vk&PmmESYTTw}toHI>(y%Ii(#Pnyb~GL`QtQa0Ofm}nN7%3m_slQxyV zg>v`g`YSbky_T*y*@j5KVlw$h8(F$>Ie~9%)Y9|1P|Th@Degl=@BCwX>iB20)JxjX z%cncnwK+$fKZk!U&^|G*6y$GD$_L&_veM-rp{(hwF%OSAUs;AS9(B%~7_(kOmy~i) zFCzr}u=Q~HA8{wNYy@vkKk;Y#P$}%8=NRp&x7TZ__cB#* z0&cD3rgtC%Yb2TXkfR~VnH{MN8)9g9S&Zk8XvbD+2WYaOVQfb-|3>8L`bX)xL+VK8 zUkPMO@R_H0O)0UW=23t@qlsc>Hk9r%`E$|uK3r)N^n|57^*kB(R~YxsTCM5H-rLxB zGL)>t-vMuX>V@_LZ>Sxqap#|DYB>r%j9S0)WP8)0 z_;GF9v&moFu330{T0R5s(0n0%nUwS4pRL39L7GO7kIujynU`N8*OuADLRQ~OT9ipTt&go&cC5wM>g5$J zhs(+^ltV`Du91X-r>gnA;vkPJiqhk!Y&mB+-ee;@^=c!a~23{%T7fqxZu zd-P}KA4xeD5<5n&JGDD=IZ>vU-i7B_nJ{Y1Eo9GsJ4R-#f*QniAA}s~Sadl1Jm^^a z51Ogb59AMRA8z}U?&hAcJx`vb5G4myXh(2=g)fe*tPnZYkq`|{PxX1R`eEDzL)+mZ zFLd0TzDm_nZMf6F)Iou`5w1CsdG$qV+r|od4MBKA_LuWFlj51!0y;GG0Ae^}Km^%J z{=xM!57ts>JUmtV+f#S~bhhOuzu#MNpO*evHO%iwJt_P@GD{nYS7=*bL=y(LVsx6g zTbubOE%l6c{0*)4#{n#Q4ZUbFN;{m-XepOA^d!F7|0;R3J-v4ms(wZ?>3fzovWw(u zBRf>ew!Xx~!AKJKzwwPgJD$~QD~4WzD9wonN;??&&?v+mP9A6<8SF>@wU1P33m7Nb zL7bQ^JgFUjt-W>^X?xIORP1A|a6Z|d+Qm4I3=V>k5-y}>-hU%sAlzDt)}>Lze_uNm zge-^{Tv>llRkxE^d^brwt2v*fZ!8DKW-J)|j%9wp zrVK+Q<<$2@yiib}D-Uuzraz}WFztb94@`St+5^)bnD)T52c|tR?SW|zOncz}g9j{F zZf6^$sI-xZ?pM2`aTWVvI9-i)2mU#i)YUp0#l|RtW{IkV?)3CxFCN+q1x2LUqS=GZT1~P}J*(E!a5)(4D%W3T3LY3>y^b zab-n(KJ>H|Vga;H&kHa)&b+8MYD#ewx zzId{`dq7$44f>Y)R=1c_UEK)*p*Qkg{6uQ|a&1(fDR%T|ie?MM1R68&9a~BSB_} zF`%r8c0~AswM;G6JtAm=<3kY3NFvDUa!ffi0cX zyP{FNR2w4G22`In5`nor@Tq@53Fz@?(5Lw0UdTs*ZGbbNVE?Bmfzg-|3}fFZ6a!EK z>yr_v1efkteAqTB5>@s7m^Z>YTH3FMyvc|!Fsak88_{H3Se1;$gJA4Y<9a}c)4bSR zhnXIWN5jwx^x==0Z!B^y(C0@O~WEmTCmN5kyVDCxKm$7A}%Q2fsd4a&|C*OUSGGLf%AT zAQycq?UaNT`_(Wu!U_$jUHC5p(oPo^m~kC|0D++PgreAVsYmTf8U~`eOZRz`30)27 ziJ-x{PVLr1h8peeuIZxfzwE*gc5xyG;9dkY*%8M+QNf6==0X+JY7d&7Fa|=wM1cA* znozNam!W%ea_9n_yu^Lp5RFkrAec}g4xH)Td}*82!O5 zk|ToZ(?dEeC7WbpBDkFV18TqvRLC1fO&}On2XtcPg9n_(^}_sMf?c9&k_IAG@9qwg zIs+cU=2}S~1+?{;1v%-y-t^M z6uu&7cq3?9PL3GG;508TQVQfXJ zf(YyLCgA5Fu1;L}UW6IYxaeRwhH)1Bc4aMMjCv{>r%n9)Y9g74At0j31OlFh?=D^E zO%Sn*?g?Uu?iKvhj@%Ui&jxWbGZ4sqpz?*1elU}G3M(k-R^?ExMiC#B3ryBb`L zF1M?x)=}%Mb=B6^*45V6Hqzf)J4bBEvLv2G{Lw!R-Lt}%xp{dc)=xlT~);88P);BgZHa5B&o7@h! z)9rHCy6fEa?gn?G+wE>@f{9H~-vrT3U~2-3T4cGh^rQXAEAeL>nV5J8dF#(7CZ0pS z4*BcIFZq{=33^q+_KS&$VdT%@;YV31_Qu5nt=*`98+jQXYz^bF7QLHoU`5{ncpV-~ zh6s;*55aht_%?ESPF?{zJTYU*a@QKg(qCn{a?Xs3U8pBK-ndc%(DPxUXq~|xOW07X z!{6xPi3xf;Myae?QCWSnb#`CHpz`r6mVBhvHXm5VkKb}Bfuj=>jFFH?B-g;-Cg4Yj zU!;Y|cj7MtecK4Ptg4(5d!-#Ct^5m!oI zbfS*h{af%HL|&GF;hj01?WD7n>vWW?F3{ON`_l!O7J7k|?AQV^8_-YGzm)KJT_NAs zp|2u@=%}n3D!F}DWyQd3^5sKB5C6PKbvIX5eUbc=E=8MnlK$;zvt#I@l3Sqz7E!7fY8_s_C+2l?!*4w^TZ| zU({0R9-6VTa-Z5!xv#mSa%_ER&n2@e-Ovx;gEps|wN-^EKV&aPpO+#p`w%|BG&E!T zMLWyWWnV1)QVF$vC*tww*u=yQC@XE1RohE$t7MPb08UI;X5HKc4?JNhXV3{t425r4 z${(#!Ua{=4ykp1yeC1iYk}fMhWLLg(Q8_UCXO#cMt~^#z{;*wnW@h;VcID`-@~_&J zU(6{_fqqWP@?MQ{-=*aTYLu^DR{nI2@}27PKh-Gd%gcXfSN2_DMS)=^; zy7FJwDEHXPzh9#~Yb(F+M&;WJ%F{P0rx%oOy;1qi^|0ly*Ox%r-bEh+(_cPvJ=Fi? zhQ+`PFD9A078CjXi%HDOi%HDs#U$phi%I*A8WQuZ8d8O(mcKx)oKDjonD)T52c|tR z?SW|zOnYG31JfRu_Q13Uradt2foTu?_ws<;UtjL0FZa*KN(oDJwg4pwpDciB?|({j zc%~>SFW@)zENxoGbK0YalHA`PI~K7d*XZP)<8nWLxi%)(VcvOvf<5@zEA+`_iWqx0 zTAtm6^)*pHOUU0<#tXFeMTypXDB-6sc}cJ-C&YgFSQ27sk0{XkAtj+s!BQ1V{j_+B zB=OCn;nHrma2)-Bm6Fsi_e_^`_=OB>AIKRBZ~Zd3Q#cl{Co@>uiC+&hSUMg*C}Xf_ zE?4%S_$kjzD-`KaIi^|Uog&{P@|eg6MZQbqdqlohsbvki6 ziKE7uWT5l&E|=X=XRlo(D)Kq=Aq&7VE{Dqg4N(X zOF7{-LBCSq9UtR}7X^ME6chdEbsRzKsg$k}csYKsHB{hhfw$m9i_hM}$a3SyGT^7O zpU&eV`C~V6dii;)z*h-7CB94G<#;9W{lJsnSb^SQhQCaay$4e3f#AQavaTBC7#@D*4Nc;B6SyRLD^Z z_C#^*Y|**&rx}w2a3plj?u#p&754?Z?}-0Rmw3N$I~+gN@t76nGXXV z0LR#;INi^ArrZYn+>$Gl!t1&Rc=DU0nG+rq^bY`kE$FKTIYN%}KLvg&zr8~ElDUe! zUkWkG>qY2i;s!XC9QtYcRCqh^WasG?E}1>&LGg(q^uH>CFURk8r;>kZ5&S|locu4( zKV{GKpm$Lb`fw5a-9_-bi{O7q_!8VaRo-6t`6%$zkA?AkpopBSabusVy@!kVCsB+Z z_^HmX`z`R)FY3p+h3vT^iu2(&y6%)gX*thtWcVwU{(JKAbkjeZ~t0E&Ra$B7h@tcl|OrdpUMyWfmhFOmU5zqoHvW$mtjIUm7Gr$!EXkB zs&fdp0zZ}g^ctMuv`2B4nYSzG!K*av^(efwgqMr#cvFgw!^0VDD?4so*3#ketoGoY zfp9WmcyO3nS2Wbt*_%6R&{dbjdkc8?1_$sNbUYl68pQiTc&(y6+~y16^gCBAUJAj> zQ4!r^^s$2uJvg%vZv(Z5JHoZDwvKRvs}7&FO^x*F#D}ZC0WTNSHaP16I0;kd!V3Wn zjjkrb5FNc<(ByJA)H#TRuyqa>-Y;-8){{u5n|(O(wn8o5PH004uUY4zll&YWr-R<% z2z!iynBHa&CgNV4%SOk=*?rNFU&*yQk?iuc^K<0rw80Ly#}kawiGT|HmoV@qp&jqn z<<4*P80=ugh@OCB{ZS8IpX&04Jbt{mmGF3z{R-ZxiG_5$eFSw=Qm_LLJ>GcSI{+UU zcqgwLcIqB~GK_Z_JsuMTcL$9;QjcfFnwC{c&KfK@gs+($kB77E=oGvE z$RU-SK^(P*Q}P%OBK(u=wK{e#WjjI|r(v#jdfM7rU8Gd^dkyd8*@5RP{aa5^EI4K} z&nTSa=V`wcXA9!3DG%PVLm1|rZJ4K>pIDfO`uAES58Nzza;VuF_=kc4@s8I!@oKOW zB_oO>5AjYXt{zViaom1qTU&3Z?@pXsNp!&;94HE#{fVdtFG}HnM$s|+%|)|!m%G~N z=uQgYAUn4)j$<1W26O$ITh_WfO6#ZWalPAK{M^qXr%mR!fld#FMRWwC=}5?&*Kio3 zIP1}j;>SMbFgQH%ztDBzW5=noxZp=v=IqQ}2n7eRPM)I5N#!Nr<}ZHd_?a`*+=tUl3oz_-+2+ZUFmd2z;jx-IGtdES$n|(|KCT?x{xi>hHQPppT*v{s z=N(v@CzP)33zC|5;_CS#>G)i{t7)#dFh$Fq?KlOujSm0C1z1aW4;uUURfB;r;uk*x z;Hoh2B3}52Q~catvsreWVTwTur>*8B1Pgf!#?PFtA-uc3GQm$D) z8g%{E&Wz4GB)3_fID0{g^G^&uuPsv? zWL@;IYIer6Sps)O{@KQKB6J>&V#j0?Z%89Y;IKR(OPFfv@t9)Ai}Sj@Cz7<|m214B zi=SppqPmj7kiQ1+)N>Th+^wOBE+Y?k69L8UABfQ5z$`c7yoUY6126qEG68r{7st;{ zh)|SbAw#jVVabl79dEKLtdP)siXDdw17vp}dpyb}5q3QwCT{^hq)7;Ba8$6~@6%%j zrr~t%H<+vaet#e!Ohh-O*XfG8?GQWz0 zxlf`sr|SUVD`Jk{E%;^bFcYJUuU&bm0g#!WHuj_>a~lV9pIC0lOYMNn{BnI!=4u}G zo-e<_IrV)wR_1g*Y{<ZZCCMlApP@Xofm4Zb6G;*?E^2_yRnQtkeFVz2=0{(@c<_t1_Pzn}u3d~VX7VsYv>*F$~cjC>d zpgWW^sH8`1;xf;z$L|esu0nrH{W7QL6jl?zT>l>x{F07Kn~Z;nw}PKyU+S0Z@<*k9 z(SEvqDM>zQ*AIZBHc5W@eDQ=>fL&P7V977@YSf$gl|cxj6yuq^&N(!xU&8dtJIRvz zn+JJKwcvLb^j|Zt&V61d^gD7GUXc7X1$eTRk~uF>!Rj@kf3I*HT}GGTFV}8H#h+_Q k+XaecXZtwxU988MlhiA5b5WRz|C{%4ip2#C1)%bO05!tuq5uE@ literal 0 HcmV?d00001 diff --git a/demo/cpp-shared-lib/cpp-logger.cpp b/demo/cpp-shared-lib/cpp-logger.cpp new file mode 100644 index 0000000..341f823 --- /dev/null +++ b/demo/cpp-shared-lib/cpp-logger.cpp @@ -0,0 +1,170 @@ +#include "../../liblogjet/include/liblogjet.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using version_fn = const char *(*)(); +using error_fn = const char *(*)(); +using new_http_fn = lj_logger *(*)(const char *, const char *, std::uint64_t); +using new_grpc_fn = lj_logger *(*)(const char *, const char *, std::uint64_t); +using free_fn = void (*)(lj_logger *); +using log_fn = bool (*)(lj_logger *, const lj_log_record *); + +struct api { + version_fn version; + error_fn error_message; + new_http_fn new_http; + new_grpc_fn new_grpc; + free_fn free_logger; + log_fn log_record; +}; + +std::int32_t info_severity() { + return LJ_SEVERITY_INFO; +} + +const char *pick(const std::vector &values, std::mt19937 &rng) { + std::uniform_int_distribution dist(0, values.size() - 1); + return values[dist(rng)]; +} + +std::uint64_t unix_time_nanos() { + auto now = std::chrono::system_clock::now().time_since_epoch(); + return static_cast(std::chrono::duration_cast(now).count()); +} + +void *must_symbol(void *handle, const char *name) { + dlerror(); + void *symbol = dlsym(handle, name); + const char *error = dlerror(); + if (error != nullptr) { + std::cerr << "dlsym failed for " << name << ": " << error << "\n"; + std::exit(1); + } + return symbol; +} + +api load_api(void *handle) { + return api{ + reinterpret_cast(must_symbol(handle, "lj_version")), + reinterpret_cast(must_symbol(handle, "lj_error_message")), + reinterpret_cast(must_symbol(handle, "lj_logger_new_http")), + reinterpret_cast(must_symbol(handle, "lj_logger_new_grpc")), + reinterpret_cast(must_symbol(handle, "lj_logger_free")), + reinterpret_cast(must_symbol(handle, "lj_logger_log")), + }; +} + +} // namespace + +int main(int argc, char **argv) { + const std::string so_path = argc > 1 ? argv[1] : "./liblogjet.so"; + const std::string endpoint = argc > 2 ? argv[2] : "127.0.0.1:4317"; + const int message_count = argc > 3 ? std::atoi(argv[3]) : 25; + + void *handle = dlopen(so_path.c_str(), RTLD_NOW | RTLD_LOCAL); + if (handle == nullptr) { + std::cerr << "dlopen failed: " << dlerror() << "\n"; + return 1; + } + + const api lib = load_api(handle); + std::cout << "loaded liblogjet version " << lib.version() << "\n"; + + std::mt19937 rng(std::random_device{}()); + const std::vector characters = { + "Bender", "Fry", "Leela", "Professor Farnsworth", "Zoidberg", + "Amy", "Hermes", "Nibbler", "Scruffy", "Calculon", + }; + const std::vector locations = { + "Planet Express", "New New York", "The Moon", "Mars University", + "Robot Hell", "Slurm factory", "Omicron Persei 8", "Bender's fun park", + }; + const std::vector attractions = { + "blackjack dome", "dark matter coaster", "hooker-bot lounge", + "slurm chute", "robot petting zoo", "delivery cannon", + }; + const std::vector moods = { + "greedy", "heroic", "dramatic", "sleepy", + "chaotic", "optimistic", "hungry", "unbothered", + }; + const std::vector schemes = { + "casino expansion", "fun park launch", "delivery detour", + "robot uprising rehearsal", "slurm promotion", "budget evaporation", + }; + const std::vector quotes = { + "Bender promised a classy fun park financed mostly by blackjack.", + "Fry pressed the glowing button because hesitation felt off-brand.", + "Leela requested a routine delivery and got stylish chaos instead.", + "The Professor called this outage a perfectly normal science moment.", + "Zoidberg celebrated because nobody had blamed him yet.", + "Hermes filed the disaster under efficient bureaucratic progress.", + "Amy said the ship felt stable, which worried everyone instantly.", + "Nibbler stared into the void like it owed him money.", + "Scruffy fixed the panel and resumed mopping without commentary.", + "Calculon demanded better lighting for the emergency landing.", + "Bender unveiled a premium attraction featuring hooker-bots and bad odds.", + "The crew found a shortcut through poor planning and dark matter.", + "Mission control agreed this was still cheaper than preparation.", + "Someone ordered suspicious robot bees and called it innovation.", + "The delivery manifest now includes one crate of dramatic overreaction.", + }; + + lj_logger *logger = lib.new_grpc(endpoint.c_str(), "cpp-appliance", 2000); + if (logger == nullptr) { + std::cerr << "lj_logger_new_grpc failed: " << lib.error_message() << "\n"; + dlclose(handle); + return 1; + } + + for (int index = 1; index <= message_count; ++index) { + const std::string sequence = std::to_string(index); + const std::string character = pick(characters, rng); + const std::string location = pick(locations, rng); + const std::string attraction = pick(attractions, rng); + const std::string mood = pick(moods, rng); + const std::string scheme = pick(schemes, rng); + const std::string message = + std::string(pick(quotes, rng)) + " character=" + character + " location=" + location; + const lj_attribute attributes[] = { + {"appliance.kind", "cpp-demo"}, + {"appliance.sequence", sequence.c_str()}, + {"character", character.c_str()}, + {"location", location.c_str()}, + {"attraction", attraction.c_str()}, + {"mood", mood.c_str()}, + {"scheme", scheme.c_str()}, + }; + const lj_log_record record{ + unix_time_nanos(), + info_severity(), + "INFO", + message.c_str(), + attributes, + sizeof(attributes) / sizeof(attributes[0]), + }; + + if (!lib.log_record(logger, &record)) { + std::cerr << "lj_logger_log failed: " << lib.error_message() << "\n"; + lib.free_logger(logger); + dlclose(handle); + return 1; + } + + std::cout << "sent: " << message << "\n"; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + + lib.free_logger(logger); + dlclose(handle); + return 0; +} diff --git a/demo/cpp-shared-lib/liblogjet.so b/demo/cpp-shared-lib/liblogjet.so new file mode 120000 index 0000000..3a3e4e9 --- /dev/null +++ b/demo/cpp-shared-lib/liblogjet.so @@ -0,0 +1 @@ +/home/boma6672/work/logjet/demo/cpp-shared-lib/../../target/debug/libliblogjet.so \ No newline at end of file diff --git a/demo/cpp-shared-lib/ljd.conf b/demo/cpp-shared-lib/ljd.conf new file mode 100644 index 0000000..909904e --- /dev/null +++ b/demo/cpp-shared-lib/ljd.conf @@ -0,0 +1,6 @@ +output: file +file.path: ./logs +file.size: 1048576 +file.name: cpp-demo.logjet +ingest.protocol: otlp-grpc +ingest.listen: 127.0.0.1:4317 diff --git a/demo/cpp-shared-lib/run-demo.sh b/demo/cpp-shared-lib/run-demo.sh new file mode 100755 index 0000000..696c79c --- /dev/null +++ b/demo/cpp-shared-lib/run-demo.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +TARGET_DIR="$SCRIPT_DIR/../../target/debug" +LJD="$TARGET_DIR/ljd" +LJX="$TARGET_DIR/ljx" +LIB_SRC="$TARGET_DIR/libliblogjet.so" +LIB_DST="$SCRIPT_DIR/liblogjet.so" +CPP_SRC="$SCRIPT_DIR/cpp-logger.cpp" +CPP_BIN="$SCRIPT_DIR/cpp-logger" +CONFIG="$SCRIPT_DIR/ljd.conf" + +for bin in "$LJD" "$LJX" "$LIB_SRC"; do + if [ ! -e "$bin" ]; then + echo "missing $bin" + echo "build everything first with: cargo build -p ljd -p ljx -p liblogjet" + exit 1 + fi +done + +if ! command -v g++ >/dev/null 2>&1; then + echo "g++ not found" + echo "install a C++ compiler to run this demo" + exit 1 +fi + +mkdir -p "$SCRIPT_DIR/logs" +ln -sf "$LIB_SRC" "$LIB_DST" + +echo "building C++ example" +g++ -std=c++17 -Wall -Wextra -pedantic -O2 -I"$SCRIPT_DIR/../../liblogjet/include" "$CPP_SRC" -ldl -o "$CPP_BIN" + +echo "starting ljd with file-backed OTLP ingest" +"$LJD" --config "$CONFIG" serve & +LJD_PID=$! + +cleanup() { + kill "${LJD_PID:-}" 2>/dev/null || true +} + +trap cleanup EXIT INT TERM + +sleep 1 + +echo "sending logs from C++ through liblogjet.so into ljd over OTLP/gRPC" +"$CPP_BIN" "$LIB_DST" "127.0.0.1:4317" 25 + +sleep 1 + +echo "opening ljx view on ./logs/cpp-demo.logjet" +"$LJX" view "$SCRIPT_DIR/logs/cpp-demo.logjet" From b28e3359d4b055f1ee48f57f8a4d4b7cf9b3037d Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 16:28:55 +0100 Subject: [PATCH 04/12] Update deps --- Cargo.lock | 10 ++++++++++ Cargo.toml | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3582156..b28baae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,6 +748,16 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "liblogjet" +version = "0.1.0" +dependencies = [ + "opentelemetry-proto", + "prost", + "tokio", + "tonic", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" diff --git a/Cargo.toml b/Cargo.toml index c09338b..068cf69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,8 @@ edition = "2024" license = "Apache-2.0" [workspace] -members = [".", "logjetd", "demo", "ljx"] -default-members = [".", "logjetd", "demo", "ljx"] +members = [".", "logjetd", "demo", "ljx", "liblogjet"] +default-members = [".", "logjetd", "demo", "ljx", "liblogjet"] [profile.release] lto = true From 7ac724d33fb543e003d3f8d2c75cdc14d448d552 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 16:29:12 +0100 Subject: [PATCH 05/12] Bump versions --- Cargo.lock | 71 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b28baae..240226b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -34,15 +34,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -212,9 +212,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -243,9 +243,9 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -253,9 +253,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -266,9 +266,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -278,15 +278,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -691,9 +691,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", @@ -744,9 +744,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "liblogjet" @@ -838,9 +838,9 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" [[package]] name = "matchit" @@ -883,9 +883,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1195,7 +1195,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1828,6 +1828,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -1977,18 +1986,18 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", From 589344c795539b12249fa8e5a32f10366d674184 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 16:43:34 +0100 Subject: [PATCH 06/12] Update docs --- README.md | 1 + doc/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 03400c4..e54a4c1 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ fn replay_batches() -> Result<(), Box> { ## Notes - Examples for standalone usage live in [examples](./examples). +- C and C++ shared-library usage lives in [doc/c-cpp-integration.md](./doc/c-cpp-integration.md). - The reader is sequential by design. - Compression is per block, not per file. - The payload bytes are opaque to `logjet`. diff --git a/doc/README.md b/doc/README.md index 7d496d3..300b275 100644 --- a/doc/README.md +++ b/doc/README.md @@ -1,6 +1,7 @@ # Documentation - [overview.md](./overview.md): project overview +- [c-cpp-integration.md](./c-cpp-integration.md): minimal `liblogjet` usage from C and C++ - [ljx.md](./ljx.md): `ljx` offline CLI scope and command plan - [daemon.md](./daemon.md): `ljd` behaviour and current limits - [configuration.md](./configuration.md): YAML keys and defaults From f7fe342074a2398eeb205868cfcb04ff3e99e100 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 16:43:50 +0100 Subject: [PATCH 07/12] Add C++ integration doc + example --- doc/c-cpp-integration.md | 97 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 doc/c-cpp-integration.md diff --git a/doc/c-cpp-integration.md b/doc/c-cpp-integration.md new file mode 100644 index 0000000..a374420 --- /dev/null +++ b/doc/c-cpp-integration.md @@ -0,0 +1,97 @@ +# C/C++ Integration + +`liblogjet` is a shared library for C and C++ callers that want to emit OTLP +logs into `ljd` without embedding Rust directly in the appliance application. + +The ABI is intentionally small: + +- create a logger for OTLP/HTTP or OTLP/gRPC +- send one log record at a time +- provide: + - message body as a string + - severity + - timestamp in Unix nanoseconds + - zero or more string key/value attributes + +Those key/value pairs become OTLP `LogRecord.attributes`, which is the standard +OpenTelemetry metadata field for logs. + +## Build + +From the project root: + +```bash +cargo build -p liblogjet +``` + +Header: + +```text +liblogjet/include/liblogjet.h +``` + +Shared object: + +```text +target/debug/libliblogjet.so +``` + +## Minimal C++ Example + +This is the smallest useful flow: + +1. load the `.so` +2. resolve the needed symbols +3. create a logger +4. send one warning-level log + +```cpp +#include "liblogjet.h" +#include + +using new_grpc_fn = lj_logger *(*)(const char *, const char *, uint64_t); +using log_fn = bool (*)(lj_logger *, const lj_log_record *); +using free_fn = void (*)(lj_logger *); +using err_fn = const char *(*)(); + +int main() { + void *so = dlopen("./liblogjet.so", RTLD_NOW | RTLD_LOCAL); + auto lj_logger_new_grpc = reinterpret_cast(dlsym(so, "lj_logger_new_grpc")); + auto lj_logger_log = reinterpret_cast(dlsym(so, "lj_logger_log")); + auto lj_logger_free = reinterpret_cast(dlsym(so, "lj_logger_free")); + auto lj_error_message = reinterpret_cast(dlsym(so, "lj_error_message")); + + lj_logger *logger = lj_logger_new_grpc("127.0.0.1:4317", "hello-cpp", 2000); + if (logger == nullptr) return 1; + + const lj_attribute attrs[] = { + {"tag", "hello-world"}, + {"version", "2.04"}, + }; + const lj_log_record record{ + 1700000000000000000ULL, // timestamp in unix ns + LJ_SEVERITY_WARN, // severity number + "WARN", // severity text + "Biohazard: C++ is in use!", // :-) + attrs, // attributes + sizeof(attrs) / sizeof(attrs[0]), // attributes (len) + }; + + if (!lj_logger_log(logger, &record)) { + const char *err = lj_error_message(); + (void)err; + } + + lj_logger_free(logger); + return 0; +} +``` + +## Notes + +- use `lj_logger_new_http(...)` for OTLP/HTTP +- use `lj_logger_new_grpc(...)` for OTLP/gRPC +- strings must be valid UTF-8 +- attribute keys and values are currently string-only by design +- richer C++ usage lives in the demo: + - [`demo/cpp-shared-lib`](../demo/cpp-shared-lib) From 19ea7287ade65a813c3551fa87118a497b8edaf3 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 16:44:02 +0100 Subject: [PATCH 08/12] Lintfixes + refactor --- liblogjet/src/lib.rs | 123 +++++++++++++++++++++++++------------------ 1 file changed, 72 insertions(+), 51 deletions(-) diff --git a/liblogjet/src/lib.rs b/liblogjet/src/lib.rs index 95d3139..449a70e 100644 --- a/liblogjet/src/lib.rs +++ b/liblogjet/src/lib.rs @@ -87,21 +87,29 @@ impl HttpEndpoint { impl LjLogger { fn new_http(endpoint: &str, service_name: &str, timeout_ms: u64) -> io::Result { - let timeout = Duration::from_millis(timeout_ms.max(1)); - Ok(Self { transport: Transport::Http(HttpEndpoint::parse(endpoint)?), service_name: service_name.to_string(), timeout }) + Ok(Self { + transport: Transport::Http(HttpEndpoint::parse(endpoint)?), + service_name: service_name.to_string(), + timeout: Duration::from_millis(timeout_ms.max(1)), + }) } fn new_grpc(endpoint: &str, service_name: &str, timeout_ms: u64) -> io::Result { - let timeout = Duration::from_millis(timeout_ms.max(1)); - let runtime = Runtime::new().map_err(io::Error::other)?; - Ok(Self { transport: Transport::Grpc { endpoint: GrpcEndpoint::parse(endpoint)?, runtime }, service_name: service_name.to_string(), timeout }) + Ok(Self { + transport: Transport::Grpc { endpoint: GrpcEndpoint::parse(endpoint)?, runtime: Runtime::new().map_err(io::Error::other)? }, + service_name: service_name.to_string(), + timeout: Duration::from_millis(timeout_ms.max(1)), + }) } fn log(&self, record: LogRecordInput) -> io::Result<()> { - let batch = build_logs_request(&self.service_name, record, self.transport_name()); match &self.transport { - Transport::Http(endpoint) => post_otlp_http(endpoint, self.timeout, &batch.encode_to_vec()), - Transport::Grpc { endpoint, runtime } => runtime.block_on(post_otlp_grpc(endpoint, self.timeout, batch)), + Transport::Http(endpoint) => { + post_otlp_http(endpoint, self.timeout, &build_logs_request(&self.service_name, record, self.transport_name()).encode_to_vec()) + } + Transport::Grpc { endpoint, runtime } => { + runtime.block_on(post_otlp_grpc(endpoint, self.timeout, build_logs_request(&self.service_name, record, self.transport_name()))) + } } } @@ -126,25 +134,32 @@ pub extern "C" fn lj_error_message() -> *const c_char { #[unsafe(no_mangle)] pub extern "C" fn lj_logger_new_http(endpoint: *const c_char, service_name: *const c_char, timeout_ms: u64) -> *mut LjLogger { ffi_new(|| { - let endpoint = required_cstr(endpoint, "endpoint")?; - let service_name = required_cstr(service_name, "service_name")?; - let logger = LjLogger::new_http(&endpoint, &service_name, timeout_ms)?; - Ok(Box::into_raw(Box::new(logger))) + Ok(Box::into_raw(Box::new(LjLogger::new_http( + &required_cstr(endpoint, "endpoint")?, + &required_cstr(service_name, "service_name")?, + timeout_ms, + )?))) }) } #[unsafe(no_mangle)] pub extern "C" fn lj_logger_new_grpc(endpoint: *const c_char, service_name: *const c_char, timeout_ms: u64) -> *mut LjLogger { ffi_new(|| { - let endpoint = required_cstr(endpoint, "endpoint")?; - let service_name = required_cstr(service_name, "service_name")?; - let logger = LjLogger::new_grpc(&endpoint, &service_name, timeout_ms)?; - Ok(Box::into_raw(Box::new(logger))) + Ok(Box::into_raw(Box::new(LjLogger::new_grpc( + &required_cstr(endpoint, "endpoint")?, + &required_cstr(service_name, "service_name")?, + timeout_ms, + )?))) }) } #[unsafe(no_mangle)] -pub extern "C" fn lj_logger_free(logger: *mut LjLogger) { +/// # Safety +/// +/// `logger` must be either null or a pointer previously returned by +/// `lj_logger_new_http` or `lj_logger_new_grpc` that has not already been +/// freed. +pub unsafe extern "C" fn lj_logger_free(logger: *mut LjLogger) { if logger.is_null() { return; } @@ -157,7 +172,13 @@ pub extern "C" fn lj_logger_free(logger: *mut LjLogger) { } #[unsafe(no_mangle)] -pub extern "C" fn lj_logger_log(logger: *mut LjLogger, record: *const lj_log_record) -> bool { +/// # Safety +/// +/// `logger` must be a valid pointer returned by `lj_logger_new_http` or +/// `lj_logger_new_grpc`. `record` must be a valid pointer for the duration of +/// this call, and all nested C strings and attribute pointers referenced by +/// `record` must also remain valid for the duration of the call. +pub unsafe extern "C" fn lj_logger_log(logger: *mut LjLogger, record: *const lj_log_record) -> bool { ffi_bool(|| { if logger.is_null() { return Err(io::Error::new(io::ErrorKind::InvalidInput, "logger must not be null")); @@ -166,21 +187,13 @@ pub extern "C" fn lj_logger_log(logger: *mut LjLogger, record: *const lj_log_rec return Err(io::Error::new(io::ErrorKind::InvalidInput, "record must not be null")); } - // SAFETY: caller guarantees a valid pointer for the duration of this call. - let logger = unsafe { &*logger }; - // SAFETY: caller guarantees a valid pointer for the duration of this call. - let record = unsafe { &*record }; - logger.log(parse_record(record)?)?; + // SAFETY: caller guarantees valid pointers for the duration of this call. + unsafe { (&*logger).log(parse_record(&*record)?)? }; Ok(()) }) } fn build_logs_request(service_name: &str, record: LogRecordInput, transport_name: &str) -> ExportLogsServiceRequest { - let severity_text = record.severity_text.unwrap_or_else(|| default_severity_text(record.severity_number).to_string()); - - let mut attributes = vec![string_attr("liblogjet.transport", transport_name), string_attr("liblogjet.runtime", "cpp-ffi")]; - attributes.extend(record.attributes.into_iter().map(|(key, value)| string_attr(&key, &value))); - ExportLogsServiceRequest { resource_logs: vec![ResourceLogs { resource: Some(Resource { attributes: vec![string_attr("service.name", service_name)], dropped_attributes_count: 0 }), @@ -195,9 +208,12 @@ fn build_logs_request(service_name: &str, record: LogRecordInput, transport_name time_unix_nano: record.timestamp_unix_ns, observed_time_unix_nano: record.timestamp_unix_ns, severity_number: normalized_severity(record.severity_number), - severity_text, + severity_text: record.severity_text.unwrap_or_else(|| default_severity_text(record.severity_number).to_string()), body: Some(AnyValue { value: Some(Value::StringValue(record.body)) }), - attributes, + attributes: std::iter::once(string_attr("liblogjet.transport", transport_name)) + .chain(std::iter::once(string_attr("liblogjet.runtime", "cpp-ffi"))) + .chain(record.attributes.into_iter().map(|(key, value)| string_attr(&key, &value))) + .collect(), dropped_attributes_count: 0, flags: 0, trace_id: Vec::new(), @@ -259,23 +275,13 @@ async fn post_otlp_grpc(endpoint: &GrpcEndpoint, timeout: Duration, batch: Expor } fn parse_record(record: &lj_log_record) -> io::Result { - let body = required_cstr(record.body, "record.body")?; - let severity_text = optional_cstr(record.severity_text, "record.severity_text")?; - let mut attributes = Vec::with_capacity(record.attributes_len); - - if record.attributes_len > 0 { - if record.attributes.is_null() { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "record.attributes is null while attributes_len is non-zero")); - } - - // SAFETY: pointer validity is checked above and length is provided by the caller. - let slice = unsafe { std::slice::from_raw_parts(record.attributes, record.attributes_len) }; - for attr in slice { - attributes.push((required_cstr(attr.key, "attribute.key")?, required_cstr(attr.value, "attribute.value")?)); - } - } - - Ok(LogRecordInput { timestamp_unix_ns: record.timestamp_unix_ns, severity_number: record.severity_number, severity_text, body, attributes }) + Ok(LogRecordInput { + timestamp_unix_ns: record.timestamp_unix_ns, + severity_number: record.severity_number, + severity_text: optional_cstr(record.severity_text, "record.severity_text")?, + body: required_cstr(record.body, "record.body")?, + attributes: parse_attributes(record.attributes, record.attributes_len)?, + }) } fn required_cstr(ptr: *const c_char, field: &str) -> io::Result { @@ -295,6 +301,21 @@ fn optional_cstr(ptr: *const c_char, field: &str) -> io::Result> required_cstr(ptr, field).map(Some) } +fn parse_attributes(attributes: *const lj_attribute, attributes_len: usize) -> io::Result> { + if attributes_len == 0 { + return Ok(Vec::new()); + } + if attributes.is_null() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "record.attributes is null while attributes_len is non-zero")); + } + + // SAFETY: pointer validity is checked above and length is provided by the caller. + unsafe { std::slice::from_raw_parts(attributes, attributes_len) } + .iter() + .map(|attr| Ok((required_cstr(attr.key, "attribute.key")?, required_cstr(attr.value, "attribute.value")?))) + .collect() +} + fn normalized_severity(value: i32) -> i32 { if value == 0 { SeverityNumber::Info as i32 } else { value } } @@ -430,8 +451,8 @@ mod tests { attributes_len: attributes.len(), }; - assert!(lj_logger_log(logger, &record)); - lj_logger_free(logger); + assert!(unsafe { lj_logger_log(logger, &record) }); + unsafe { lj_logger_free(logger) }; let batch = server.join().unwrap(); let resource = &batch.resource_logs[0].resource.as_ref().unwrap().attributes; @@ -480,8 +501,8 @@ mod tests { attributes_len: attributes.len(), }; - assert!(lj_logger_log(logger, &record)); - lj_logger_free(logger); + assert!(unsafe { lj_logger_log(logger, &record) }); + unsafe { lj_logger_free(logger) }; runtime.block_on(async { for _ in 0..50 { From c77dbc53631cc07777a1ba38bf9b42bef0249635 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 16:48:38 +0100 Subject: [PATCH 09/12] Sync docs --- demo/cpp-shared-lib/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/cpp-shared-lib/README.md b/demo/cpp-shared-lib/README.md index fb2a756..e7e8bde 100644 --- a/demo/cpp-shared-lib/README.md +++ b/demo/cpp-shared-lib/README.md @@ -33,7 +33,7 @@ The script: 1. builds the example C++ logger 2. starts file-backed `ljd` on `127.0.0.1:4317` 3. loads `liblogjet.so` through `dlopen` -4. sends five OTLP log records from C++ +4. sends 25 OTLP log records from C++ by default 5. opens `ljx view` on the resulting `./logs/cpp-demo.logjet` ## Notes From d522cb846b9b8330ba8652e16caeef37b30129fa Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 16:48:51 +0100 Subject: [PATCH 10/12] liblogjet.h is anyway in the path --- demo/cpp-shared-lib/cpp-logger.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/cpp-shared-lib/cpp-logger.cpp b/demo/cpp-shared-lib/cpp-logger.cpp index 341f823..11e31bb 100644 --- a/demo/cpp-shared-lib/cpp-logger.cpp +++ b/demo/cpp-shared-lib/cpp-logger.cpp @@ -1,4 +1,4 @@ -#include "../../liblogjet/include/liblogjet.h" +#include #include #include From 0a1c53b7866d99fbddb055b0bc44f6f267d81b74 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 17:08:35 +0100 Subject: [PATCH 11/12] Add comments --- liblogjet/include/liblogjet.h | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/liblogjet/include/liblogjet.h b/liblogjet/include/liblogjet.h index 5c89443..4e84f85 100644 --- a/liblogjet/include/liblogjet.h +++ b/liblogjet/include/liblogjet.h @@ -9,8 +9,12 @@ extern "C" { #endif +/* Public C ABI for sending OTLP log records through liblogjet. */ + +/* Opaque logger handle created by lj_logger_new_http/grpc and freed by lj_logger_free. */ typedef struct lj_logger lj_logger; +/* Selected OTLP severity_number values for common log levels. */ enum { LJ_SEVERITY_TRACE = 1, LJ_SEVERITY_DEBUG = 5, @@ -32,20 +36,41 @@ typedef struct lj_log_record { uint64_t timestamp_unix_ns; /* OTel severity_number, for example LJ_SEVERITY_INFO */ int32_t severity_number; - /* OTel severity_text, for example "INFO"; may be NULL */ + /* OTel severity_text, for example "INFO"; UTF-8, NUL-terminated, may be NULL */ const char *severity_text; - /* OTel body string */ + /* OTel body string; UTF-8, NUL-terminated, must not be NULL */ const char *body; - /* Arbitrary OTLP LogRecord string attributes */ + /* Arbitrary OTLP LogRecord string attributes; may be NULL only when attributes_len == 0 */ const struct lj_attribute *attributes; + /* Number of entries in attributes */ size_t attributes_len; } lj_log_record; +/* Returns the liblogjet version string as a static NUL-terminated string. */ const char *lj_version(void); + +/* Returns the calling thread's last liblogjet error message as a static string view. */ const char *lj_error_message(void); + +/* Creates an OTLP/HTTP logger. + * endpoint must be UTF-8 and NUL-terminated, using http://host:port[/path] or bare host:port[/path]. + * https:// is rejected for this constructor. Returns NULL on failure. + */ lj_logger *lj_logger_new_http(const char *endpoint, const char *service_name, uint64_t timeout_ms); + +/* Creates an OTLP/gRPC logger. + * endpoint must be UTF-8 and NUL-terminated, using host:port or an explicit http/https URL. + * Returns NULL on failure. + */ lj_logger *lj_logger_new_grpc(const char *endpoint, const char *service_name, uint64_t timeout_ms); + +/* Frees a logger created by lj_logger_new_http or lj_logger_new_grpc. Accepts NULL. */ void lj_logger_free(lj_logger *logger); + +/* Sends one OTLP log record through logger. + * logger and record must be valid for the duration of the call. + * Returns false on failure; inspect lj_error_message() for details. + */ bool lj_logger_log(lj_logger *logger, const lj_log_record *record); #ifdef __cplusplus From 4a6b01e705c9efa3591997b96b9b3c83a4850181 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 17 Mar 2026 17:08:50 +0100 Subject: [PATCH 12/12] Move out UTs to their own files --- liblogjet/src/lib.rs | 180 ++---------------------------------- liblogjet/src/lib_ut.rs | 175 +++++++++++++++++++++++++++++++++++ ljx/src/commands/view.rs | 160 +++++++------------------------- ljx/src/commands/view_ut.rs | 171 ++++++++++++++++++++++++++++++++++ ljx/src/predicate.rs | 87 +---------------- ljx/src/predicate_ut.rs | 78 ++++++++++++++++ 6 files changed, 466 insertions(+), 385 deletions(-) create mode 100644 liblogjet/src/lib_ut.rs create mode 100644 ljx/src/commands/view_ut.rs create mode 100644 ljx/src/predicate_ut.rs diff --git a/liblogjet/src/lib.rs b/liblogjet/src/lib.rs index 449a70e..0e0953c 100644 --- a/liblogjet/src/lib.rs +++ b/liblogjet/src/lib.rs @@ -68,6 +68,12 @@ enum Transport { impl HttpEndpoint { fn parse(input: &str) -> io::Result { + if input.trim().starts_with("https://") { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "https endpoints are not supported by lj_logger_new_http; use http:// or lj_logger_new_grpc", + )); + } let value = input.strip_prefix("http://").unwrap_or(input).trim(); if value.is_empty() { return Err(io::Error::new(io::ErrorKind::InvalidInput, "endpoint must not be empty")); @@ -393,176 +399,4 @@ fn ffi_bool(func: impl FnOnce() -> io::Result<()> + std::panic::UnwindSafe) -> b } #[cfg(test)] -mod tests { - use super::*; - use std::net::TcpListener; - use std::sync::{Arc, Mutex}; - use std::thread; - use tokio::sync::oneshot; - use tonic::transport::Server; - use tonic::{Response, Status}; - - use opentelemetry_proto::tonic::collector::logs::v1::{ - ExportLogsServiceResponse, - logs_service_server::{LogsService, LogsServiceServer}, - }; - - #[test] - fn endpoint_parse_defaults_path() { - let endpoint = HttpEndpoint::parse("127.0.0.1:4318").unwrap(); - assert_eq!(endpoint.authority, "127.0.0.1:4318"); - assert_eq!(endpoint.path, "/v1/logs"); - } - - #[test] - fn grpc_endpoint_parse_defaults_scheme() { - let endpoint = GrpcEndpoint::parse("127.0.0.1:4317").unwrap(); - assert_eq!(endpoint.url, "http://127.0.0.1:4317"); - } - - #[test] - fn ffi_logger_posts_log_record() { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - - let server = thread::spawn(move || -> ExportLogsServiceRequest { - let (mut stream, _) = listener.accept().unwrap(); - let request = read_http_request(&mut stream).unwrap(); - stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").unwrap(); - ExportLogsServiceRequest::decode(request.as_slice()).unwrap() - }); - - let endpoint = CString::new(format!("127.0.0.1:{}", addr.port())).unwrap(); - let service = CString::new("cpp-appliance").unwrap(); - let logger = lj_logger_new_http(endpoint.as_ptr(), service.as_ptr(), 1_000); - assert!(!logger.is_null(), "ffi init failed: {}", unsafe { CStr::from_ptr(lj_error_message()).to_string_lossy() }); - - let severity_text = CString::new("INFO").unwrap(); - let body = CString::new("ffi hello").unwrap(); - let attr_key = CString::new("appliance.id").unwrap(); - let attr_value = CString::new("node-7").unwrap(); - let attributes = [lj_attribute { key: attr_key.as_ptr(), value: attr_value.as_ptr() }]; - let record = lj_log_record { - timestamp_unix_ns: 123, - severity_number: SeverityNumber::Info as i32, - severity_text: severity_text.as_ptr(), - body: body.as_ptr(), - attributes: attributes.as_ptr(), - attributes_len: attributes.len(), - }; - - assert!(unsafe { lj_logger_log(logger, &record) }); - unsafe { lj_logger_free(logger) }; - - let batch = server.join().unwrap(); - let resource = &batch.resource_logs[0].resource.as_ref().unwrap().attributes; - assert!(resource.iter().any(|attr| attr.key == "service.name")); - let log_record = &batch.resource_logs[0].scope_logs[0].log_records[0]; - assert_eq!(log_record.severity_text, "INFO"); - let body = log_record.body.as_ref().and_then(|value| value.value.as_ref()); - assert!(matches!(body, Some(Value::StringValue(text)) if text == "ffi hello")); - assert!(log_record.attributes.iter().any(|attr| attr.key == "appliance.id")); - } - - #[test] - fn ffi_logger_posts_log_record_over_grpc() { - let received = Arc::new(Mutex::new(Vec::new())); - let runtime = Runtime::new().unwrap(); - let addr = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - - let service = TestLogsService { received: Arc::clone(&received) }; - runtime.spawn(async move { - Server::builder() - .add_service(LogsServiceServer::new(service)) - .serve_with_shutdown(addr, async { - let _ = shutdown_rx.await; - }) - .await - .unwrap(); - }); - - let endpoint = CString::new(format!("127.0.0.1:{}", addr.port())).unwrap(); - let service_name = CString::new("cpp-appliance").unwrap(); - let logger = lj_logger_new_grpc(endpoint.as_ptr(), service_name.as_ptr(), 1_000); - assert!(!logger.is_null(), "ffi init failed: {}", unsafe { CStr::from_ptr(lj_error_message()).to_string_lossy() }); - - let severity_text = CString::new("INFO").unwrap(); - let body = CString::new("ffi grpc hello").unwrap(); - let attr_key = CString::new("appliance.id").unwrap(); - let attr_value = CString::new("node-9").unwrap(); - let attributes = [lj_attribute { key: attr_key.as_ptr(), value: attr_value.as_ptr() }]; - let record = lj_log_record { - timestamp_unix_ns: 456, - severity_number: SeverityNumber::Info as i32, - severity_text: severity_text.as_ptr(), - body: body.as_ptr(), - attributes: attributes.as_ptr(), - attributes_len: attributes.len(), - }; - - assert!(unsafe { lj_logger_log(logger, &record) }); - unsafe { lj_logger_free(logger) }; - - runtime.block_on(async { - for _ in 0..50 { - if !received.lock().unwrap().is_empty() { - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - }); - let _ = shutdown_tx.send(()); - - let batches = received.lock().unwrap(); - assert_eq!(batches.len(), 1); - let batch = &batches[0]; - let log_record = &batch.resource_logs[0].scope_logs[0].log_records[0]; - assert_eq!(log_record.severity_text, "INFO"); - let body = log_record.body.as_ref().and_then(|value| value.value.as_ref()); - assert!(matches!(body, Some(Value::StringValue(text)) if text == "ffi grpc hello")); - assert!(log_record.attributes.iter().any(|attr| attr.key == "appliance.id")); - assert!(log_record.attributes.iter().any(|attr| attr.key == "liblogjet.transport")); - } - - fn read_http_request(stream: &mut TcpStream) -> io::Result> { - let mut header = Vec::new(); - let mut byte = [0u8; 1]; - loop { - stream.read_exact(&mut byte)?; - header.push(byte[0]); - if header.ends_with(b"\r\n\r\n") { - break; - } - } - - let header_text = - std::str::from_utf8(&header[..header.len() - 4]).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid header"))?; - let mut content_length = None; - for line in header_text.lines().skip(1) { - if let Some((name, value)) = line.split_once(':') - && name.eq_ignore_ascii_case("content-length") - { - content_length = - Some(value.trim().parse::().map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid content-length"))?); - } - } - - let mut body = vec![0u8; content_length.unwrap_or(0)]; - stream.read_exact(&mut body)?; - Ok(body) - } - - #[derive(Clone)] - struct TestLogsService { - received: Arc>>, - } - - #[tonic::async_trait] - impl LogsService for TestLogsService { - async fn export(&self, request: Request) -> Result, Status> { - self.received.lock().unwrap().push(request.into_inner()); - Ok(Response::new(ExportLogsServiceResponse { partial_success: None })) - } - } -} +mod lib_ut; diff --git a/liblogjet/src/lib_ut.rs b/liblogjet/src/lib_ut.rs new file mode 100644 index 0000000..cda55d7 --- /dev/null +++ b/liblogjet/src/lib_ut.rs @@ -0,0 +1,175 @@ +use super::*; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; +use std::thread; +use tokio::sync::oneshot; +use tonic::transport::Server; +use tonic::{Response, Status}; + +use opentelemetry_proto::tonic::collector::logs::v1::{ + ExportLogsServiceResponse, + logs_service_server::{LogsService, LogsServiceServer}, +}; + +#[test] +fn endpoint_parse_defaults_path() { + let endpoint = HttpEndpoint::parse("127.0.0.1:4318").unwrap(); + assert_eq!(endpoint.authority, "127.0.0.1:4318"); + assert_eq!(endpoint.path, "/v1/logs"); +} + +#[test] +fn http_endpoint_rejects_https_scheme() { + let err = HttpEndpoint::parse("https://127.0.0.1:4318").unwrap_err(); + assert!(err.to_string().contains("https endpoints are not supported")); +} + +#[test] +fn grpc_endpoint_parse_defaults_scheme() { + let endpoint = GrpcEndpoint::parse("127.0.0.1:4317").unwrap(); + assert_eq!(endpoint.url, "http://127.0.0.1:4317"); +} + +#[test] +fn ffi_logger_posts_log_record() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + + let server = thread::spawn(move || -> ExportLogsServiceRequest { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream).unwrap(); + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").unwrap(); + ExportLogsServiceRequest::decode(request.as_slice()).unwrap() + }); + + let endpoint = CString::new(format!("127.0.0.1:{}", addr.port())).unwrap(); + let service = CString::new("cpp-appliance").unwrap(); + let logger = lj_logger_new_http(endpoint.as_ptr(), service.as_ptr(), 1_000); + assert!(!logger.is_null(), "ffi init failed: {}", unsafe { CStr::from_ptr(lj_error_message()).to_string_lossy() }); + + let severity_text = CString::new("INFO").unwrap(); + let body = CString::new("ffi hello").unwrap(); + let attr_key = CString::new("appliance.id").unwrap(); + let attr_value = CString::new("node-7").unwrap(); + let attributes = [lj_attribute { key: attr_key.as_ptr(), value: attr_value.as_ptr() }]; + let record = lj_log_record { + timestamp_unix_ns: 123, + severity_number: SeverityNumber::Info as i32, + severity_text: severity_text.as_ptr(), + body: body.as_ptr(), + attributes: attributes.as_ptr(), + attributes_len: attributes.len(), + }; + + assert!(unsafe { lj_logger_log(logger, &record) }); + unsafe { lj_logger_free(logger) }; + + let batch = server.join().unwrap(); + let resource = &batch.resource_logs[0].resource.as_ref().unwrap().attributes; + assert!(resource.iter().any(|attr| attr.key == "service.name")); + let log_record = &batch.resource_logs[0].scope_logs[0].log_records[0]; + assert_eq!(log_record.severity_text, "INFO"); + let body = log_record.body.as_ref().and_then(|value| value.value.as_ref()); + assert!(matches!(body, Some(Value::StringValue(text)) if text == "ffi hello")); + assert!(log_record.attributes.iter().any(|attr| attr.key == "appliance.id")); +} + +#[test] +fn ffi_logger_posts_log_record_over_grpc() { + let received = Arc::new(Mutex::new(Vec::new())); + let runtime = Runtime::new().unwrap(); + let addr = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let service = TestLogsService { received: Arc::clone(&received) }; + runtime.spawn(async move { + Server::builder() + .add_service(LogsServiceServer::new(service)) + .serve_with_shutdown(addr, async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + let endpoint = CString::new(format!("127.0.0.1:{}", addr.port())).unwrap(); + let service_name = CString::new("cpp-appliance").unwrap(); + let logger = lj_logger_new_grpc(endpoint.as_ptr(), service_name.as_ptr(), 1_000); + assert!(!logger.is_null(), "ffi init failed: {}", unsafe { CStr::from_ptr(lj_error_message()).to_string_lossy() }); + + let severity_text = CString::new("INFO").unwrap(); + let body = CString::new("ffi grpc hello").unwrap(); + let attr_key = CString::new("appliance.id").unwrap(); + let attr_value = CString::new("node-9").unwrap(); + let attributes = [lj_attribute { key: attr_key.as_ptr(), value: attr_value.as_ptr() }]; + let record = lj_log_record { + timestamp_unix_ns: 456, + severity_number: SeverityNumber::Info as i32, + severity_text: severity_text.as_ptr(), + body: body.as_ptr(), + attributes: attributes.as_ptr(), + attributes_len: attributes.len(), + }; + + assert!(unsafe { lj_logger_log(logger, &record) }); + unsafe { lj_logger_free(logger) }; + + runtime.block_on(async { + for _ in 0..50 { + if !received.lock().unwrap().is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }); + let _ = shutdown_tx.send(()); + + let batches = received.lock().unwrap(); + assert_eq!(batches.len(), 1); + let batch = &batches[0]; + let log_record = &batch.resource_logs[0].scope_logs[0].log_records[0]; + assert_eq!(log_record.severity_text, "INFO"); + let body = log_record.body.as_ref().and_then(|value| value.value.as_ref()); + assert!(matches!(body, Some(Value::StringValue(text)) if text == "ffi grpc hello")); + assert!(log_record.attributes.iter().any(|attr| attr.key == "appliance.id")); + assert!(log_record.attributes.iter().any(|attr| attr.key == "liblogjet.transport")); +} + +fn read_http_request(stream: &mut TcpStream) -> io::Result> { + let mut header = Vec::new(); + let mut byte = [0u8; 1]; + loop { + stream.read_exact(&mut byte)?; + header.push(byte[0]); + if header.ends_with(b"\r\n\r\n") { + break; + } + } + + let header_text = std::str::from_utf8(&header[..header.len() - 4]).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid header"))?; + let mut content_length = None; + for line in header_text.lines().skip(1) { + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + content_length = Some(value.trim().parse::().map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid content-length"))?); + } + } + + let mut body = vec![0u8; content_length.unwrap_or(0)]; + stream.read_exact(&mut body)?; + Ok(body) +} + +#[derive(Clone)] +struct TestLogsService { + received: Arc>>, +} + +#[tonic::async_trait] +impl LogsService for TestLogsService { + async fn export(&self, request: Request) -> Result, Status> { + self.received.lock().unwrap().push(request.into_inner()); + Ok(Response::new(ExportLogsServiceResponse { partial_success: None })) + } +} diff --git a/ljx/src/commands/view.rs b/ljx/src/commands/view.rs index 87cf95d..c6d8ae2 100644 --- a/ljx/src/commands/view.rs +++ b/ljx/src/commands/view.rs @@ -33,6 +33,7 @@ const SUMMARY_CACHE_LIMIT: usize = 256; const DETAIL_PREVIEW_BYTES: usize = 1024; const SCAN_BATCH_SIZE: usize = 128; const TICK_RATE: Duration = Duration::from_millis(100); +const MODAL_ATTR_ENTRY_LIMIT_PER_KIND: usize = 32; pub fn run(args: ViewArgs) -> Result<()> { if !io::stdin().is_terminal() || !io::stdout().is_terminal() { @@ -1028,6 +1029,8 @@ fn render_modal_info_entries(detail: &DetailRecord) -> Vec<(String, String)> { let mut span_ids = 0usize; let mut resource_attr_entries = Vec::new(); let mut record_attr_entries = Vec::new(); + let mut resource_attr_omitted = 0usize; + let mut record_attr_omitted = 0usize; for resource_logs in &batch.resource_logs { if let Some(resource) = &resource_logs.resource { @@ -1040,7 +1043,13 @@ fn render_modal_info_entries(detail: &DetailRecord) -> Vec<(String, String)> { { service_names.push(service.clone()); } - resource_attr_entries.push(("resource".to_string(), attr.key.clone(), format_any_value(attr.value.as_ref()))); + push_modal_attribute_entry( + &mut resource_attr_entries, + &mut resource_attr_omitted, + "resource", + &attr.key, + format_any_value(attr.value.as_ref()), + ); } } @@ -1066,7 +1075,13 @@ fn render_modal_info_entries(detail: &DetailRecord) -> Vec<(String, String)> { span_ids += 1; } for attr in &record.attributes { - record_attr_entries.push(("record".to_string(), attr.key.clone(), format_any_value(attr.value.as_ref()))); + push_modal_attribute_entry( + &mut record_attr_entries, + &mut record_attr_omitted, + "record", + &attr.key, + format_any_value(attr.value.as_ref()), + ); } } } @@ -1091,9 +1106,15 @@ fn render_modal_info_entries(detail: &DetailRecord) -> Vec<(String, String)> { for (kind, key, value) in resource_attr_entries { lines.push((format!("{kind}.{key}"), value)); } + if resource_attr_omitted > 0 { + lines.push(("resource.attrs.more".to_string(), format!("{resource_attr_omitted} not shown"))); + } for (kind, key, value) in record_attr_entries { lines.push((format!("{kind}.{key}"), value)); } + if record_attr_omitted > 0 { + lines.push(("record.attrs.more".to_string(), format!("{record_attr_omitted} not shown"))); + } if trace_ids > 0 { lines.push(("trace_id".to_string(), format!("{trace_ids} present"))); } @@ -1104,6 +1125,14 @@ fn render_modal_info_entries(detail: &DetailRecord) -> Vec<(String, String)> { lines } +fn push_modal_attribute_entry(entries: &mut Vec<(String, String, String)>, omitted: &mut usize, kind: &str, key: &str, value: String) { + if entries.len() < MODAL_ATTR_ENTRY_LIMIT_PER_KIND { + entries.push((kind.to_string(), key.to_string(), value)); + } else { + *omitted += 1; + } +} + fn modal_info_line(key: &str, value: String, key_width: usize, value_width: usize) -> Line<'static> { let value = trim_single_line(&value, value_width); let (key_style, value_style) = if is_otlp_attribute_entry(key) { @@ -1404,128 +1433,5 @@ fn create_temp_path() -> Result { } #[cfg(test)] -mod tests { - use super::{DetailRecord, EntryMeta, extract_otlp_log_message, format_summary, render_modal_info_entries, render_modal_message, text_preview}; - use logjet::RecordType; - use opentelemetry_proto::tonic::collector::logs::v1::ExportLogsServiceRequest; - use opentelemetry_proto::tonic::common::v1::any_value::Value; - use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope, KeyValue}; - use opentelemetry_proto::tonic::logs::v1::{LogRecord, ResourceLogs, ScopeLogs}; - use opentelemetry_proto::tonic::resource::v1::Resource; - use prost::Message; - - #[test] - fn text_preview_flattens_newlines() { - assert_eq!(text_preview(b"hello\nworld", 32), "hello world"); - } - - #[test] - fn summary_uses_trimmed_single_line_preview() { - let detail = DetailRecord { - meta: EntryMeta { offset: 0, record_type: RecordType::Logs, seq: 7, ts_unix_ns: 9, payload_len: 13 }, - payload: b"line one\nline two".to_vec(), - }; - let summary = format_summary(&detail, false); - assert_eq!(summary, "line one line two"); - } - - #[test] - fn summary_prefers_decoded_otlp_log_message() { - let batch = ExportLogsServiceRequest { - resource_logs: vec![ResourceLogs { - resource: Some(Resource { attributes: Vec::new(), dropped_attributes_count: 0 }), - scope_logs: vec![ScopeLogs { - scope: Some(InstrumentationScope { - name: "test".to_string(), - version: String::new(), - attributes: Vec::new(), - dropped_attributes_count: 0, - }), - log_records: vec![LogRecord { - time_unix_nano: 0, - observed_time_unix_nano: 0, - severity_number: 0, - severity_text: String::new(), - body: Some(AnyValue { value: Some(Value::StringValue("hello from body".to_string())) }), - attributes: Vec::new(), - dropped_attributes_count: 0, - flags: 0, - trace_id: Vec::new(), - span_id: Vec::new(), - event_name: String::new(), - }], - schema_url: String::new(), - }], - schema_url: String::new(), - }], - }; - let payload = batch.encode_to_vec(); - let detail = DetailRecord { - meta: EntryMeta { offset: 0, record_type: RecordType::Logs, seq: 1, ts_unix_ns: 2, payload_len: payload.len() as u64 }, - payload, - }; - - assert_eq!(extract_otlp_log_message(&detail.payload).as_deref(), Some("hello from body")); - assert_eq!(format_summary(&detail, false), "hello from body"); - } - - #[test] - fn modal_falls_back_to_raw_payload() { - let detail = DetailRecord { - meta: EntryMeta { offset: 0, record_type: RecordType::Metrics, seq: 1, ts_unix_ns: 2, payload_len: 5 }, - payload: b"hello".to_vec(), - }; - let body = render_modal_message(&detail, false); - assert_eq!(body, "hello"); - } - - #[test] - fn modal_info_lists_otlp_attributes() { - let batch = ExportLogsServiceRequest { - resource_logs: vec![ResourceLogs { - resource: Some(Resource { - attributes: vec![KeyValue { - key: "service.name".to_string(), - value: Some(AnyValue { value: Some(Value::StringValue("cpp-appliance".to_string())) }), - }], - dropped_attributes_count: 0, - }), - scope_logs: vec![ScopeLogs { - scope: Some(InstrumentationScope { - name: "liblogjet".to_string(), - version: String::new(), - attributes: Vec::new(), - dropped_attributes_count: 0, - }), - log_records: vec![LogRecord { - time_unix_nano: 0, - observed_time_unix_nano: 0, - severity_number: 0, - severity_text: "INFO".to_string(), - body: Some(AnyValue { value: Some(Value::StringValue("hello from cpp".to_string())) }), - attributes: vec![KeyValue { - key: "character".to_string(), - value: Some(AnyValue { value: Some(Value::StringValue("Bender".to_string())) }), - }], - dropped_attributes_count: 0, - flags: 0, - trace_id: Vec::new(), - span_id: Vec::new(), - event_name: String::new(), - }], - schema_url: String::new(), - }], - schema_url: String::new(), - }], - }; - let payload = batch.encode_to_vec(); - let detail = DetailRecord { - meta: EntryMeta { offset: 0, record_type: RecordType::Logs, seq: 1, ts_unix_ns: 2, payload_len: payload.len() as u64 }, - payload, - }; - - let entries = render_modal_info_entries(&detail); - assert!(entries.iter().any(|(key, value)| key == "resource.service.name" && value == "cpp-appliance")); - assert!(entries.iter().any(|(key, value)| key == "record.character" && value == "Bender")); - } -} +#[path = "view_ut.rs"] +mod view_ut; diff --git a/ljx/src/commands/view_ut.rs b/ljx/src/commands/view_ut.rs new file mode 100644 index 0000000..53c04b0 --- /dev/null +++ b/ljx/src/commands/view_ut.rs @@ -0,0 +1,171 @@ +use super::{ + DetailRecord, EntryMeta, MODAL_ATTR_ENTRY_LIMIT_PER_KIND, extract_otlp_log_message, format_summary, render_modal_info_entries, + render_modal_message, text_preview, +}; +use logjet::RecordType; +use opentelemetry_proto::tonic::collector::logs::v1::ExportLogsServiceRequest; +use opentelemetry_proto::tonic::common::v1::any_value::Value; +use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope, KeyValue}; +use opentelemetry_proto::tonic::logs::v1::{LogRecord, ResourceLogs, ScopeLogs}; +use opentelemetry_proto::tonic::resource::v1::Resource; +use prost::Message; + +#[test] +fn text_preview_flattens_newlines() { + assert_eq!(text_preview(b"hello\nworld", 32), "hello world"); +} + +#[test] +fn summary_uses_trimmed_single_line_preview() { + let detail = DetailRecord { + meta: EntryMeta { offset: 0, record_type: RecordType::Logs, seq: 7, ts_unix_ns: 9, payload_len: 13 }, + payload: b"line one\nline two".to_vec(), + }; + let summary = format_summary(&detail, false); + assert_eq!(summary, "line one line two"); +} + +#[test] +fn summary_prefers_decoded_otlp_log_message() { + let batch = ExportLogsServiceRequest { + resource_logs: vec![ResourceLogs { + resource: Some(Resource { attributes: Vec::new(), dropped_attributes_count: 0 }), + scope_logs: vec![ScopeLogs { + scope: Some(InstrumentationScope { + name: "test".to_string(), + version: String::new(), + attributes: Vec::new(), + dropped_attributes_count: 0, + }), + log_records: vec![LogRecord { + time_unix_nano: 0, + observed_time_unix_nano: 0, + severity_number: 0, + severity_text: String::new(), + body: Some(AnyValue { value: Some(Value::StringValue("hello from body".to_string())) }), + attributes: Vec::new(), + dropped_attributes_count: 0, + flags: 0, + trace_id: Vec::new(), + span_id: Vec::new(), + event_name: String::new(), + }], + schema_url: String::new(), + }], + schema_url: String::new(), + }], + }; + let payload = batch.encode_to_vec(); + let detail = DetailRecord { + meta: EntryMeta { offset: 0, record_type: RecordType::Logs, seq: 1, ts_unix_ns: 2, payload_len: payload.len() as u64 }, + payload, + }; + + assert_eq!(extract_otlp_log_message(&detail.payload).as_deref(), Some("hello from body")); + assert_eq!(format_summary(&detail, false), "hello from body"); +} + +#[test] +fn modal_falls_back_to_raw_payload() { + let detail = DetailRecord { + meta: EntryMeta { offset: 0, record_type: RecordType::Metrics, seq: 1, ts_unix_ns: 2, payload_len: 5 }, + payload: b"hello".to_vec(), + }; + let body = render_modal_message(&detail, false); + assert_eq!(body, "hello"); +} + +#[test] +fn modal_info_lists_otlp_attributes() { + let batch = ExportLogsServiceRequest { + resource_logs: vec![ResourceLogs { + resource: Some(Resource { + attributes: vec![KeyValue { + key: "service.name".to_string(), + value: Some(AnyValue { value: Some(Value::StringValue("cpp-appliance".to_string())) }), + }], + dropped_attributes_count: 0, + }), + scope_logs: vec![ScopeLogs { + scope: Some(InstrumentationScope { + name: "liblogjet".to_string(), + version: String::new(), + attributes: Vec::new(), + dropped_attributes_count: 0, + }), + log_records: vec![LogRecord { + time_unix_nano: 0, + observed_time_unix_nano: 0, + severity_number: 0, + severity_text: "INFO".to_string(), + body: Some(AnyValue { value: Some(Value::StringValue("hello from cpp".to_string())) }), + attributes: vec![KeyValue { + key: "character".to_string(), + value: Some(AnyValue { value: Some(Value::StringValue("Bender".to_string())) }), + }], + dropped_attributes_count: 0, + flags: 0, + trace_id: Vec::new(), + span_id: Vec::new(), + event_name: String::new(), + }], + schema_url: String::new(), + }], + schema_url: String::new(), + }], + }; + let payload = batch.encode_to_vec(); + let detail = DetailRecord { + meta: EntryMeta { offset: 0, record_type: RecordType::Logs, seq: 1, ts_unix_ns: 2, payload_len: payload.len() as u64 }, + payload, + }; + + let entries = render_modal_info_entries(&detail); + assert!(entries.iter().any(|(key, value)| key == "resource.service.name" && value == "cpp-appliance")); + assert!(entries.iter().any(|(key, value)| key == "record.character" && value == "Bender")); +} + +#[test] +fn modal_info_caps_attribute_entries_per_kind() { + let attributes = (0..40) + .map(|index| KeyValue { key: format!("custom.{index}"), value: Some(AnyValue { value: Some(Value::StringValue(format!("value-{index}"))) }) }) + .collect::>(); + let batch = ExportLogsServiceRequest { + resource_logs: vec![ResourceLogs { + resource: Some(Resource { attributes: Vec::new(), dropped_attributes_count: 0 }), + scope_logs: vec![ScopeLogs { + scope: Some(InstrumentationScope { + name: "liblogjet".to_string(), + version: String::new(), + attributes: Vec::new(), + dropped_attributes_count: 0, + }), + log_records: vec![LogRecord { + time_unix_nano: 0, + observed_time_unix_nano: 0, + severity_number: 0, + severity_text: "INFO".to_string(), + body: Some(AnyValue { value: Some(Value::StringValue("hello from cpp".to_string())) }), + attributes, + dropped_attributes_count: 0, + flags: 0, + trace_id: Vec::new(), + span_id: Vec::new(), + event_name: String::new(), + }], + schema_url: String::new(), + }], + schema_url: String::new(), + }], + }; + let payload = batch.encode_to_vec(); + let detail = DetailRecord { + meta: EntryMeta { offset: 0, record_type: RecordType::Logs, seq: 1, ts_unix_ns: 2, payload_len: payload.len() as u64 }, + payload, + }; + + let entries = render_modal_info_entries(&detail); + let record_entries = entries.iter().filter(|(key, _)| key.starts_with("record.custom.")).count(); + assert_eq!(record_entries, MODAL_ATTR_ENTRY_LIMIT_PER_KIND); + assert!(entries.iter().any(|(key, value)| key == "record.attrs.more" && value == "8 not shown")); +} diff --git a/ljx/src/predicate.rs b/ljx/src/predicate.rs index 26284f3..124df8b 100644 --- a/ljx/src/predicate.rs +++ b/ljx/src/predicate.rs @@ -180,88 +180,5 @@ impl From for RecordType { } #[cfg(test)] -mod tests { - use super::{FilterMode, PredicateArgs, RecordKind, parse_filter_query}; - use logjet::{OwnedRecord, RecordType}; - - fn sample_record(payload: &[u8]) -> OwnedRecord { - OwnedRecord { record_type: RecordType::Logs, seq: 42, ts_unix_ns: 1_700_000_000, payload: payload.to_vec() } - } - - #[test] - fn fixed_string_match_is_literal() { - let predicate = PredicateArgs { fixed_string: Some("java.crap.failed".to_string()), ..PredicateArgs::default() }.build().unwrap(); - - assert!(predicate.matches(&sample_record(b"xxx java.crap.failed yyy"))); - assert!(!predicate.matches(&sample_record(b"javaXcrapXfailed"))); - } - - #[test] - fn regex_match_supports_wildcards() { - let predicate = PredicateArgs { grep: Some(r"java\..*\.bs".to_string()), ..PredicateArgs::default() }.build().unwrap(); - - assert!(predicate.matches(&sample_record(b"java.very.long.bs"))); - assert!(!predicate.matches(&sample_record(b"java.very.long.cs"))); - } - - #[test] - fn ignore_case_applies_to_fixed_string_and_regex() { - let fixed = PredicateArgs { fixed_string: Some("error".to_string()), ignore_case: true, ..PredicateArgs::default() }.build().unwrap(); - let regex = PredicateArgs { grep: Some("error".to_string()), ignore_case: true, ..PredicateArgs::default() }.build().unwrap(); - - let record = sample_record(b"prefix eRrOr suffix"); - assert!(fixed.matches(&record)); - assert!(regex.matches(&record)); - } - - #[test] - fn matcher_combines_with_record_fields() { - let predicate = PredicateArgs { - record_type: Some(RecordKind::Logs), - seq_min: Some(40), - seq_max: Some(45), - ts_min: Some(1_699_999_999), - ts_max: Some(1_700_000_001), - fixed_string: Some("hello".to_string()), - ..PredicateArgs::default() - } - .build() - .unwrap(); - - assert!(predicate.matches(&sample_record(b"hello world"))); - assert!(!predicate.matches(&sample_record(b"bye world"))); - } - - #[test] - fn invalid_regex_is_reported() { - let error = PredicateArgs { grep: Some("(".to_string()), ..PredicateArgs::default() }.build().unwrap_err(); - - assert!(error.to_string().contains("invalid payload matcher")); - } - - #[test] - fn parse_filter_query_treats_bare_text_as_fixed_string() { - let predicate = parse_filter_query("hello world", FilterMode::Strings).unwrap(); - assert!(predicate.matches(&sample_record(b"say hello world now"))); - assert!(!predicate.matches(&sample_record(b"say hello now"))); - } - - #[test] - fn parse_filter_query_supports_cli_style_flags() { - let predicate = parse_filter_query(r#"--type logs -e "error|panic" -i"#, FilterMode::Strings).unwrap(); - assert!(predicate.matches(&sample_record(b"PANIC happened"))); - assert!(!predicate.matches(&OwnedRecord { - record_type: RecordType::Metrics, - seq: 42, - ts_unix_ns: 1_700_000_000, - payload: b"panic".to_vec(), - })); - } - - #[test] - fn parse_filter_query_uses_regex_mode_for_bare_text() { - let predicate = parse_filter_query("reb.*", FilterMode::Regex).unwrap(); - assert!(predicate.matches(&sample_record(b"rebooted node"))); - assert!(!predicate.matches(&sample_record(b"stopped node"))); - } -} +#[path = "predicate_ut.rs"] +mod predicate_ut; diff --git a/ljx/src/predicate_ut.rs b/ljx/src/predicate_ut.rs new file mode 100644 index 0000000..1507438 --- /dev/null +++ b/ljx/src/predicate_ut.rs @@ -0,0 +1,78 @@ +use super::{FilterMode, PredicateArgs, RecordKind, parse_filter_query}; +use logjet::{OwnedRecord, RecordType}; + +fn sample_record(payload: &[u8]) -> OwnedRecord { + OwnedRecord { record_type: RecordType::Logs, seq: 42, ts_unix_ns: 1_700_000_000, payload: payload.to_vec() } +} + +#[test] +fn fixed_string_match_is_literal() { + let predicate = PredicateArgs { fixed_string: Some("java.crap.failed".to_string()), ..PredicateArgs::default() }.build().unwrap(); + + assert!(predicate.matches(&sample_record(b"xxx java.crap.failed yyy"))); + assert!(!predicate.matches(&sample_record(b"javaXcrapXfailed"))); +} + +#[test] +fn regex_match_supports_wildcards() { + let predicate = PredicateArgs { grep: Some(r"java\..*\.bs".to_string()), ..PredicateArgs::default() }.build().unwrap(); + + assert!(predicate.matches(&sample_record(b"java.very.long.bs"))); + assert!(!predicate.matches(&sample_record(b"java.very.long.cs"))); +} + +#[test] +fn ignore_case_applies_to_fixed_string_and_regex() { + let fixed = PredicateArgs { fixed_string: Some("error".to_string()), ignore_case: true, ..PredicateArgs::default() }.build().unwrap(); + let regex = PredicateArgs { grep: Some("error".to_string()), ignore_case: true, ..PredicateArgs::default() }.build().unwrap(); + + let record = sample_record(b"prefix eRrOr suffix"); + assert!(fixed.matches(&record)); + assert!(regex.matches(&record)); +} + +#[test] +fn matcher_combines_with_record_fields() { + let predicate = PredicateArgs { + record_type: Some(RecordKind::Logs), + seq_min: Some(40), + seq_max: Some(45), + ts_min: Some(1_699_999_999), + ts_max: Some(1_700_000_001), + fixed_string: Some("hello".to_string()), + ..PredicateArgs::default() + } + .build() + .unwrap(); + + assert!(predicate.matches(&sample_record(b"hello world"))); + assert!(!predicate.matches(&sample_record(b"bye world"))); +} + +#[test] +fn invalid_regex_is_reported() { + let error = PredicateArgs { grep: Some("(".to_string()), ..PredicateArgs::default() }.build().unwrap_err(); + + assert!(error.to_string().contains("invalid payload matcher")); +} + +#[test] +fn parse_filter_query_treats_bare_text_as_fixed_string() { + let predicate = parse_filter_query("hello world", FilterMode::Strings).unwrap(); + assert!(predicate.matches(&sample_record(b"say hello world now"))); + assert!(!predicate.matches(&sample_record(b"say hello now"))); +} + +#[test] +fn parse_filter_query_supports_cli_style_flags() { + let predicate = parse_filter_query(r#"--type logs -e "error|panic" -i"#, FilterMode::Strings).unwrap(); + assert!(predicate.matches(&sample_record(b"PANIC happened"))); + assert!(!predicate.matches(&OwnedRecord { record_type: RecordType::Metrics, seq: 42, ts_unix_ns: 1_700_000_000, payload: b"panic".to_vec() })); +} + +#[test] +fn parse_filter_query_uses_regex_mode_for_bare_text() { + let predicate = parse_filter_query("reb.*", FilterMode::Regex).unwrap(); + assert!(predicate.matches(&sample_record(b"rebooted node"))); + assert!(!predicate.matches(&sample_record(b"stopped node"))); +}