Skip to main content

forge_dioxus/
signals.rs

1//! Signals: product analytics and frontend diagnostics for Dioxus apps.
2//!
3//! Tracks user behavior, page views, errors, and custom events.
4//! GDPR-compliant (no cookies, no persistent client IDs).
5//!
6//! ## Usage
7//!
8//! ```rust,ignore
9//! let signals = use_signals();
10//! signals.track("button_clicked", json!({"id": "signup"}));
11//! signals.capture_error("Something went wrong", json!({}));
12//! ```
13
14use std::cell::RefCell;
15use std::collections::VecDeque;
16use std::rc::Rc;
17use std::sync::atomic::{AtomicU64, Ordering};
18
19use dioxus::prelude::*;
20use serde::Serialize;
21use serde_json::{Value, json};
22
23use crate::ForgeClient;
24
25const DEFAULT_FLUSH_INTERVAL_MS: u32 = 5000;
26const DEFAULT_MAX_BATCH: usize = 20;
27const MAX_BREADCRUMBS: usize = 20;
28const MAX_QUEUE_SIZE: usize = 1000;
29#[cfg(target_arch = "wasm32")]
30const AUTO_CAPTURE_DELAY_MS: u64 = 2000;
31
32fn now_iso() -> String {
33    #[cfg(target_arch = "wasm32")]
34    {
35        js_sys::Date::new_0().to_iso_string().as_string().unwrap_or_default()
36    }
37    #[cfg(not(target_arch = "wasm32"))]
38    {
39        // ISO 8601 without chrono: "2024-03-28T12:34:56Z"
40        let dur = std::time::SystemTime::now()
41            .duration_since(std::time::UNIX_EPOCH)
42            .unwrap_or_default();
43        let secs = dur.as_secs();
44        // Days since epoch
45        let days = secs / 86400;
46        let time_of_day = secs % 86400;
47        let hours = time_of_day / 3600;
48        let minutes = (time_of_day % 3600) / 60;
49        let seconds = time_of_day % 60;
50        // Convert days since 1970-01-01 to y/m/d
51        let (year, month, day) = days_to_date(days);
52        format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
53    }
54}
55
56#[cfg(not(target_arch = "wasm32"))]
57fn days_to_date(days: u64) -> (u64, u64, u64) {
58    // Civil date from days since Unix epoch (Euclidean affine algorithm)
59    let z = days + 719468;
60    let era = z / 146097;
61    let doe = z - era * 146097;
62    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
63    let y = yoe + era * 400;
64    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
65    let mp = (5 * doy + 2) / 153;
66    let d = doy - (153 * mp + 2) / 5 + 1;
67    let m = if mp < 10 { mp + 3 } else { mp - 9 };
68    let y = if m <= 2 { y + 1 } else { y };
69    (y, m, d)
70}
71
72static CORRELATION_COUNTER: AtomicU64 = AtomicU64::new(0);
73
74/// Generate a correlation ID (counter + random suffix).
75fn generate_correlation_id() -> String {
76    let counter = CORRELATION_COUNTER.fetch_add(1, Ordering::Relaxed);
77    format!("dx-{counter}-{:08x}", rand_u32())
78}
79
80fn rand_u32() -> u32 {
81    #[cfg(target_arch = "wasm32")]
82    {
83        (js_sys::Math::random() * f64::from(u32::MAX)) as u32
84    }
85    #[cfg(not(target_arch = "wasm32"))]
86    {
87        use std::collections::hash_map::RandomState;
88        use std::hash::{BuildHasher, Hasher};
89        RandomState::new().build_hasher().finish() as u32
90    }
91}
92
93/// Configuration for signals collection.
94#[derive(Clone, Debug, PartialEq)]
95pub struct SignalsConfig {
96    /// Enable signals collection (default: true).
97    pub enabled: bool,
98    /// Auto-track page views on navigation (default: true).
99    pub auto_page_views: bool,
100    /// Auto-capture frontend errors (default: true).
101    pub auto_capture_errors: bool,
102    /// Flush interval in ms (default: 5000).
103    pub flush_interval: u32,
104    /// Max events per batch (default: 20).
105    pub max_batch_size: usize,
106}
107
108impl Default for SignalsConfig {
109    fn default() -> Self {
110        Self {
111            enabled: true,
112            auto_page_views: true,
113            auto_capture_errors: true,
114            flush_interval: DEFAULT_FLUSH_INTERVAL_MS,
115            max_batch_size: DEFAULT_MAX_BATCH,
116        }
117    }
118}
119
120#[derive(Clone, Serialize)]
121struct SignalEventPayload {
122    event: String,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    properties: Option<Value>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    correlation_id: Option<String>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    timestamp: Option<String>,
129}
130
131#[derive(Clone, Serialize)]
132struct EventBatch {
133    events: Vec<SignalEventPayload>,
134    context: Option<BatchContext>,
135}
136
137#[derive(Clone, Serialize)]
138struct BatchContext {
139    #[serde(skip_serializing_if = "Option::is_none")]
140    page_url: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    session_id: Option<String>,
143}
144
145#[derive(Clone, Serialize)]
146struct DiagnosticPayload {
147    errors: Vec<ErrorPayload>,
148}
149
150#[derive(Clone, Serialize)]
151struct ErrorPayload {
152    message: String,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    stack: Option<String>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    context: Option<Value>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    correlation_id: Option<String>,
159    #[serde(skip_serializing_if = "VecDeque::is_empty")]
160    breadcrumbs: VecDeque<BreadcrumbEntry>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    page_url: Option<String>,
163}
164
165#[derive(Clone, Serialize)]
166struct BreadcrumbEntry {
167    message: String,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    data: Option<Value>,
170    timestamp: String,
171}
172
173struct SignalsInner {
174    client: ForgeClient,
175    config: SignalsConfig,
176    queue: Vec<SignalEventPayload>,
177    breadcrumbs: VecDeque<BreadcrumbEntry>,
178    session_id: Option<String>,
179    last_correlation_id: Option<String>,
180    utm_params: Option<Value>,
181    destroyed: bool,
182}
183
184/// Product analytics and diagnostics handle.
185///
186/// Obtain via `use_signals()` inside a `ForgeProvider`.
187#[derive(Clone)]
188pub struct ForgeSignals {
189    inner: Rc<RefCell<SignalsInner>>,
190}
191
192impl ForgeSignals {
193    /// Create a new signals instance tied to a ForgeClient.
194    pub fn new(client: ForgeClient, config: SignalsConfig) -> Self {
195        let utm_params = if config.enabled { extract_utm() } else { None };
196        Self {
197            inner: Rc::new(RefCell::new(SignalsInner {
198                client,
199                config,
200                queue: Vec::new(),
201                breadcrumbs: VecDeque::new(),
202                session_id: None,
203                last_correlation_id: None,
204                utm_params,
205                destroyed: false,
206            })),
207        }
208    }
209
210    /// Track a custom event.
211    pub fn track(&self, event: &str, properties: Value) {
212        let inner = self.inner.borrow();
213        if !inner.config.enabled { return; }
214        drop(inner);
215
216        let correlation_id = self.inner.borrow().last_correlation_id.clone();
217        let payload = SignalEventPayload {
218            event: event.to_string(),
219            properties: Some(properties),
220            correlation_id,
221            timestamp: Some(now_iso()),
222        };
223        let mut inner = self.inner.borrow_mut();
224        inner.queue.push(payload);
225        let should_flush = inner.queue.len() >= inner.config.max_batch_size;
226        drop(inner);
227        if should_flush {
228            let this = self.clone();
229            spawn(async move { this.flush().await; });
230        }
231    }
232
233    /// Identify the current user (links session to user).
234    pub async fn identify(&self, user_id: &str, traits: Value) {
235        let (url, session_id) = {
236            let inner = self.inner.borrow();
237            if !inner.config.enabled { return; }
238            (inner.client.get_url().to_string(), inner.session_id.clone())
239        };
240
241        let body = json!({ "user_id": user_id, "traits": traits });
242        let _ = post_signal(&url, "signal/user", &body, session_id.as_deref()).await;
243    }
244
245    /// Track a page view.
246    pub async fn page(&self, url_path: &str) {
247        let (base_url, session_id, utm) = {
248            let mut inner = self.inner.borrow_mut();
249            if !inner.config.enabled { return; }
250            let utm = inner.utm_params.take();
251            (inner.client.get_url().to_string(), inner.session_id.clone(), utm)
252        };
253
254        let mut payload = json!({ "url": url_path });
255        if let Some(utm_val) = utm
256            && let (Some(target), Some(source)) = (payload.as_object_mut(), utm_val.as_object())
257        {
258            for (k, v) in source {
259                target.insert(k.clone(), v.clone());
260            }
261        }
262
263        if let Ok(resp) = post_signal(&base_url, "signal/view", &payload, session_id.as_deref()).await
264            && let Some(sid) = resp.get("session_id").and_then(|v| v.as_str())
265        {
266            self.inner.borrow_mut().session_id = Some(sid.to_string());
267        }
268    }
269
270    /// Capture a frontend error with optional context.
271    pub async fn capture_error(&self, message: &str, context: Value) {
272        let (url, session_id, correlation_id, breadcrumbs, page_url) = {
273            let inner = self.inner.borrow();
274            if !inner.config.enabled { return; }
275            (
276                inner.client.get_url().to_string(),
277                inner.session_id.clone(),
278                inner.last_correlation_id.clone(),
279                inner.breadcrumbs.clone(),
280                current_page_url(),
281            )
282        };
283
284        let body = DiagnosticPayload {
285            errors: vec![ErrorPayload {
286                message: message.to_string(),
287                stack: None,
288                context: Some(context),
289                correlation_id,
290                breadcrumbs,
291                page_url,
292            }],
293        };
294        let _ = post_signal(
295            &url,
296            "signal/report",
297            &serde_json::to_value(&body).unwrap_or_default(),
298            session_id.as_deref(),
299        )
300        .await;
301    }
302
303    /// Add a breadcrumb for error reproduction context.
304    pub fn breadcrumb(&self, message: &str, data: Option<Value>) {
305        let mut inner = self.inner.borrow_mut();
306        if !inner.config.enabled { return; }
307        inner.breadcrumbs.push_back(BreadcrumbEntry {
308            message: message.to_string(),
309            data,
310            timestamp: now_iso(),
311        });
312        if inner.breadcrumbs.len() > MAX_BREADCRUMBS {
313            inner.breadcrumbs.pop_front();
314        }
315    }
316
317    /// Generate a correlation ID for the next RPC call.
318    pub fn next_correlation_id(&self) -> String {
319        let id = generate_correlation_id();
320        self.inner.borrow_mut().last_correlation_id = Some(id.clone());
321        id
322    }
323
324    #[must_use]
325    pub fn get_session_id(&self) -> Option<String> {
326        self.inner.borrow().session_id.clone()
327    }
328
329    /// Flush queued events to the server.
330    pub async fn flush(&self) {
331        let (url, mut events, session_id) = {
332            let mut inner = self.inner.borrow_mut();
333            if inner.queue.is_empty() { return; }
334            let max = inner.config.max_batch_size;
335            let count = inner.queue.len().min(max);
336            let events: Vec<_> = inner.queue.drain(..count).collect();
337            (inner.client.get_url().to_string(), events, inner.session_id.clone())
338        };
339
340        let batch = EventBatch {
341            events: events.clone(),
342            context: Some(BatchContext {
343                page_url: current_page_url(),
344                session_id: session_id.clone(),
345            }),
346        };
347
348        match post_signal(
349            &url,
350            "signal/event",
351            &serde_json::to_value(&batch).unwrap_or_default(),
352            session_id.as_deref(),
353        )
354        .await
355        {
356            Ok(resp) => {
357                if let Some(sid) = resp.get("session_id").and_then(|v| v.as_str()) {
358                    self.inner.borrow_mut().session_id = Some(sid.to_string());
359                }
360            }
361            Err(()) => {
362                let mut inner = self.inner.borrow_mut();
363                events.extend(inner.queue.drain(..));
364                events.truncate(MAX_QUEUE_SIZE);
365                inner.queue = events;
366            }
367        }
368    }
369
370    /// Clean up timers and flush remaining events.
371    pub fn destroy(&self) {
372        self.inner.borrow_mut().destroyed = true;
373        flush_beacon(self);
374    }
375
376    #[must_use]
377    pub fn auto_page_views(&self) -> bool {
378        let inner = self.inner.borrow();
379        inner.config.enabled && inner.config.auto_page_views
380    }
381
382    #[must_use]
383    pub fn auto_capture_errors(&self) -> bool {
384        let inner = self.inner.borrow();
385        inner.config.enabled && inner.config.auto_capture_errors
386    }
387
388    #[must_use]
389    pub fn flush_interval(&self) -> u32 {
390        self.inner.borrow().config.flush_interval
391    }
392
393    #[must_use]
394    pub fn is_destroyed(&self) -> bool {
395        self.inner.borrow().destroyed
396    }
397
398    #[must_use]
399    pub fn is_enabled(&self) -> bool {
400        self.inner.borrow().config.enabled
401    }
402}
403
404/// Send remaining events via beacon API on page unload (WASM only).
405fn flush_beacon(signals: &ForgeSignals) {
406    let (url, events, session_id) = {
407        let mut inner = signals.inner.borrow_mut();
408        if inner.queue.is_empty() { return; }
409        let events = std::mem::take(&mut inner.queue);
410        (inner.client.get_url().to_string(), events, inner.session_id.clone())
411    };
412
413    let batch = EventBatch {
414        events,
415        context: Some(BatchContext {
416            page_url: current_page_url(),
417            session_id,
418        }),
419    };
420
421    let body = serde_json::to_string(&batch).unwrap_or_default();
422
423    #[cfg(target_arch = "wasm32")]
424    {
425        let url = format!("{url}/_api/signal/event");
426        if let Some(navigator) = web_sys::window().map(|w| w.navigator()) {
427            let _ = navigator.send_beacon_with_opt_str(&url, Some(&body));
428        }
429    }
430
431    #[cfg(not(target_arch = "wasm32"))]
432    {
433        let _ = (url, body);
434    }
435}
436
437/// Get current page URL if in browser.
438fn current_page_url() -> Option<String> {
439    #[cfg(target_arch = "wasm32")]
440    {
441        web_sys::window()
442            .and_then(|w| w.location().href().ok())
443    }
444    #[cfg(not(target_arch = "wasm32"))]
445    {
446        None
447    }
448}
449
450/// Extract UTM parameters from query string.
451fn extract_utm() -> Option<Value> {
452    #[cfg(target_arch = "wasm32")]
453    {
454        let search = web_sys::window()?.location().search().ok()?;
455        let params = web_sys::UrlSearchParams::new_with_str(&search).ok()?;
456        let mut utm = serde_json::Map::new();
457        for key in &["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"] {
458            if let Some(val) = params.get(key) {
459                utm.insert(key.to_string(), Value::String(val));
460            }
461        }
462        if utm.is_empty() { None } else { Some(Value::Object(utm)) }
463    }
464    #[cfg(not(target_arch = "wasm32"))]
465    {
466        None
467    }
468}
469
470/// Platform identifier sent as `x-forge-platform` header so the server
471/// can populate `device_type` without guessing from the User-Agent.
472pub(crate) fn platform_tag() -> &'static str {
473    #[cfg(target_arch = "wasm32")]
474    { "web" }
475
476    #[cfg(not(target_arch = "wasm32"))]
477    {
478        #[cfg(target_os = "macos")]
479        { "desktop-macos" }
480        #[cfg(target_os = "ios")]
481        { "ios" }
482        #[cfg(target_os = "android")]
483        { "android" }
484        #[cfg(target_os = "windows")]
485        { "desktop-windows" }
486        #[cfg(target_os = "linux")]
487        { "desktop-linux" }
488        #[cfg(not(any(
489            target_os = "macos",
490            target_os = "ios",
491            target_os = "android",
492            target_os = "windows",
493            target_os = "linux",
494        )))]
495        { "desktop" }
496    }
497}
498
499/// POST to a signal endpoint and return the JSON response.
500async fn post_signal(
501    base_url: &str,
502    path: &str,
503    body: &Value,
504    session_id: Option<&str>,
505) -> Result<Value, ()> {
506    #[cfg(target_arch = "wasm32")]
507    {
508        use gloo_net::http::Request;
509        let mut req = Request::post(&format!("{base_url}/_api/{path}"))
510            .header("Content-Type", "application/json")
511            .header("x-forge-platform", platform_tag());
512        if let Some(sid) = session_id {
513            req = req.header("x-session-id", sid);
514        }
515        let resp = req.body(body.to_string()).map_err(|_| ())?.send().await.map_err(|_| ())?;
516        resp.json().await.map_err(|_| ())
517    }
518    #[cfg(not(target_arch = "wasm32"))]
519    {
520        use reqwest::Client;
521        let mut req = Client::new()
522            .post(format!("{base_url}/_api/{path}"))
523            .header("x-forge-platform", platform_tag())
524            .json(body);
525        if let Some(sid) = session_id {
526            req = req.header("x-session-id", sid);
527        }
528        let resp = req.send().await.map_err(|_| ())?;
529        resp.json().await.map_err(|_| ())
530    }
531}
532
533/// Hook to access the signals instance from within a ForgeProvider.
534pub fn use_signals() -> ForgeSignals {
535    use_context::<ForgeSignals>()
536}
537
538/// Setup auto-capture features (page views, errors, periodic flush, unload flush).
539/// Called from ForgeProvider after signals are provided as context.
540#[cfg(target_arch = "wasm32")]
541pub(crate) fn setup_auto_capture(signals: ForgeSignals) {
542    use wasm_bindgen::closure::Closure;
543    use wasm_bindgen::JsCast;
544
545    if !signals.is_enabled() { return; }
546
547    let flush_interval = signals.flush_interval();
548
549    // Periodic flush timer
550    {
551        let signals = signals.clone();
552        spawn(async move {
553            loop {
554                gloo_timers::future::sleep(std::time::Duration::from_millis(u64::from(flush_interval))).await;
555                if signals.is_destroyed() { break; }
556                signals.flush().await;
557            }
558        });
559    }
560
561    // Deferred auto-capture setup to avoid competing with SSE for DB pool connections
562    {
563        let signals = signals.clone();
564        spawn(async move {
565            gloo_timers::future::sleep(std::time::Duration::from_millis(AUTO_CAPTURE_DELAY_MS)).await;
566            if signals.is_destroyed() { return; }
567
568            let window = match web_sys::window() {
569                Some(w) => w,
570                None => return,
571            };
572
573            // Auto page views: track initial + monkey-patch history for SPA navigation
574            if signals.auto_page_views() {
575                let path = window.location().pathname().unwrap_or_else(|_| "/".to_string());
576                let signals_page = signals.clone();
577                spawn(async move { signals_page.page(&path).await; });
578
579                // Listen for navigation events (pushState, replaceState, popstate)
580                {
581                    let signals = signals.clone();
582                    let closure = Closure::<dyn Fn()>::new(move || {
583                        let path = web_sys::window()
584                            .and_then(|w| w.location().pathname().ok())
585                            .unwrap_or_else(|| "/".to_string());
586                        let signals = signals.clone();
587                        spawn(async move { signals.page(&path).await; });
588                    });
589                    let _ = window.add_event_listener_with_callback(
590                        "forge-pushstate",
591                        closure.as_ref().unchecked_ref(),
592                    );
593                    let _ = window.add_event_listener_with_callback(
594                        "popstate",
595                        closure.as_ref().unchecked_ref(),
596                    );
597                    // WASM closures passed to JS have no destructor hook, must leak
598                    closure.forget();
599                }
600
601                // Monkey-patch pushState/replaceState to dispatch custom events
602                // Only fires when the URL actually changes to avoid redundant page views
603                let patch_js = r#"
604                    (function() {
605                        var origPush = history.pushState;
606                        var origReplace = history.replaceState;
607                        history.pushState = function() {
608                            var before = location.href;
609                            origPush.apply(this, arguments);
610                            if (location.href !== before) {
611                                window.dispatchEvent(new Event('forge-pushstate'));
612                            }
613                        };
614                        history.replaceState = function() {
615                            var before = location.href;
616                            origReplace.apply(this, arguments);
617                            if (location.href !== before) {
618                                window.dispatchEvent(new Event('forge-pushstate'));
619                            }
620                        };
621                    })()
622                "#;
623                let _ = js_sys::eval(patch_js);
624            }
625
626            // Auto error capture
627            if signals.auto_capture_errors() {
628                // window.onerror
629                {
630                    let signals = signals.clone();
631                    let closure = Closure::<dyn Fn(web_sys::ErrorEvent)>::new(move |e: web_sys::ErrorEvent| {
632                        let msg = e.message();
633                        if msg.is_empty() { return; }
634                        let signals = signals.clone();
635                        spawn(async move { signals.capture_error(&msg, json!({})).await; });
636                    });
637                    let _ = window.add_event_listener_with_callback(
638                        "error",
639                        closure.as_ref().unchecked_ref(),
640                    );
641                    // WASM closures passed to JS have no destructor hook, must leak
642                    closure.forget();
643                }
644
645                {
646                    let signals = signals.clone();
647                    let closure = Closure::<dyn Fn(web_sys::PromiseRejectionEvent)>::new(
648                        move |e: web_sys::PromiseRejectionEvent| {
649                            let reason = e.reason();
650                            let msg = reason.as_string().unwrap_or_else(|| "Unhandled promise rejection".to_string());
651                            let signals = signals.clone();
652                            spawn(async move { signals.capture_error(&msg, json!({})).await; });
653                        },
654                    );
655                    let _ = window.add_event_listener_with_callback(
656                        "unhandledrejection",
657                        closure.as_ref().unchecked_ref(),
658                    );
659                    closure.forget();
660                }
661            }
662
663            // Flush via beacon when page goes hidden (tab close, navigate away)
664            {
665                let signals = signals.clone();
666                let closure = Closure::<dyn Fn()>::new(move || {
667                    if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
668                        if doc.visibility_state() == web_sys::VisibilityState::Hidden {
669                            flush_beacon(&signals);
670                        }
671                    }
672                });
673                if let Some(doc) = window.document() {
674                    let _ = doc.add_event_listener_with_callback(
675                        "visibilitychange",
676                        closure.as_ref().unchecked_ref(),
677                    );
678                }
679                closure.forget();
680            }
681        });
682    }
683}
684
685#[cfg(not(target_arch = "wasm32"))]
686pub(crate) fn setup_auto_capture(signals: ForgeSignals) {
687    if !signals.is_enabled() { return; }
688
689    let flush_interval = signals.flush_interval();
690
691    // Periodic flush timer using tokio
692    {
693        let signals = signals.clone();
694        spawn(async move {
695            let mut interval = tokio::time::interval(
696                std::time::Duration::from_millis(u64::from(flush_interval)),
697            );
698            loop {
699                interval.tick().await;
700                if signals.is_destroyed() { break; }
701                signals.flush().await;
702            }
703        });
704    }
705
706    // Capture panics as error reports (desktop/mobile only, WASM uses window.error).
707    // The panic hook requires Send+Sync, but ForgeSignals uses Rc (single-threaded).
708    // We capture just the base URL and send the report directly via reqwest.
709    if signals.auto_capture_errors() {
710        let base_url = {
711            let inner = signals.inner.borrow();
712            inner.client.get_url().to_string()
713        };
714        let prev = std::panic::take_hook();
715        std::panic::set_hook(Box::new(move |info| {
716            let msg = info
717                .payload()
718                .downcast_ref::<&str>()
719                .copied()
720                .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
721                .unwrap_or("panic");
722            let location = info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()));
723            let url = format!("{}/_api/signal/report", base_url);
724            let body = serde_json::json!({
725                "errors": [{
726                    "message": msg,
727                    "context": { "location": location, "kind": "panic" },
728                }]
729            });
730            // Fire-and-forget on a background thread since we can't use the
731            // single-threaded Dioxus runtime from inside a panic hook.
732            let _ = std::thread::spawn(move || {
733                let rt = tokio::runtime::Builder::new_current_thread()
734                    .enable_all()
735                    .build();
736                if let Ok(rt) = rt {
737                    let _ = rt.block_on(async {
738                        let _ = reqwest::Client::new()
739                            .post(&url)
740                            .header("x-forge-platform", platform_tag())
741                            .json(&body)
742                            .send()
743                            .await;
744                    });
745                }
746            });
747            prev(info);
748        }));
749    }
750}
751
752#[cfg(test)]
753#[allow(clippy::unwrap_used)]
754mod tests {
755    use super::*;
756
757    #[test]
758    fn signals_config_defaults() {
759        let config = SignalsConfig::default();
760        assert!(config.enabled);
761        assert!(config.auto_page_views);
762        assert!(config.auto_capture_errors);
763        assert_eq!(config.flush_interval, 5000);
764        assert_eq!(config.max_batch_size, 20);
765    }
766
767    #[test]
768    fn correlation_id_format() {
769        let id = generate_correlation_id();
770        let parts: Vec<&str> = id.split('-').collect();
771        assert_eq!(parts.first().copied(), Some("dx"));
772        assert!(parts.get(1).unwrap().parse::<u64>().is_ok());
773        let hex_part = parts.get(2).unwrap();
774        assert_eq!(hex_part.len(), 8);
775        assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
776    }
777
778    #[test]
779    fn correlation_ids_are_unique() {
780        let a = generate_correlation_id();
781        let b = generate_correlation_id();
782        assert_ne!(a, b);
783    }
784
785    #[test]
786    fn platform_tag_returns_expected_value() {
787        let tag = platform_tag();
788        let known = [
789            "web",
790            "desktop-macos",
791            "desktop-linux",
792            "desktop-windows",
793            "ios",
794            "android",
795            "desktop",
796        ];
797        assert!(
798            known.contains(&tag),
799            "unexpected platform tag: {tag}",
800        );
801    }
802
803    #[test]
804    fn now_iso_produces_valid_format() {
805        let ts = now_iso();
806        // Expected: "YYYY-MM-DDTHH:MM:SSZ"
807        assert_eq!(ts.len(), 20, "unexpected length for timestamp: {ts}");
808        assert_eq!(ts.as_bytes().get(4).copied(), Some(b'-'));
809        assert_eq!(ts.as_bytes().get(7).copied(), Some(b'-'));
810        assert_eq!(ts.as_bytes().get(10).copied(), Some(b'T'));
811        assert_eq!(ts.as_bytes().get(13).copied(), Some(b':'));
812        assert_eq!(ts.as_bytes().get(16).copied(), Some(b':'));
813        assert_eq!(ts.as_bytes().get(19).copied(), Some(b'Z'));
814    }
815
816    #[test]
817    fn days_to_date_epoch_start() {
818        assert_eq!(days_to_date(0), (1970, 1, 1));
819    }
820
821    #[test]
822    fn days_to_date_known_date() {
823        // 2024-03-28 is 19810 days after 1970-01-01
824        assert_eq!(days_to_date(19810), (2024, 3, 28));
825    }
826
827    #[test]
828    fn days_to_date_leap_year() {
829        // 2000-02-29 is day 11016
830        assert_eq!(days_to_date(11016), (2000, 2, 29));
831    }
832
833    #[test]
834    fn days_to_date_century_non_leap() {
835        // 2100 is not a leap year; 2100-03-01 is day 47541
836        assert_eq!(days_to_date(47541), (2100, 3, 1));
837    }
838
839    #[test]
840    fn now_iso_is_recent() {
841        let ts = now_iso();
842        let year: u32 = ts.get(..4).unwrap().parse().unwrap();
843        assert!(year >= 2025, "expected year >= 2025, got {year}");
844    }
845}