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";
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 use proptest::prelude::*;
271 use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest};
272
273 #[derive(Clone, Debug)]
274 enum Op {
275 Push(usize),
277 Flush,
279 LoadInPlace,
281 Restart,
283 }
284
285 #[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 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 #[test]
424 fn trace_store_follows_the_model(sequential 1..40 => TraceMachine);
425 }
426
427 #[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}