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(tag = "kind", rename_all = "camelCase")]
38pub enum TraceEvent {
39 ConfigSet {
41 widget_id: String,
43 nonce: u64,
45 bytes: usize,
47 changed: bool,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 skip: Option<SkipReason>,
52 },
53 Write {
55 transport: String,
57 ok: bool,
59 duration_ms: u32,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 error: Option<String>,
64 },
65 Reload {
67 performed: bool,
69 reason: ReloadOutcome,
71 },
72 Poll {
74 count: usize,
76 },
77 Render {
79 instance: String,
81 nonce: u64,
83 source: String,
85 trigger: String,
87 lag_ms: u64,
89 skipped: Vec<crate::receipt::SkippedElement>,
91 },
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct TraceEntry {
98 pub ts: u64,
100 #[serde(flatten)]
102 pub event: TraceEvent,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct WidgetTrace {
109 pub enabled: bool,
111 pub events: Vec<TraceEntry>,
113 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#[derive(Default)]
126pub struct TraceStore {
127 inner: Mutex<TraceInner>,
128 flush_started: AtomicBool,
129}
130
131impl TraceStore {
132 pub fn new() -> Self {
134 Self::default()
135 }
136
137 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 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 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 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 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 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 let _ = path_fn;
246 }
249}
250
251pub 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 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}