dittolive_ditto/logger.rs
1//! Use [`DittoLogger`] to control Ditto logging options.
2
3use std::{
4 path::Path,
5 sync::{Arc, Mutex},
6};
7
8use safer_ffi::{closure::boxed::*, prelude::*};
9use tokio::sync::oneshot;
10
11#[cfg(doc)]
12use crate::error::{CoreApiErrorKind, ErrorKind};
13use crate::{error::Result, prelude::*, utils::extension_traits::FfiResultIntoRustResult};
14
15type StoredCallback = Arc<dyn Fn(LogLevel, &str) + Send + Sync>;
16static CUSTOM_LOG_CALLBACK: Mutex<Option<StoredCallback>> = Mutex::new(None);
17
18/// FFI shim that bridges C callbacks to Rust closures.
19extern "C" fn custom_log_callback_shim(level: CLogLevel, msg: char_p::Box) {
20 // Clone the Arc (if present) while holding the lock, then invoke outside
21 // the lock. Use .ok() to silently skip if poisoned - crashing in a logging
22 // callback would be worse than missing logs.
23 let callback = CUSTOM_LOG_CALLBACK
24 .lock()
25 .ok()
26 .and_then(|guard| guard.as_ref().map(Arc::clone));
27
28 if let Some(cb) = callback {
29 cb(level, msg.to_str());
30 }
31}
32
33/// Type with free associated functions ("static methods") to customize the logging behavior from
34/// Ditto and log messages with the Ditto logging infrastructure.
35pub struct DittoLogger(::never_say_never::Never);
36
37impl DittoLogger {
38 /// Enable or disable logging.
39 ///
40 /// Logs exported through [`export_to_file()`] are not affected by this
41 /// setting and will also include logs emitted while `enabled` is false.
42 ///
43 /// [`export_to_file()`]: DittoLogger::export_to_file()
44 pub fn set_logging_enabled(enabled: bool) {
45 ffi_sdk::ditto_logger_enabled(enabled)
46 }
47
48 /// Return true if logging is enabled.
49 ///
50 /// Logs exported through [`export_to_file()`] are not affected by this
51 /// setting and will also include logs emitted while `enabled` is false.
52 ///
53 /// [`export_to_file()`]: DittoLogger::export_to_file()
54 pub fn get_logging_enabled() -> bool {
55 ffi_sdk::ditto_logger_enabled_get()
56 }
57
58 /// Get the current minimum log level.
59 ///
60 /// Logs exported through [`export_to_file()`] are not affected by this
61 /// setting and include all logs at [`LogLevel::Debug`] and above.
62 ///
63 /// [`export_to_file()`]: DittoLogger::export_to_file()
64 pub fn get_minimum_log_level() -> LogLevel {
65 ffi_sdk::ditto_logger_minimum_log_level_get()
66 }
67
68 /// Set the current minimum log level.
69 ///
70 /// Logs exported through [`export_to_file()`] are not affected by this
71 /// setting and include all logs at [`LogLevel::Debug`] and above.
72 ///
73 /// [`export_to_file()`]: DittoLogger::export_to_file()
74 pub fn set_minimum_log_level(log_level: LogLevel) {
75 ffi_sdk::ditto_logger_minimum_log_level(log_level);
76 }
77}
78
79impl DittoLogger {
80 /// Not part of the public API.
81 #[doc(hidden)]
82 pub fn __log_error(msg: impl Into<String>) {
83 ::ffi_sdk::ditto_log(CLogLevel::Error, char_p::new(msg.into()).as_ref())
84 }
85}
86
87/// Custom log callback API.
88impl DittoLogger {
89 /// Registers a custom callback to receive Ditto log messages.
90 ///
91 /// When set, the callback is invoked for each log message that Ditto emits.
92 ///
93 /// **Panics:** The callback must not panic. If it does, the process will abort.
94 ///
95 /// **Re-entrancy:** The callback should avoid calling Ditto APIs that emit
96 /// logs, as this causes recursion.
97 ///
98 /// # Example
99 ///
100 /// ```rust
101 /// use dittolive_ditto::prelude::*;
102 ///
103 /// // Route Ditto logs to the `log` crate
104 /// DittoLogger::set_custom_log_callback(|level, message| match level {
105 /// LogLevel::Error => log::error!("{}", message),
106 /// LogLevel::Warning => log::warn!("{}", message),
107 /// LogLevel::Info => log::info!("{}", message),
108 /// LogLevel::Debug => log::debug!("{}", message),
109 /// LogLevel::Verbose => log::trace!("{}", message),
110 /// });
111 ///
112 /// // Later, remove the callback
113 /// DittoLogger::clear_custom_log_callback();
114 /// ```
115 pub fn set_custom_log_callback<F>(callback: F)
116 where
117 F: Fn(LogLevel, &str) + Send + Sync + 'static,
118 {
119 let mut guard = CUSTOM_LOG_CALLBACK.lock().unwrap();
120 let was_none = guard.is_none();
121 *guard = Some(Arc::new(callback) as StoredCallback);
122 if was_none {
123 ffi_sdk::ditto_logger_set_custom_log_cb(Some(ffi_sdk::CustomLogCb(
124 custom_log_callback_shim,
125 )));
126 }
127 }
128
129 /// Removes any registered custom log callback.
130 ///
131 /// After calling this method, log messages will no longer be forwarded to
132 /// the previously registered callback.
133 pub fn clear_custom_log_callback() {
134 let mut guard = CUSTOM_LOG_CALLBACK.lock().unwrap();
135 ffi_sdk::ditto_logger_set_custom_log_cb(None);
136 *guard = None;
137 }
138}
139
140/// [`DittoLogger::export_to_file()`] API.
141impl DittoLogger {
142 fn rx_export_to_file(file_path: &Path) -> oneshot::Receiver<Result<u64>> {
143 let (tx, rx) = oneshot::channel();
144 let mut tx = Some(tx);
145 ffi_sdk::dittoffi_logger_try_export_to_file_async(
146 char_p::new(file_path.to_str().expect("path to be UTF-8")).as_ref(),
147 #[allow(clippy::useless_conversion)] // False positive (AFAICT)
148 BoxDynFnMut1::new(Box::new(move |ffi_result: ::ffi_sdk::FfiResult<u64>| {
149 tx.take()
150 .expect("completion callback to be called exactly once")
151 .send(ffi_result.into_rust_result().map_err(DittoError::from))
152 .ok();
153 }))
154 .into(),
155 );
156 rx
157 }
158
159 /// Exports collected logs to a compressed and JSON-encoded file on the
160 /// local file system.
161 ///
162 /// `DittoLogger` locally collects a limited amount of logs at the `LogLevel::Debug`
163 /// level and above, periodically discarding old logs. The internal logger is
164 /// always enabled and works independently of the `enabled` setting and the
165 /// configured `minimum_log_level`. Its logs can be requested and downloaded
166 /// from any peer that is active in a Ditto app using the portal's device
167 /// dashboard. This method provides an alternative way of accessing those
168 /// logs by exporting them to the local filesystem.
169 ///
170 /// The logs will be written as a gzip-compressed file at the path specified
171 /// by the `file_path` parameter. When uncompressed, the file contains one
172 /// JSON value per line with the oldest entry on the first line (JSON lines
173 /// format).
174 ///
175 /// By default, Ditto limits the amount of logs it retains on disk to 15 MB
176 /// and a maximum age of 15 days. Older logs are periodically discarded once
177 /// one of these limits is reached.
178 ///
179 /// This method currently only exports logs from the most recently created
180 /// Ditto instance, even when multiple instances are running in the same
181 /// process.
182 ///
183 /// - **Parameter** `file_path`: the path of the file to write the logs to. The file must not
184 /// already exist, and the containing directory must exist. **It is recommended for the path
185 /// to have the `.jsonl.gz` file extension** but Ditto won't enforce it, nor correct it.
186 ///
187 /// - **Errors**: it can run into I/O errors when the file cannot be written to disk. Prevent
188 /// this by ensuring that no file exists at the provided path, all parent directories exist,
189 /// sufficient permissions are granted, and that the disk is not full.
190 ///
191 /// More precisely, this "throws" a [`DittoError`] whose [`.kind()`][DittoError::kind()] is
192 /// that of an [`ErrorKind::CoreApi`], such as:
193 ///
194 /// - [`CoreApiErrorKind::IoNotFound`]
195 /// - [`CoreApiErrorKind::IoPermissionDenied`]
196 /// - [`CoreApiErrorKind::IoAlreadyExists`]
197 /// - [`CoreApiErrorKind::IoOperationFailed`]
198 ///
199 /// - **Returns**: the number of bytes written to disk.
200 ///
201 /// # Example
202 ///
203 /// ```
204 /// use dittolive_ditto::{
205 /// error::{CoreApiErrorKind, ErrorKind},
206 /// prelude::*,
207 /// };
208 /// # use eprintln as error;
209 /// # #[tokio::main]
210 /// # async fn main() {
211 /// # let (_root, _ditto) = dittolive_ditto::doctest_helpers::doctest_ditto();
212 /// # let export_path = std::env::temp_dir().join(format!("ditto-export-{}.jsonl.gz", std::process::id()));
213 /// # let export_path = export_path.to_str().unwrap();
214 ///
215 /// // At least one Ditto instance must exist before calling export_to_file.
216 /// match DittoLogger::export_to_file(export_path).await {
217 /// Ok(_bytes_written) => { /* … */ }
218 /// Err(err) => match err.kind() {
219 /// ErrorKind::CoreApi(CoreApiErrorKind::IoNotFound) => { /* … */ }
220 /// ErrorKind::CoreApi(CoreApiErrorKind::IoPermissionDenied) => { /* … */ }
221 /// ErrorKind::CoreApi(CoreApiErrorKind::IoAlreadyExists) => { /* … */ }
222 /// ErrorKind::CoreApi(CoreApiErrorKind::IoOperationFailed) => { /* … */ }
223 /// _ => error!("{err}"),
224 /// },
225 /// }
226 /// # }
227 /// ```
228 pub async fn export_to_file(file_path: &(impl ?Sized + AsRef<Path>)) -> Result<u64> {
229 Self::rx_export_to_file(file_path.as_ref())
230 .await
231 .expect("channel to be used by the FFI")
232 }
233
234 #[cfg(any(test, doctest))] // NOT exported yet until we settle on whether to Townhousify this API.
235 /// Convenience function around [`Self::export_to_file()`], to be used when outside
236 /// of `async`hronous contexts, by falling back to a blocking call.
237 ///
238 /// ### Panics
239 ///
240 /// This function may panic if called from within an asynchronous context.
241 pub fn blocking_export_to_file(file_path: &(impl ?Sized + AsRef<Path>)) -> Result<u64> {
242 Self::rx_export_to_file(file_path.as_ref())
243 .blocking_recv()
244 .expect("channel to be used by the FFI")
245 }
246}
247
248#[cfg(any(test, doctest))]
249#[path = "logger_tests.rs"]
250mod tests;