Skip to main content

matomo_logger/
lib.rs

1use log::LevelFilter;
2
3#[cfg(target_arch = "wasm32")]
4use log::{Level, Log, Metadata, Record};
5
6#[cfg(target_arch = "wasm32")]
7use wasm_bindgen::prelude::*;
8#[cfg(target_arch = "wasm32")]
9use web_sys::{console, window};
10
11#[derive(Clone, Debug)]
12pub struct MatomoConfig {
13    pub base_url: Option<String>, // e.g. https://matomo.videocall.rs/
14    pub site_id: Option<u32>,
15    pub console_level: LevelFilter,
16    pub matomo_level: LevelFilter,
17    pub inject_snippet: bool,
18}
19
20impl Default for MatomoConfig {
21    fn default() -> Self {
22        Self {
23            base_url: None,
24            site_id: None,
25            console_level: LevelFilter::Info,
26            matomo_level: LevelFilter::Warn,
27            inject_snippet: true,
28        }
29    }
30}
31
32pub struct MatomoLogger {
33    #[allow(dead_code)]
34    min_console_level: LevelFilter,
35    #[allow(dead_code)]
36    min_matomo_level: LevelFilter,
37}
38
39// Public facade: works on all targets by delegating to the platform implementation.
40impl MatomoLogger {
41    pub fn init(config: MatomoConfig) -> Result<(), log::SetLoggerError> {
42        imp::init(config)
43    }
44}
45
46pub fn track_page_view(title: &str, url: &str) {
47    imp::track_page_view_impl(title, url)
48}
49pub fn set_user_id(user_id: &str) {
50    imp::set_user_id_impl(user_id)
51}
52
53#[cfg(target_arch = "wasm32")]
54fn log_to_console(threshold: LevelFilter, record: &Record) {
55    if record.level().to_level_filter() > threshold {
56        return;
57    }
58    let msg = format!(
59        "{}: {} — {}",
60        record.level(),
61        record.target(),
62        record.args()
63    );
64    match record.level() {
65        Level::Error => console::error_1(&JsValue::from_str(&msg)),
66        Level::Warn => console::warn_1(&JsValue::from_str(&msg)),
67        Level::Info => console::info_1(&JsValue::from_str(&msg)),
68        Level::Debug => console::log_1(&JsValue::from_str(&msg)),
69        Level::Trace => console::debug_1(&JsValue::from_str(&msg)),
70    }
71}
72
73#[cfg(target_arch = "wasm32")]
74fn log_to_matomo(threshold: LevelFilter, record: &Record) {
75    if record.level().to_level_filter() > threshold {
76        return;
77    }
78    let mut name = format!("{} — {}", record.target(), record.args());
79    if name.len() > 300 {
80        name.truncate(300);
81    }
82
83    let arr = js_sys::Array::new();
84    arr.push(&JsValue::from_str("trackEvent"));
85    arr.push(&JsValue::from_str("RustLog"));
86    arr.push(&JsValue::from_str(record.level().as_str()));
87    arr.push(&JsValue::from_str(&name));
88    let value = match record.level() {
89        Level::Error => 50,
90        Level::Warn => 40,
91        Level::Info => 30,
92        Level::Debug => 20,
93        Level::Trace => 10,
94    } as f64;
95    arr.push(&JsValue::from_f64(value));
96
97    if has_paq() {
98        push_to_paq(&arr.into());
99    }
100}
101
102#[cfg(target_arch = "wasm32")]
103fn has_paq() -> bool {
104    if let Some(w) = window() {
105        js_sys::Reflect::has(&w, &JsValue::from_str("_paq")).unwrap_or(false)
106    } else {
107        false
108    }
109}
110
111#[cfg(target_arch = "wasm32")]
112fn push_to_paq(args: &JsValue) {
113    if let Some(w) = window() {
114        if let Ok(paq) = js_sys::Reflect::get(&w, &JsValue::from_str("_paq")) {
115            if let Ok(push) = js_sys::Reflect::get(&paq, &JsValue::from_str("push")) {
116                let func: js_sys::Function = push.into();
117                // Use the _paq array as the 'this' value for push
118                if let Err(e) = func.call1(&paq, args) {
119                    console::error_2(&JsValue::from_str("_paq.push error"), &e);
120                }
121            }
122        }
123    }
124}
125
126#[cfg(target_arch = "wasm32")]
127fn maybe_inject_snippet(cfg: &MatomoConfig) {
128    // Only in main thread with window
129    let Some(w) = window() else {
130        return;
131    };
132    if has_paq() {
133        return;
134    }
135    let Some(base) = cfg.base_url.as_ref() else {
136        return;
137    };
138    let Some(site) = cfg.site_id else {
139        return;
140    };
141
142    // Create window._paq and push basic setup
143    let paq = js_sys::Array::new();
144    let _ = js_sys::Reflect::set(&w, &JsValue::from_str("_paq"), &paq);
145
146    let set_tracker = js_sys::Array::new();
147    set_tracker.push(&JsValue::from_str("setTrackerUrl"));
148    set_tracker.push(&JsValue::from_str(&(base.to_string() + "matomo.php")));
149    push_to_paq(&set_tracker.into());
150
151    let set_site = js_sys::Array::new();
152    set_site.push(&JsValue::from_str("setSiteId"));
153    set_site.push(&JsValue::from_str(&site.to_string()));
154    push_to_paq(&set_site.into());
155
156    let enable = js_sys::Array::new();
157    enable.push(&JsValue::from_str("enableLinkTracking"));
158    push_to_paq(&enable.into());
159
160    // Inject matomo.js script tag
161    if let Some(doc) = w.document() {
162        if let Ok(script) = doc.create_element("script") {
163            script.set_attribute("async", "true").ok();
164            script
165                .set_attribute("src", &(base.to_string() + "matomo.js"))
166                .ok();
167            if let Some(head) = doc.head() {
168                let _ = head.append_child(&script);
169            }
170        }
171    }
172}
173
174// Worker bridge API (simple): serialize log event and let host push it to Matomo.
175#[cfg(all(target_arch = "wasm32", feature = "worker"))]
176pub mod worker {
177    use super::*;
178    use web_sys::DedicatedWorkerGlobalScope;
179
180    /// Install a worker-side logger that forwards WARN+ records to the main thread via postMessage.
181    /// The `sender` argument is accepted for compatibility but is ignored; the logger uses postMessage directly.
182    pub fn init_with_bridge(
183        console_level: LevelFilter,
184        matomo_level: LevelFilter,
185        _sender: js_sys::Function,
186    ) -> Result<(), log::SetLoggerError> {
187        let logger = BridgeLogger {
188            console_level,
189            matomo_level,
190        };
191        // Leak to obtain 'static lifetime required by log::set_logger
192        let leaked: &'static BridgeLogger = Box::leak(Box::new(logger));
193        log::set_logger(leaked)?;
194        log::set_max_level(console_level.max(matomo_level));
195        Ok(())
196    }
197
198    struct BridgeLogger {
199        console_level: LevelFilter,
200        matomo_level: LevelFilter,
201    }
202    impl Log for BridgeLogger {
203        fn enabled(&self, _metadata: &Metadata) -> bool {
204            true
205        }
206        fn log(&self, record: &Record) {
207            super::log_to_console(self.console_level, record);
208            if record.level().to_level_filter() > self.matomo_level {
209                return;
210            }
211            // Build a compact log object and post to main thread
212            let obj = js_sys::Object::new();
213            let _ =
214                js_sys::Reflect::set(&obj, &JsValue::from_str("type"), &JsValue::from_str("log"));
215            let _ = js_sys::Reflect::set(
216                &obj,
217                &JsValue::from_str("level"),
218                &JsValue::from_str(record.level().as_str()),
219            );
220            let _ = js_sys::Reflect::set(
221                &obj,
222                &JsValue::from_str("target"),
223                &JsValue::from_str(record.target()),
224            );
225            let _ = js_sys::Reflect::set(
226                &obj,
227                &JsValue::from_str("message"),
228                &JsValue::from_str(&format!("{}", record.args())),
229            );
230            let scope: DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
231            let _ = scope.post_message(&obj);
232        }
233        fn flush(&self) {}
234    }
235}
236
237// Host build (non-wasm or no `worker` feature): provide a stub so root clippy/fmt succeeds
238#[cfg(not(all(target_arch = "wasm32", feature = "worker")))]
239pub mod worker {
240    use super::*;
241    // Accept the same signature but ignore the sender type via a generic to avoid js_sys import
242    pub fn init_with_bridge<T>(
243        _console_level: LevelFilter,
244        _matomo_level: LevelFilter,
245        _sender: T,
246    ) -> Result<(), log::SetLoggerError> {
247        Ok(())
248    }
249}
250
251// ---------------- Platform-specific implementations -----------------
252
253// WASM implementation
254#[cfg(target_arch = "wasm32")]
255mod imp {
256    use super::*;
257
258    impl Log for MatomoLogger {
259        fn enabled(&self, _metadata: &Metadata) -> bool {
260            true
261        }
262        fn log(&self, record: &Record) {
263            super::log_to_console(self.min_console_level, record);
264            super::log_to_matomo(self.min_matomo_level, record);
265        }
266        fn flush(&self) {}
267    }
268
269    pub fn init(config: MatomoConfig) -> Result<(), log::SetLoggerError> {
270        if config.inject_snippet {
271            super::maybe_inject_snippet(&config);
272        }
273        let logger = MatomoLogger {
274            min_console_level: config.console_level,
275            min_matomo_level: config.matomo_level,
276        };
277        let leaked: &'static MatomoLogger = Box::leak(Box::new(logger));
278        log::set_logger(leaked)?;
279        log::set_max_level(config.console_level.max(config.matomo_level));
280        Ok(())
281    }
282
283    pub fn track_page_view_impl(title: &str, url: &str) {
284        // Inline implementation to avoid recursion with the public facade
285        // setCustomUrl
286        let a = js_sys::Array::new();
287        a.push(&JsValue::from_str("setCustomUrl"));
288        a.push(&JsValue::from_str(url));
289        if super::has_paq() {
290            super::push_to_paq(&a.into());
291        }
292
293        // setDocumentTitle
294        let a = js_sys::Array::new();
295        a.push(&JsValue::from_str("setDocumentTitle"));
296        a.push(&JsValue::from_str(title));
297        if super::has_paq() {
298            super::push_to_paq(&a.into());
299        }
300
301        // trackPageView
302        let a = js_sys::Array::new();
303        a.push(&JsValue::from_str("trackPageView"));
304        if super::has_paq() {
305            super::push_to_paq(&a.into());
306        }
307    }
308    pub fn set_user_id_impl(user_id: &str) {
309        // Inline implementation to avoid recursion with the public facade
310        let a = js_sys::Array::new();
311        a.push(&JsValue::from_str("setUserId"));
312        a.push(&JsValue::from_str(user_id));
313        if super::has_paq() {
314            super::push_to_paq(&a.into());
315        }
316    }
317}
318
319// Native/no-op implementation (for clippy/fmt host builds)
320#[cfg(not(target_arch = "wasm32"))]
321mod imp {
322    use super::*;
323    pub fn init(_config: MatomoConfig) -> Result<(), log::SetLoggerError> {
324        log::set_max_level(LevelFilter::Info);
325        Ok(())
326    }
327    pub fn track_page_view_impl(_title: &str, _url: &str) {}
328    pub fn set_user_id_impl(_user_id: &str) {}
329}