app

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

commit 439590d479e4af50fc21c59f706e8833a30a5450
parent 76f987ecb6b4fb3334658731a32071471a9bc791
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 23:17:38 +0000

app: add structured logging with datastore persistence

- add logging module with metadata, entries, and error mapping
- wire logging init and error emission into app startup and reset
- extend config key maps with log error param key and tests
- add logging dependencies and update lockfile

Diffstat:
MCargo.lock | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/Cargo.toml | 9++++++++-
Mapp/src/app.rs | 12++++++++++--
Mapp/src/config.rs | 17+++++++++++++++++
Mapp/src/entry.rs | 3++-
Mapp/src/lib.rs | 22++++++++++++++++++++++
Aapp/src/logging.rs | 362+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 638 insertions(+), 4 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -347,6 +347,16 @@ dependencies = [ ] [[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] name = "const-str" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -421,6 +431,15 @@ dependencies = [ ] [[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -452,6 +471,15 @@ dependencies = [ ] [[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] name = "derive-where" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1020,6 +1048,12 @@ dependencies = [ ] [[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] name = "leptos" version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1216,6 +1250,15 @@ dependencies = [ ] [[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1253,6 +1296,21 @@ dependencies = [ ] [[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1426,6 +1484,12 @@ dependencies = [ ] [[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1541,12 +1605,16 @@ name = "radroots-app" version = "0.1.0" dependencies = [ "async-trait", + "console_error_panic_hook", "futures", "js-sys", "leptos", "radroots-app-core", + "radroots-log", "serde", "serde_json", + "tracing-wasm", + "uuid", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -1645,6 +1713,16 @@ dependencies = [ ] [[package]] +name = "radroots-log" +version = "0.1.0" +dependencies = [ + "thiserror 1.0.69", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] name = "radroots-nostr" version = "0.1.0" dependencies = [ @@ -2123,6 +2201,15 @@ dependencies = [ ] [[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2270,6 +2357,15 @@ dependencies = [ ] [[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] name = "throw_error" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2279,6 +2375,37 @@ dependencies = [ ] [[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] name = "tinystr" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2335,6 +2462,90 @@ dependencies = [ ] [[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] name = "typed-builder" version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2454,6 +2665,12 @@ dependencies = [ ] [[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/app/Cargo.toml b/app/Cargo.toml @@ -16,9 +16,16 @@ wasm-bindgen-futures.workspace = true js-sys.workspace = true web-sys.workspace = true radroots-app-core = { path = "../crates/core" } +radroots-log = { path = "../refs/crates/log", default-features = false } +tracing-wasm = "0.2" +console_error_panic_hook = "0.1" serde.workspace = true +serde_json.workspace = true +uuid.workspace = true [dev-dependencies] futures.workspace = true async-trait.workspace = true -serde_json.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +radroots-log = { path = "../refs/crates/log", features = ["std"] } diff --git a/app/src/app.rs b/app/src/app.rs @@ -12,6 +12,8 @@ use crate::{ app_init_stage_set, app_init_total_add, app_init_total_unknown, + app_log_error_emit, + app_log_error_store, app_config_default, app_datastore_read_app_data, app_health_check_all, @@ -125,7 +127,9 @@ pub fn App() -> impl IntoView { ) .await; if let Err(err) = assets_result { - init_error.set(Some(AppInitError::Assets(err))); + let init_err = AppInitError::Assets(err); + let _ = app_log_error_emit(&init_err); + init_error.set(Some(init_err)); init_state.update(|state| app_init_stage_set(state, AppInitStage::Error)); return; } @@ -138,6 +142,7 @@ pub fn App() -> impl IntoView { init_state.update(|state| app_init_stage_set(state, AppInitStage::Ready)); } Err(err) => { + let _ = app_log_error_emit(&err); init_error.set(Some(err)); init_state.update(|state| app_init_stage_set(state, AppInitStage::Error)); } @@ -218,7 +223,10 @@ pub fn App() -> impl IntoView { reset_status.set(Some("reset_done".to_string())); spawn_health_checks(config, health_report, health_running, active_key); } - Err(err) => reset_status.set(Some(err.to_string())), + Err(err) => { + let _ = app_log_error_store(&datastore, &config.datastore.key_maps, &err).await; + reset_status.set(Some(err.to_string())); + } } }); } diff --git a/app/src/config.rs b/app/src/config.rs @@ -18,6 +18,7 @@ pub const APP_DATASTORE_KEY_NOSTR_KEY: &str = "nostr:key"; pub const APP_DATASTORE_KEY_EULA_DATE: &str = "app:eula:date"; pub const APP_DATASTORE_KEY_OBJ_CFG_DATA: &str = "cfg:data"; pub const APP_DATASTORE_KEY_OBJ_APP_DATA: &str = "app:data"; +pub const APP_DATASTORE_KEY_LOG_ERROR: &str = "log:error"; pub const APP_KEYSTORE_KEY_NOSTR_DEFAULT: &str = "nostr:default"; pub fn app_datastore_param_nostr_profile(public_key: &str) -> String { @@ -28,6 +29,10 @@ pub fn app_datastore_param_radroots_profile(public_key: &str) -> String { format!("radroots:{public_key}:profile") } +pub fn app_datastore_param_log_error(entry_id: &str) -> String { + format!("{APP_DATASTORE_KEY_LOG_ERROR}:{entry_id}") +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppKeyMapConfig { pub key_map: AppDatastoreKeyMap, @@ -55,6 +60,7 @@ pub fn app_key_maps_default() -> AppKeyMapConfig { "radroots_profile", app_datastore_param_radroots_profile as AppDatastoreKeyParam, ); + param_map.insert("log_error", app_datastore_param_log_error as AppDatastoreKeyParam); let mut obj_map = BTreeMap::new(); obj_map.insert("cfg_data", APP_DATASTORE_KEY_OBJ_CFG_DATA); obj_map.insert("app_data", APP_DATASTORE_KEY_OBJ_APP_DATA); @@ -107,6 +113,9 @@ pub fn app_key_maps_validate(config: &AppKeyMapConfig) -> AppConfigResult<()> { if !config.param_map.contains_key("radroots_profile") { return Err(AppConfigError::MissingParamMap("radroots_profile")); } + if !config.param_map.contains_key("log_error") { + return Err(AppConfigError::MissingParamMap("log_error")); + } if !config.obj_map.contains_key("cfg_data") { return Err(AppConfigError::MissingObjMap("cfg_data")); } @@ -279,6 +288,7 @@ mod tests { app_config_default, app_config_from_env, app_datastore_param_nostr_profile, + app_datastore_param_log_error, app_datastore_key_eula_date, app_datastore_key_nostr_key, app_datastore_obj_key_app_data, @@ -302,6 +312,7 @@ mod tests { APP_DATASTORE_KEY_NOSTR_KEY, APP_DATASTORE_KEY_OBJ_APP_DATA, APP_DATASTORE_KEY_OBJ_CFG_DATA, + APP_DATASTORE_KEY_LOG_ERROR, APP_KEYSTORE_KEY_NOSTR_DEFAULT, }; use radroots_app_core::idb::{IDB_CONFIG_DATASTORE, IDB_CONFIG_KEYSTORE_NOSTR}; @@ -390,6 +401,10 @@ mod tests { Some(&APP_DATASTORE_KEY_OBJ_APP_DATA) ); assert_eq!(app_datastore_param_nostr_profile("abc"), "nostr:abc:profile"); + assert_eq!( + app_datastore_param_log_error("entry"), + format!("{APP_DATASTORE_KEY_LOG_ERROR}:entry") + ); } #[test] @@ -433,6 +448,8 @@ mod tests { ); let nostr_param = app_datastore_param_key(&config, "nostr_profile").expect("param"); assert_eq!(nostr_param("abc"), "nostr:abc:profile"); + let log_param = app_datastore_param_key(&config, "log_error").expect("param"); + assert_eq!(log_param("entry"), format!("{APP_DATASTORE_KEY_LOG_ERROR}:entry")); } #[test] diff --git a/app/src/entry.rs b/app/src/entry.rs @@ -1,9 +1,10 @@ use leptos::mount::mount_to_body; use wasm_bindgen::prelude::wasm_bindgen; -use crate::App; +use crate::{app_logging_init, App}; #[wasm_bindgen(start)] pub fn start() { + let _ = app_logging_init(None); mount_to_body(App); } diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -8,6 +8,7 @@ mod data; mod health; mod init; mod keystore; +mod logging; mod notifications; mod tangle; mod entry; @@ -44,6 +45,25 @@ pub use keystore::{ AppKeystoreError, AppKeystoreResult, }; +pub use logging::{ + app_log_entry_error, + app_log_entry_emit, + app_log_entry_store, + app_log_error_emit, + app_log_error_store, + app_log_error_key, + app_log_metadata, + app_log_timestamp_ms, + app_logging_init, + AppLogEntry, + AppLogError, + AppLogLevel, + AppLogResult, + AppLoggableError, + AppLogMetadata, + AppLoggingError, + AppLoggingResult, +}; pub use notifications::{AppNotifications, AppNotificationsError, AppNotificationsResult}; pub use tangle::{AppTangleClient, AppTangleClientStub, AppTangleError, AppTangleResult}; pub use config::{ @@ -53,6 +73,7 @@ pub use config::{ app_datastore_key_eula_date, app_datastore_key_nostr_key, app_datastore_param_nostr_profile, + app_datastore_param_log_error, app_datastore_param_radroots_profile, app_datastore_param_key, app_datastore_obj_key, @@ -79,6 +100,7 @@ pub use config::{ AppKeystoreKeyMap, AppKeyMapConfig, APP_DATASTORE_KEY_EULA_DATE, + APP_DATASTORE_KEY_LOG_ERROR, APP_DATASTORE_KEY_NOSTR_KEY, APP_DATASTORE_KEY_OBJ_APP_DATA, APP_DATASTORE_KEY_OBJ_CFG_DATA, diff --git a/app/src/logging.rs b/app/src/logging.rs @@ -0,0 +1,362 @@ +#![forbid(unsafe_code)] + +use std::sync::OnceLock; + +#[cfg(not(target_arch = "wasm32"))] +use std::path::PathBuf; +#[cfg(not(target_arch = "wasm32"))] +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(target_arch = "wasm32")] +use js_sys::Date; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use radroots_app_core::datastore::{RadrootsClientDatastore, RadrootsClientDatastoreError}; + +use crate::{ + app_datastore_param_key, + AppConfigError, + AppInitAssetError, + AppInitError, + AppKeystoreError, + AppKeyMapConfig, + AppNotificationsError, + AppTangleError, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppLogMetadata { + pub app_name: String, + pub app_version: String, + pub app_hash: String, + pub target: String, +} + +impl Default for AppLogMetadata { + fn default() -> Self { + let app_name = String::from(env!("CARGO_PKG_NAME")); + let app_version = String::from(env!("CARGO_PKG_VERSION")); + let app_hash = String::from(option_env!("RADROOTS_GIT_HASH").unwrap_or("unknown")); + let target = if cfg!(target_arch = "wasm32") { + String::from("wasm32") + } else { + String::from("native") + }; + Self { + app_name, + app_version, + app_hash, + target, + } + } +} + +static LOG_META: OnceLock<AppLogMetadata> = OnceLock::new(); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppLogLevel { + Debug, + Info, + Warn, + Error, +} + +impl AppLogLevel { + pub const fn as_str(self) -> &'static str { + match self { + AppLogLevel::Debug => "debug", + AppLogLevel::Info => "info", + AppLogLevel::Warn => "warn", + AppLogLevel::Error => "error", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppLogEntry { + pub id: String, + pub timestamp_ms: i64, + pub level: AppLogLevel, + pub code: String, + pub message: String, + pub context: Option<String>, + pub metadata: AppLogMetadata, +} + +pub trait AppLoggableError: std::fmt::Display { + fn log_code(&self) -> &'static str; + fn log_context(&self) -> Option<String> { + None + } +} + +impl AppLoggableError for AppInitAssetError { + fn log_code(&self) -> &'static str { + self.message() + } +} + +impl AppLoggableError for AppConfigError { + fn log_code(&self) -> &'static str { + self.message() + } + + fn log_context(&self) -> Option<String> { + match self { + AppConfigError::MissingKeyMap(key) => Some(format!("key_map={key}")), + AppConfigError::MissingParamMap(key) => Some(format!("param_map={key}")), + AppConfigError::MissingObjMap(key) => Some(format!("obj_map={key}")), + AppConfigError::MissingKeystoreKeyMap(key) => Some(format!("keystore_map={key}")), + } + } +} + +impl AppLoggableError for AppInitError { + fn log_code(&self) -> &'static str { + self.message() + } + + fn log_context(&self) -> Option<String> { + match self { + AppInitError::Idb(err) => Some(err.to_string()), + AppInitError::Datastore(err) => Some(err.to_string()), + AppInitError::Keystore(err) => Some(err.to_string()), + AppInitError::Config(err) => err.log_context().or_else(|| Some(err.message().to_string())), + AppInitError::Assets(err) => Some(err.message().to_string()), + } + } +} + +impl AppLoggableError for AppKeystoreError { + fn log_code(&self) -> &'static str { + self.message() + } + + fn log_context(&self) -> Option<String> { + match self { + AppKeystoreError::Keystore(err) => Some(err.to_string()), + } + } +} + +impl AppLoggableError for AppNotificationsError { + fn log_code(&self) -> &'static str { + self.message() + } + + fn log_context(&self) -> Option<String> { + match self { + AppNotificationsError::Notifications(err) => Some(err.message().to_string()), + } + } +} + +impl AppLoggableError for AppTangleError { + fn log_code(&self) -> &'static str { + self.message() + } +} + +#[derive(Debug)] +pub enum AppLogError { + Config(AppConfigError), + Datastore(RadrootsClientDatastoreError), +} + +pub type AppLogResult<T> = Result<T, AppLogError>; + +impl std::fmt::Display for AppLogError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppLogError::Config(err) => write!(f, "{err}"), + AppLogError::Datastore(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for AppLogError {} + +impl From<AppConfigError> for AppLogError { + fn from(err: AppConfigError) -> Self { + AppLogError::Config(err) + } +} + +impl From<RadrootsClientDatastoreError> for AppLogError { + fn from(err: RadrootsClientDatastoreError) -> Self { + AppLogError::Datastore(err) + } +} + +pub fn app_log_timestamp_ms() -> i64 { + #[cfg(target_arch = "wasm32")] + { + Date::now() as i64 + } + #[cfg(not(target_arch = "wasm32"))] + { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_millis() as i64) + .unwrap_or(0) + } +} + +pub fn app_log_entry_error<E: AppLoggableError>(err: &E) -> AppLogEntry { + AppLogEntry { + id: Uuid::new_v4().to_string(), + timestamp_ms: app_log_timestamp_ms(), + level: AppLogLevel::Error, + code: err.log_code().to_string(), + message: err.to_string(), + context: err.log_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)); + match entry.level { + AppLogLevel::Error => radroots_log::log_error(payload), + AppLogLevel::Warn => radroots_log::log_info(payload), + AppLogLevel::Info => radroots_log::log_info(payload), + AppLogLevel::Debug => radroots_log::log_debug(payload), + } +} + +pub fn app_log_error_emit<E: AppLoggableError>(err: &E) -> AppLogEntry { + let entry = app_log_entry_error(err); + app_log_entry_emit(&entry); + entry +} + +pub fn app_log_error_key( + key_maps: &AppKeyMapConfig, + entry_id: &str, +) -> AppLogResult<String> { + let param = app_datastore_param_key(key_maps, "log_error")?; + Ok(param(entry_id)) +} + +pub async fn app_log_entry_store<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &AppKeyMapConfig, + entry: &AppLogEntry, +) -> AppLogResult<AppLogEntry> { + let key = app_log_error_key(key_maps, &entry.id)?; + datastore + .set_obj(&key, entry) + .await + .map_err(AppLogError::Datastore) +} + +pub async fn app_log_error_store<T: RadrootsClientDatastore, E: AppLoggableError>( + datastore: &T, + key_maps: &AppKeyMapConfig, + err: &E, +) -> AppLogResult<AppLogEntry> { + let entry = app_log_error_emit(err); + app_log_entry_store(datastore, key_maps, &entry).await +} + +#[derive(Debug)] +pub enum AppLoggingError { + Logging(radroots_log::Error), +} + +pub type AppLoggingResult<T> = Result<T, AppLoggingError>; + +impl std::fmt::Display for AppLoggingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AppLoggingError::Logging(err) => write!(f, "{err:?}"), + } + } +} + +impl std::error::Error for AppLoggingError {} + +pub fn app_log_metadata() -> &'static AppLogMetadata { + LOG_META.get_or_init(AppLogMetadata::default) +} + +pub fn app_logging_init(meta: Option<AppLogMetadata>) -> AppLoggingResult<()> { + if LOG_META.get().is_none() { + let _ = LOG_META.set(meta.unwrap_or_default()); + } + #[cfg(target_arch = "wasm32")] + { + console_error_panic_hook::set_once(); + let _ = tracing_wasm::set_as_global_default(); + Ok(()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let opts = radroots_log::LoggingOptions { + dir: Some(PathBuf::from("logs")), + file_name: "radroots-app.log".into(), + stdout: true, + default_level: Some(String::from("info")), + }; + match radroots_log::init_logging(opts) { + Ok(()) => Ok(()), + Err(err) => { + radroots_log::init_stdout().map_err(AppLoggingError::Logging)?; + radroots_log::log_error(format!("logging_init_failed: {err}")); + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + app_log_entry_error, + app_log_error_key, + app_log_metadata, + app_log_timestamp_ms, + AppLogLevel, + AppLogMetadata, + }; + use crate::{ + app_key_maps_default, + AppConfigError, + APP_DATASTORE_KEY_LOG_ERROR, + }; + + #[test] + fn log_metadata_defaults_populated() { + let meta = AppLogMetadata::default(); + assert!(!meta.app_name.is_empty()); + assert!(!meta.app_version.is_empty()); + assert!(!meta.app_hash.is_empty()); + assert!(!meta.target.is_empty()); + } + + #[test] + fn log_metadata_once_lock_returns_default() { + let meta = app_log_metadata(); + assert!(!meta.app_name.is_empty()); + } + + #[test] + fn log_entry_error_includes_context() { + let err = AppConfigError::MissingKeyMap("nostr_key"); + let entry = app_log_entry_error(&err); + assert_eq!(entry.level, AppLogLevel::Error); + assert_eq!(entry.code, err.message()); + assert_eq!(entry.message, err.to_string()); + assert_eq!(entry.context.as_deref(), Some("key_map=nostr_key")); + assert!(entry.timestamp_ms >= app_log_timestamp_ms() - 10_000); + } + + #[test] + fn log_error_key_uses_param_map() { + let key_maps = app_key_maps_default(); + let key = app_log_error_key(&key_maps, "entry").expect("key"); + assert_eq!(key, format!("{APP_DATASTORE_KEY_LOG_ERROR}:entry")); + } +}