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(
38    tag = "kind",
39    rename_all = "camelCase",
40    rename_all_fields = "camelCase"
41)]
42pub enum TraceEvent {
43    /// Host applied (or skipped) a config write.
44    ConfigSet {
45        /// Widget id.
46        widget_id: String,
47        /// Map nonce after the write attempt.
48        nonce: u64,
49        /// Serialized config size.
50        bytes: usize,
51        /// `true` when store bytes changed.
52        changed: bool,
53        /// Present when the write was skipped.
54        #[serde(default, skip_serializing_if = "Option::is_none")]
55        skip: Option<SkipReason>,
56    },
57    /// A transport `write` finished.
58    Write {
59        /// Driver name.
60        transport: String,
61        /// Success flag.
62        ok: bool,
63        /// Wall time of the write.
64        duration_ms: u32,
65        /// Error text when `ok` is false.
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        error: Option<String>,
68    },
69    /// Native / WidgetKit reload attempt.
70    Reload {
71        /// Whether reload was invoked.
72        performed: bool,
73        /// Outcome detail.
74        reason: ReloadOutcome,
75    },
76    /// Host drained pending actions.
77    Poll {
78        /// Actions returned this poll.
79        count: usize,
80    },
81    /// Native / desktop surface painted.
82    Render {
83        /// Instance id (family / appWidgetId / window).
84        instance: String,
85        /// Config nonce observed.
86        nonce: u64,
87        /// Transport / prefs source label.
88        source: String,
89        /// Why paint ran (`reload`, `timeline`, …).
90        trigger: String,
91        /// Host write → paint lag.
92        lag_ms: u64,
93        /// Elements skipped this paint.
94        skipped: Vec<crate::receipt::SkippedElement>,
95    },
96}
97
98/// Timestamped [`TraceEvent`] in the ring.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct TraceEntry {
102    /// Unix ms.
103    pub ts: u64,
104    /// Event payload (flattened in JSON).
105    #[serde(flatten)]
106    pub event: TraceEvent,
107}
108
109/// Snapshot returned by `get_widget_trace`.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct WidgetTrace {
113    /// Whether journaling is active in this process.
114    pub enabled: bool,
115    /// Ring buffer contents (oldest → newest).
116    pub events: Vec<TraceEntry>,
117    /// Latest receipts from the companion store.
118    pub receipts: Vec<crate::receipt::WidgetRenderReceipt>,
119}
120
121#[derive(Default)]
122struct TraceInner {
123    events: VecDeque<TraceEntry>,
124    dirty: bool,
125    last_flush: Option<Instant>,
126}
127
128/// Process-wide style store held on [`crate::desktop::Widget`] / mobile.
129#[derive(Default)]
130pub struct TraceStore {
131    inner: Mutex<TraceInner>,
132    flush_started: AtomicBool,
133}
134
135impl TraceStore {
136    /// Empty store.
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Append an event when tracing is enabled.
142    pub fn push(&self, event: TraceEvent) {
143        if !trace_enabled() {
144            return;
145        }
146        let mut g = self.inner.lock().unwrap();
147        g.events.push_back(TraceEntry {
148            ts: now_ms(),
149            event,
150        });
151        while g.events.len() > RING_CAP {
152            g.events.pop_front();
153        }
154        g.dirty = true;
155    }
156
157    /// Events with `ts >= since_ms` (or all when `None`).
158    pub fn list_since(&self, since_ms: Option<u64>) -> Vec<TraceEntry> {
159        let g = self.inner.lock().unwrap();
160        g.events
161            .iter()
162            .filter(|e| since_ms.map(|s| e.ts >= s).unwrap_or(true))
163            .cloned()
164            .collect()
165    }
166
167    /// Write the ring to disk if dirty.
168    pub fn flush_to_path(&self, path: &Path) -> crate::Result<()> {
169        if !trace_enabled() {
170            return Ok(());
171        }
172        let (events, dirty) = {
173            let mut g = self.inner.lock().unwrap();
174            let dirty = g.dirty;
175            g.dirty = false;
176            g.last_flush = Some(Instant::now());
177            (g.events.iter().cloned().collect::<Vec<_>>(), dirty)
178        };
179        if !dirty && path.exists() {
180            return Ok(());
181        }
182        if let Some(parent) = path.parent() {
183            fs::create_dir_all(parent)?;
184        }
185        let json = serde_json::to_string_pretty(&events)?;
186        let tmp = path.with_extension("tmp");
187        fs::write(&tmp, json.as_bytes())?;
188        fs::rename(&tmp, path)?;
189        Ok(())
190    }
191
192    /// Replace the ring from a previous flush (best-effort).
193    pub fn load_from_path(&self, path: &Path) {
194        if !trace_enabled() {
195            return;
196        }
197        let Ok(raw) = fs::read_to_string(path) else {
198            return;
199        };
200        let Ok(list): Result<Vec<TraceEntry>, _> = serde_json::from_str(&raw) else {
201            return;
202        };
203        let mut g = self.inner.lock().unwrap();
204        g.events.clear();
205        for e in list
206            .into_iter()
207            .rev()
208            .take(RING_CAP)
209            .collect::<Vec<_>>()
210            .into_iter()
211            .rev()
212        {
213            g.events.push_back(e);
214        }
215        g.dirty = false;
216    }
217
218    /// `true` when dirty and the flush interval has elapsed.
219    pub fn needs_timed_flush(&self) -> bool {
220        if !trace_enabled() {
221            return false;
222        }
223        let g = self.inner.lock().unwrap();
224        if !g.dirty {
225            return false;
226        }
227        match g.last_flush {
228            None => true,
229            Some(t) => t.elapsed() >= FLUSH_INTERVAL,
230        }
231    }
232
233    /// Spawn a 10s flush loop once (desktop).
234    pub fn ensure_flush_thread<F>(&self, path_fn: F)
235    where
236        F: Fn() -> Option<PathBuf> + Send + 'static,
237    {
238        if !trace_enabled() {
239            return;
240        }
241        if self
242            .flush_started
243            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
244            .is_err()
245        {
246            return;
247        }
248        // Hold a weak pattern via path_fn only — TraceStore is inside Widget Arc-ish
249        // via AppHandle state; we can't clone TraceStore easily, so pass path +
250        // re-check dirty via a shared AtomicBool is hard. Instead spawn with
251        // path_fn and store pointer is wrong. Use std::thread with path polling
252        // of file only when Widget calls flush from poller.
253        //
254        // Practical approach: the desktop action poller also calls
255        // `maybe_flush_trace` — no extra thread needed.
256        let _ = path_fn;
257        // Reset so callers can use maybe_flush; flag stays true to avoid
258        // duplicate setup messaging.
259    }
260}
261
262/// Default disk path under an app data dir.
263pub fn trace_path(app_data: &Path) -> PathBuf {
264    app_data.join("widgets").join(TRACE_FILE_NAME)
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use proptest::prelude::*;
271    use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest};
272
273    #[derive(Clone, Debug)]
274    enum Op {
275        /// Push `n` poll events (counts continue from the model's counter).
276        Push(usize),
277        /// Flush the ring to disk (only when dirty or the file is missing).
278        Flush,
279        /// Load the disk file into the running store.
280        LoadInPlace,
281        /// Fresh store (app restart) that loads the disk file.
282        Restart,
283    }
284
285    /// Model of the journal: ring of poll counts, dirty flag, flushed-since-start flag,
286    /// last flushed contents on disk.
287    #[derive(Clone, Debug, Default)]
288    struct Model {
289        ring: VecDeque<usize>,
290        next: usize,
291        dirty: bool,
292        flushed: bool,
293        disk: Option<Vec<usize>>,
294    }
295
296    impl ReferenceStateMachine for Model {
297        type State = Model;
298        type Transition = Op;
299
300        fn init_state() -> BoxedStrategy<Model> {
301            Just(Model::default()).boxed()
302        }
303
304        fn transitions(_: &Model) -> BoxedStrategy<Op> {
305            prop_oneof![
306                4 => (1usize..90).prop_map(Op::Push),
307                1 => Just(Op::Flush),
308                1 => Just(Op::LoadInPlace),
309                1 => Just(Op::Restart),
310            ]
311            .boxed()
312        }
313
314        fn apply(mut m: Model, op: &Op) -> Model {
315            match *op {
316                Op::Push(n) => {
317                    for _ in 0..n {
318                        m.ring.push_back(m.next);
319                        m.next += 1;
320                        if m.ring.len() > RING_CAP {
321                            m.ring.pop_front();
322                        }
323                    }
324                    m.dirty = true;
325                }
326                Op::Flush => {
327                    if m.dirty || m.disk.is_none() {
328                        m.disk = Some(m.ring.iter().copied().collect());
329                    }
330                    m.dirty = false;
331                    m.flushed = true;
332                }
333                Op::LoadInPlace => {
334                    if let Some(d) = &m.disk {
335                        m.ring = d.iter().copied().collect();
336                        m.dirty = false;
337                    }
338                }
339                Op::Restart => {
340                    m.ring = m.disk.clone().unwrap_or_default().into();
341                    m.dirty = false;
342                    m.flushed = false;
343                }
344            }
345            m
346        }
347    }
348
349    struct Sut {
350        store: TraceStore,
351        dir: tempfile::TempDir,
352        next: usize,
353    }
354
355    struct TraceMachine;
356
357    impl StateMachineTest for TraceMachine {
358        type SystemUnderTest = Sut;
359        type Reference = Model;
360
361        fn init_test(_: &Model) -> Sut {
362            Sut {
363                store: TraceStore::new(),
364                dir: tempfile::tempdir().unwrap(),
365                next: 0,
366            }
367        }
368
369        fn apply(mut sut: Sut, _: &Model, op: Op) -> Sut {
370            let path = trace_path(sut.dir.path());
371            match op {
372                Op::Push(n) => {
373                    for _ in 0..n {
374                        sut.store.push(TraceEvent::Poll { count: sut.next });
375                        sut.next += 1;
376                    }
377                }
378                Op::Flush => {
379                    sut.store.flush_to_path(&path).unwrap();
380                    assert!(path.exists() && !path.with_extension("tmp").exists());
381                }
382                Op::LoadInPlace => sut.store.load_from_path(&path),
383                Op::Restart => {
384                    sut.store = TraceStore::new();
385                    sut.store.load_from_path(&path);
386                }
387            }
388            sut
389        }
390
391        fn check_invariants(sut: &Sut, m: &Model) {
392            let counts = |list: Vec<TraceEntry>| -> Vec<usize> {
393                list.into_iter()
394                    .map(|e| match e.event {
395                        TraceEvent::Poll { count } => count,
396                        other => panic!("{other:?}"),
397                    })
398                    .collect()
399            };
400            let all = sut.store.list_since(None);
401            let want: Vec<usize> = m.ring.iter().copied().collect();
402            assert_eq!(counts(all.clone()), want);
403            assert!(all.windows(2).all(|w| w[0].ts <= w[1].ts));
404            if let Some(first) = all.first() {
405                assert_eq!(sut.store.list_since(Some(first.ts)).len(), all.len());
406            }
407            assert!(sut.store.list_since(Some(u64::MAX)).is_empty());
408            // Dirty and never flushed by this store → due now; otherwise not within 10 s.
409            assert_eq!(sut.store.needs_timed_flush(), m.dirty && !m.flushed);
410            let path = trace_path(sut.dir.path());
411            let on_disk: Option<Vec<TraceEntry>> = fs::read_to_string(&path)
412                .ok()
413                .map(|s| serde_json::from_str(&s).unwrap());
414            assert_eq!(on_disk.map(counts), m.disk.clone());
415        }
416    }
417
418    prop_state_machine! {
419        #![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })]
420        /// Journal vs a model: ring capped at 200 (oldest dropped), flush writes only when
421        /// dirty (or no file yet), load replaces the ring and clears dirty, restart reloads
422        /// the last flush, the timed flush is due only while dirty and never flushed.
423        #[test]
424        fn trace_store_follows_the_model(sequential 1..40 => TraceMachine);
425    }
426
427    /// Wire format of an event in the flushed journal: `kind` tag, camelCase fields, the
428    /// reload outcome nested with its own tag, `ts` flattened beside them.
429    #[test]
430    fn flushed_event_wire_format() {
431        let store = TraceStore::new();
432        let dir = tempfile::tempdir().unwrap();
433        let path = trace_path(dir.path());
434        assert!(path.ends_with("widgets/widget_trace.json"));
435        store.push(TraceEvent::Reload {
436            performed: false,
437            reason: ReloadOutcome::Throttled {
438                remaining_secs: 840,
439            },
440        });
441        store.flush_to_path(&path).unwrap();
442        let v: serde_json::Value =
443            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
444        let e = &v[0];
445        assert_eq!(e["kind"], "reload");
446        assert_eq!(e["performed"], false);
447        assert_eq!(e["reason"]["outcome"], "throttled");
448        assert_eq!(e["reason"]["remainingSecs"], 840);
449        assert!(e["ts"].as_u64().unwrap() > 0);
450        assert!(trace_enabled(), "tests run with debug assertions");
451    }
452}