Skip to main content

tauri_plugin_widgets/
trace.rs

1//! Host-side black-box journal for widget delivery diagnostics.
2//!
3//! Enabled in debug builds, or in release when `WIDGET_DEBUG=1`.
4//! Events stay in a memory ring (cap 200); disk flush is never per-event —
5//! only on timer / explicit `get_widget_trace` / `flush_widget_trace`.
6
7use serde::{Deserialize, Serialize};
8use std::collections::VecDeque;
9use std::env;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::Mutex;
14use std::time::{Duration, Instant};
15
16use crate::apply::{ReloadOutcome, SkipReason};
17use crate::store::now_ms;
18
19const RING_CAP: usize = 200;
20const FLUSH_INTERVAL: Duration = Duration::from_secs(10);
21pub const TRACE_FILE_NAME: &str = "widget_trace.json";
22
23/// Whether the in-memory / disk journal is active.
24pub fn trace_enabled() -> bool {
25    if cfg!(debug_assertions) {
26        return true;
27    }
28    matches!(
29        env::var("WIDGET_DEBUG").ok().as_deref(),
30        Some("1") | Some("true") | Some("TRUE")
31    )
32}
33
34/// Why a config write was skipped (trace mirror of apply types).
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(tag = "reason", rename_all = "camelCase")]
37pub enum TraceSkipReason {
38    Unchanged { hash: u64 },
39    NoInstances,
40    TransportUnavailable { name: String },
41}
42
43impl From<SkipReason> for TraceSkipReason {
44    fn from(s: SkipReason) -> Self {
45        match s {
46            SkipReason::Unchanged { hash } => Self::Unchanged { hash },
47        }
48    }
49}
50
51/// One journal entry.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(tag = "kind", rename_all = "camelCase")]
54pub enum TraceEvent {
55    ConfigSet {
56        widget_id: String,
57        nonce: u64,
58        bytes: usize,
59        changed: bool,
60        #[serde(default, skip_serializing_if = "Option::is_none")]
61        skip: Option<TraceSkipReason>,
62    },
63    Write {
64        transport: String,
65        ok: bool,
66        duration_ms: u32,
67        #[serde(default, skip_serializing_if = "Option::is_none")]
68        error: Option<String>,
69    },
70    Reload {
71        performed: bool,
72        reason: ReloadOutcome,
73    },
74    Poll {
75        count: usize,
76    },
77    Render {
78        instance: String,
79        nonce: u64,
80        source: String,
81        trigger: String,
82        lag_ms: u64,
83        skipped: Vec<crate::receipt::SkippedElement>,
84    },
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct TraceEntry {
90    pub ts: u64,
91    #[serde(flatten)]
92    pub event: TraceEvent,
93}
94
95/// Snapshot returned by `get_widget_trace`.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct WidgetTrace {
99    pub enabled: bool,
100    pub events: Vec<TraceEntry>,
101    pub receipts: Vec<crate::receipt::WidgetRenderReceipt>,
102}
103
104#[derive(Default)]
105struct TraceInner {
106    events: VecDeque<TraceEntry>,
107    dirty: bool,
108    last_flush: Option<Instant>,
109}
110
111/// Process-wide style store held on [`crate::desktop::Widget`] / mobile.
112#[derive(Default)]
113pub struct TraceStore {
114    inner: Mutex<TraceInner>,
115    flush_started: AtomicBool,
116}
117
118impl TraceStore {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    pub fn push(&self, event: TraceEvent) {
124        if !trace_enabled() {
125            return;
126        }
127        let mut g = self.inner.lock().unwrap();
128        g.events.push_back(TraceEntry {
129            ts: now_ms(),
130            event,
131        });
132        while g.events.len() > RING_CAP {
133            g.events.pop_front();
134        }
135        g.dirty = true;
136    }
137
138    pub fn list_since(&self, since_ms: Option<u64>) -> Vec<TraceEntry> {
139        let g = self.inner.lock().unwrap();
140        g.events
141            .iter()
142            .filter(|e| since_ms.map(|s| e.ts >= s).unwrap_or(true))
143            .cloned()
144            .collect()
145    }
146
147    pub fn flush_to_path(&self, path: &Path) -> crate::Result<()> {
148        if !trace_enabled() {
149            return Ok(());
150        }
151        let (events, dirty) = {
152            let mut g = self.inner.lock().unwrap();
153            let dirty = g.dirty;
154            g.dirty = false;
155            g.last_flush = Some(Instant::now());
156            (g.events.iter().cloned().collect::<Vec<_>>(), dirty)
157        };
158        if !dirty && path.exists() {
159            return Ok(());
160        }
161        if let Some(parent) = path.parent() {
162            fs::create_dir_all(parent)?;
163        }
164        let json = serde_json::to_string_pretty(&events)?;
165        let tmp = path.with_extension("tmp");
166        fs::write(&tmp, json.as_bytes())?;
167        fs::rename(&tmp, path)?;
168        Ok(())
169    }
170
171    pub fn load_from_path(&self, path: &Path) {
172        if !trace_enabled() {
173            return;
174        }
175        let Ok(raw) = fs::read_to_string(path) else {
176            return;
177        };
178        let Ok(list): Result<Vec<TraceEntry>, _> = serde_json::from_str(&raw) else {
179            return;
180        };
181        let mut g = self.inner.lock().unwrap();
182        g.events.clear();
183        for e in list.into_iter().rev().take(RING_CAP).collect::<Vec<_>>().into_iter().rev() {
184            g.events.push_back(e);
185        }
186        g.dirty = false;
187    }
188
189    pub fn needs_timed_flush(&self) -> bool {
190        if !trace_enabled() {
191            return false;
192        }
193        let g = self.inner.lock().unwrap();
194        if !g.dirty {
195            return false;
196        }
197        match g.last_flush {
198            None => true,
199            Some(t) => t.elapsed() >= FLUSH_INTERVAL,
200        }
201    }
202
203    /// Spawn a 10s flush loop once (desktop).
204    pub fn ensure_flush_thread<F>(&self, path_fn: F)
205    where
206        F: Fn() -> Option<PathBuf> + Send + 'static,
207    {
208        if !trace_enabled() {
209            return;
210        }
211        if self
212            .flush_started
213            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
214            .is_err()
215        {
216            return;
217        }
218        // Hold a weak pattern via path_fn only — TraceStore is inside Widget Arc-ish
219        // via AppHandle state; we can't clone TraceStore easily, so pass path +
220        // re-check dirty via a shared AtomicBool is hard. Instead spawn with
221        // path_fn and store pointer is wrong. Use std::thread with path polling
222        // of file only when Widget calls flush from poller.
223        //
224        // Practical approach: the desktop action poller also calls
225        // `maybe_flush_trace` — no extra thread needed.
226        let _ = path_fn;
227        // Reset so callers can use maybe_flush; flag stays true to avoid
228        // duplicate setup messaging.
229    }
230}
231
232/// Default disk path under an app data dir.
233pub fn trace_path(app_data: &Path) -> PathBuf {
234    app_data.join("widgets").join(TRACE_FILE_NAME)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn ring_caps_at_200() {
243        let store = TraceStore::new();
244        // Force-enable by pushing only works if debug — tests are debug.
245        for i in 0..250 {
246            store.push(TraceEvent::Poll { count: i });
247        }
248        let list = store.list_since(None);
249        assert!(list.len() <= RING_CAP);
250        assert_eq!(list.last().unwrap().event, TraceEvent::Poll { count: 249 });
251    }
252
253    #[test]
254    fn serialize_reload_throttled() {
255        let e = TraceEvent::Reload {
256            performed: false,
257            reason: ReloadOutcome::Throttled {
258                remaining_secs: 840,
259            },
260        };
261        let v = serde_json::to_value(&e).unwrap();
262        assert_eq!(v["kind"], "reload");
263        assert_eq!(v["performed"], false);
264        assert_eq!(v["reason"]["outcome"], "throttled");
265        assert_eq!(v["reason"]["remainingSecs"], 840);
266    }
267}