1use 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
23pub 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#[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#[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#[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#[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 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 let _ = path_fn;
227 }
230}
231
232pub 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 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}