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);
21/// On-disk journal filename under the widgets data dir.
22pub const TRACE_FILE_NAME: &str = "widget_trace.json";
23
24/// Whether the in-memory / disk journal is active.
25pub fn trace_enabled() -> bool {
26    if cfg!(debug_assertions) {
27        return true;
28    }
29    matches!(
30        env::var("WIDGET_DEBUG").ok().as_deref(),
31        Some("1") | Some("true") | Some("TRUE")
32    )
33}
34
35/// One journal entry.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37#[serde(tag = "kind", rename_all = "camelCase")]
38pub enum TraceEvent {
39    /// Host applied (or skipped) a config write.
40    ConfigSet {
41        /// Widget id.
42        widget_id: String,
43        /// Map nonce after the write attempt.
44        nonce: u64,
45        /// Serialized config size.
46        bytes: usize,
47        /// `true` when store bytes changed.
48        changed: bool,
49        /// Present when the write was skipped.
50        #[serde(default, skip_serializing_if = "Option::is_none")]
51        skip: Option<SkipReason>,
52    },
53    /// A transport `write` finished.
54    Write {
55        /// Driver name.
56        transport: String,
57        /// Success flag.
58        ok: bool,
59        /// Wall time of the write.
60        duration_ms: u32,
61        /// Error text when `ok` is false.
62        #[serde(default, skip_serializing_if = "Option::is_none")]
63        error: Option<String>,
64    },
65    /// Native / WidgetKit reload attempt.
66    Reload {
67        /// Whether reload was invoked.
68        performed: bool,
69        /// Outcome detail.
70        reason: ReloadOutcome,
71    },
72    /// Host drained pending actions.
73    Poll {
74        /// Actions returned this poll.
75        count: usize,
76    },
77    /// Native / desktop surface painted.
78    Render {
79        /// Instance id (family / appWidgetId / window).
80        instance: String,
81        /// Config nonce observed.
82        nonce: u64,
83        /// Transport / prefs source label.
84        source: String,
85        /// Why paint ran (`reload`, `timeline`, …).
86        trigger: String,
87        /// Host write → paint lag.
88        lag_ms: u64,
89        /// Elements skipped this paint.
90        skipped: Vec<crate::receipt::SkippedElement>,
91    },
92}
93
94/// Timestamped [`TraceEvent`] in the ring.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct TraceEntry {
98    /// Unix ms.
99    pub ts: u64,
100    /// Event payload (flattened in JSON).
101    #[serde(flatten)]
102    pub event: TraceEvent,
103}
104
105/// Snapshot returned by `get_widget_trace`.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct WidgetTrace {
109    /// Whether journaling is active in this process.
110    pub enabled: bool,
111    /// Ring buffer contents (oldest → newest).
112    pub events: Vec<TraceEntry>,
113    /// Latest receipts from the companion store.
114    pub receipts: Vec<crate::receipt::WidgetRenderReceipt>,
115}
116
117#[derive(Default)]
118struct TraceInner {
119    events: VecDeque<TraceEntry>,
120    dirty: bool,
121    last_flush: Option<Instant>,
122}
123
124/// Process-wide style store held on [`crate::desktop::Widget`] / mobile.
125#[derive(Default)]
126pub struct TraceStore {
127    inner: Mutex<TraceInner>,
128    flush_started: AtomicBool,
129}
130
131impl TraceStore {
132    /// Empty store.
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    /// Append an event when tracing is enabled.
138    pub fn push(&self, event: TraceEvent) {
139        if !trace_enabled() {
140            return;
141        }
142        let mut g = self.inner.lock().unwrap();
143        g.events.push_back(TraceEntry {
144            ts: now_ms(),
145            event,
146        });
147        while g.events.len() > RING_CAP {
148            g.events.pop_front();
149        }
150        g.dirty = true;
151    }
152
153    /// Events with `ts >= since_ms` (or all when `None`).
154    pub fn list_since(&self, since_ms: Option<u64>) -> Vec<TraceEntry> {
155        let g = self.inner.lock().unwrap();
156        g.events
157            .iter()
158            .filter(|e| since_ms.map(|s| e.ts >= s).unwrap_or(true))
159            .cloned()
160            .collect()
161    }
162
163    /// Write the ring to disk if dirty.
164    pub fn flush_to_path(&self, path: &Path) -> crate::Result<()> {
165        if !trace_enabled() {
166            return Ok(());
167        }
168        let (events, dirty) = {
169            let mut g = self.inner.lock().unwrap();
170            let dirty = g.dirty;
171            g.dirty = false;
172            g.last_flush = Some(Instant::now());
173            (g.events.iter().cloned().collect::<Vec<_>>(), dirty)
174        };
175        if !dirty && path.exists() {
176            return Ok(());
177        }
178        if let Some(parent) = path.parent() {
179            fs::create_dir_all(parent)?;
180        }
181        let json = serde_json::to_string_pretty(&events)?;
182        let tmp = path.with_extension("tmp");
183        fs::write(&tmp, json.as_bytes())?;
184        fs::rename(&tmp, path)?;
185        Ok(())
186    }
187
188    /// Replace the ring from a previous flush (best-effort).
189    pub fn load_from_path(&self, path: &Path) {
190        if !trace_enabled() {
191            return;
192        }
193        let Ok(raw) = fs::read_to_string(path) else {
194            return;
195        };
196        let Ok(list): Result<Vec<TraceEntry>, _> = serde_json::from_str(&raw) else {
197            return;
198        };
199        let mut g = self.inner.lock().unwrap();
200        g.events.clear();
201        for e in list.into_iter().rev().take(RING_CAP).collect::<Vec<_>>().into_iter().rev() {
202            g.events.push_back(e);
203        }
204        g.dirty = false;
205    }
206
207    /// `true` when dirty and the flush interval has elapsed.
208    pub fn needs_timed_flush(&self) -> bool {
209        if !trace_enabled() {
210            return false;
211        }
212        let g = self.inner.lock().unwrap();
213        if !g.dirty {
214            return false;
215        }
216        match g.last_flush {
217            None => true,
218            Some(t) => t.elapsed() >= FLUSH_INTERVAL,
219        }
220    }
221
222    /// Spawn a 10s flush loop once (desktop).
223    pub fn ensure_flush_thread<F>(&self, path_fn: F)
224    where
225        F: Fn() -> Option<PathBuf> + Send + 'static,
226    {
227        if !trace_enabled() {
228            return;
229        }
230        if self
231            .flush_started
232            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
233            .is_err()
234        {
235            return;
236        }
237        // Hold a weak pattern via path_fn only — TraceStore is inside Widget Arc-ish
238        // via AppHandle state; we can't clone TraceStore easily, so pass path +
239        // re-check dirty via a shared AtomicBool is hard. Instead spawn with
240        // path_fn and store pointer is wrong. Use std::thread with path polling
241        // of file only when Widget calls flush from poller.
242        //
243        // Practical approach: the desktop action poller also calls
244        // `maybe_flush_trace` — no extra thread needed.
245        let _ = path_fn;
246        // Reset so callers can use maybe_flush; flag stays true to avoid
247        // duplicate setup messaging.
248    }
249}
250
251/// Default disk path under an app data dir.
252pub fn trace_path(app_data: &Path) -> PathBuf {
253    app_data.join("widgets").join(TRACE_FILE_NAME)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn ring_caps_at_200() {
262        let store = TraceStore::new();
263        // Force-enable by pushing only works if debug — tests are debug.
264        for i in 0..250 {
265            store.push(TraceEvent::Poll { count: i });
266        }
267        let list = store.list_since(None);
268        assert!(list.len() <= RING_CAP);
269        assert_eq!(list.last().unwrap().event, TraceEvent::Poll { count: 249 });
270    }
271
272    #[test]
273    fn serialize_reload_throttled() {
274        let e = TraceEvent::Reload {
275            performed: false,
276            reason: ReloadOutcome::Throttled {
277                remaining_secs: 840,
278            },
279        };
280        let v = serde_json::to_value(&e).unwrap();
281        assert_eq!(v["kind"], "reload");
282        assert_eq!(v["performed"], false);
283        assert_eq!(v["reason"]["outcome"], "throttled");
284        assert_eq!(v["reason"]["remainingSecs"], 840);
285    }
286}