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:
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"));
+ }
+}