app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 7a93c82c8c69aaab94c44d96e9e5348b51a764b0
parent a3d556ce0f2ff390528d6c1620c57f7811917bca
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 23:32:37 +0000

app: add log entry builder and buffer

- add generic log entry constructor and record helper

- add debug info warn emitters for structured logs

- add in-memory log buffer with max size and drain

- add unit tests for entry builder and buffer

Diffstat:
Mapp/src/lib.rs | 8++++++++
Mapp/src/logging.rs | 102++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 107 insertions(+), 3 deletions(-)

diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -48,12 +48,19 @@ pub use keystore::{ pub use logging::{ app_log_entry_error, app_log_entry_emit, + app_log_entry_new, + app_log_entry_record, app_log_entry_store, + app_log_buffer_drain, + app_log_buffer_push, app_log_error_emit, app_log_error_store, app_log_entry_key, + app_log_debug_emit, + app_log_info_emit, app_log_metadata, app_log_timestamp_ms, + app_log_warn_emit, app_logging_init, AppLogEntry, AppLogError, @@ -63,6 +70,7 @@ pub use logging::{ AppLogMetadata, AppLoggingError, AppLoggingResult, + APP_LOG_BUFFER_MAX_ENTRIES, }; pub use notifications::{AppNotifications, AppNotificationsError, AppNotificationsResult}; pub use tangle::{AppTangleClient, AppTangleClientStub, AppTangleError, AppTangleResult}; diff --git a/app/src/logging.rs b/app/src/logging.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use std::sync::OnceLock; +use std::sync::{Mutex, OnceLock}; #[cfg(not(target_arch = "wasm32"))] use std::path::PathBuf; @@ -53,6 +53,9 @@ impl Default for AppLogMetadata { } static LOG_META: OnceLock<AppLogMetadata> = OnceLock::new(); +static LOG_BUFFER: OnceLock<Mutex<Vec<AppLogEntry>>> = OnceLock::new(); + +pub const APP_LOG_BUFFER_MAX_ENTRIES: usize = 512; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AppLogLevel { @@ -215,6 +218,23 @@ pub fn app_log_entry_error<E: AppLoggableError>(err: &E) -> AppLogEntry { } } +pub fn app_log_entry_new( + level: AppLogLevel, + code: &str, + message: &str, + context: Option<String>, +) -> AppLogEntry { + AppLogEntry { + id: Uuid::new_v4().to_string(), + timestamp_ms: app_log_timestamp_ms(), + level, + code: code.to_string(), + message: message.to_string(), + context, + metadata: app_log_metadata().clone(), + } +} + pub fn app_log_entry_emit(entry: &AppLogEntry) { let payload = serde_json::to_string(entry) .unwrap_or_else(|_| format!("{}: {}", entry.code, entry.message)); @@ -226,12 +246,43 @@ pub fn app_log_entry_emit(entry: &AppLogEntry) { } } -pub fn app_log_error_emit<E: AppLoggableError>(err: &E) -> AppLogEntry { - let entry = app_log_entry_error(err); +pub fn app_log_entry_record(entry: AppLogEntry) -> AppLogEntry { app_log_entry_emit(&entry); + app_log_buffer_push(entry.clone()); entry } +pub fn app_log_error_emit<E: AppLoggableError>(err: &E) -> AppLogEntry { + app_log_entry_record(app_log_entry_error(err)) +} + +pub fn app_log_debug_emit(code: &str, message: &str, context: Option<String>) -> AppLogEntry { + app_log_entry_record(app_log_entry_new( + AppLogLevel::Debug, + code, + message, + context, + )) +} + +pub fn app_log_info_emit(code: &str, message: &str, context: Option<String>) -> AppLogEntry { + app_log_entry_record(app_log_entry_new( + AppLogLevel::Info, + code, + message, + context, + )) +} + +pub fn app_log_warn_emit(code: &str, message: &str, context: Option<String>) -> AppLogEntry { + app_log_entry_record(app_log_entry_new( + AppLogLevel::Warn, + code, + message, + context, + )) +} + pub fn app_log_entry_key( key_maps: &AppKeyMapConfig, entry_id: &str, @@ -261,6 +312,22 @@ pub async fn app_log_error_store<T: RadrootsClientDatastore, E: AppLoggableError app_log_entry_store(datastore, key_maps, &entry).await } +pub fn app_log_buffer_push(entry: AppLogEntry) { + let buffer = LOG_BUFFER.get_or_init(|| Mutex::new(Vec::new())); + let mut entries = buffer.lock().unwrap_or_else(|err| err.into_inner()); + entries.push(entry); + if entries.len() > APP_LOG_BUFFER_MAX_ENTRIES { + let drop = entries.len() - APP_LOG_BUFFER_MAX_ENTRIES; + entries.drain(0..drop); + } +} + +pub fn app_log_buffer_drain() -> Vec<AppLogEntry> { + let buffer = LOG_BUFFER.get_or_init(|| Mutex::new(Vec::new())); + let mut entries = buffer.lock().unwrap_or_else(|err| err.into_inner()); + entries.drain(..).collect() +} + #[derive(Debug)] pub enum AppLoggingError { Logging(radroots_log::Error), @@ -315,7 +382,10 @@ pub fn app_logging_init(meta: Option<AppLogMetadata>) -> AppLoggingResult<()> { mod tests { use super::{ app_log_entry_error, + app_log_entry_new, app_log_entry_key, + app_log_buffer_drain, + app_log_buffer_push, app_log_metadata, app_log_timestamp_ms, AppLogLevel, @@ -359,4 +429,30 @@ mod tests { let key = app_log_entry_key(&key_maps, "entry").expect("key"); assert_eq!(key, format!("{APP_DATASTORE_KEY_LOG_ENTRY}:entry")); } + + #[test] + fn log_entry_new_populates_fields() { + let entry = app_log_entry_new( + AppLogLevel::Info, + "log.code.test", + "hello", + Some(String::from("ctx")), + ); + assert_eq!(entry.level, AppLogLevel::Info); + assert_eq!(entry.code, "log.code.test"); + assert_eq!(entry.message, "hello"); + assert_eq!(entry.context.as_deref(), Some("ctx")); + assert!(!entry.id.is_empty()); + } + + #[test] + fn log_buffer_drains_entries() { + let _ = app_log_buffer_drain(); + let entry = app_log_entry_new(AppLogLevel::Debug, "log.code.test", "buf", None); + app_log_buffer_push(entry.clone()); + let drained = app_log_buffer_drain(); + assert_eq!(drained.len(), 1); + assert_eq!(drained[0].id, entry.id); + assert!(app_log_buffer_drain().is_empty()); + } }