tauri_plugin_widgets/
trace.rs1use 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";
23
24pub 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#[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 ConfigSet {
45 widget_id: String,
47 nonce: u64,
49 bytes: usize,
51 changed: bool,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 skip: Option<SkipReason>,
56 },
57 Write {
59 transport: String,
61 ok: bool,
63 duration_ms: u32,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 error: Option<String>,
68 },
69 Reload {
71 performed: bool,
73 reason: ReloadOutcome,
75 },
76 Poll {
78 count: usize,
80 },
81 Render {
83 instance: String,
85 nonce: u64,
87 source: String,
89 trigger: String,
91 lag_ms: u64,
93 skipped: Vec<crate::receipt::SkippedElement>,
95 },
96}
97
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct TraceEntry {
102 pub ts: u64,
104 #[serde(flatten)]
106 pub event: TraceEvent,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct WidgetTrace {
113 pub enabled: bool,
115 pub events: Vec<TraceEntry>,
117 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#[derive(Default)]
130pub struct TraceStore {
131 inner: Mutex<TraceInner>,
132 flush_started: AtomicBool,
133}
134
135impl TraceStore {
136 pub fn new() -> Self {
138 Self::default()
139 }
140
141 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 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 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 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 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 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 let _ = path_fn;
257 }
260}
261
262pub 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
271 #[test]
272 fn ring_caps_at_200() {
273 let store = TraceStore::new();
274 for i in 0..250 {
276 store.push(TraceEvent::Poll { count: i });
277 }
278 let list = store.list_since(None);
279 assert!(list.len() <= RING_CAP);
280 assert_eq!(list.last().unwrap().event, TraceEvent::Poll { count: 249 });
281 }
282
283 #[test]
284 fn serialize_reload_throttled() {
285 let e = TraceEvent::Reload {
286 performed: false,
287 reason: ReloadOutcome::Throttled {
288 remaining_secs: 840,
289 },
290 };
291 let v = serde_json::to_value(&e).unwrap();
292 assert_eq!(v["kind"], "reload");
293 assert_eq!(v["performed"], false);
294 assert_eq!(v["reason"]["outcome"], "throttled");
295 assert_eq!(v["reason"]["remainingSecs"], 840);
296 }
297}