use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::apply::{ReloadOutcome, SkipReason};
use crate::store::now_ms;
const RING_CAP: usize = 200;
const FLUSH_INTERVAL: Duration = Duration::from_secs(10);
pub const TRACE_FILE_NAME: &str = "widget_trace.json";
pub fn trace_enabled() -> bool {
if cfg!(debug_assertions) {
return true;
}
matches!(
env::var("WIDGET_DEBUG").ok().as_deref(),
Some("1") | Some("true") | Some("TRUE")
)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum TraceEvent {
ConfigSet {
widget_id: String,
nonce: u64,
bytes: usize,
changed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
skip: Option<SkipReason>,
},
Write {
transport: String,
ok: bool,
duration_ms: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
Reload {
performed: bool,
reason: ReloadOutcome,
},
Poll {
count: usize,
},
Render {
instance: String,
nonce: u64,
source: String,
trigger: String,
lag_ms: u64,
skipped: Vec<crate::receipt::SkippedElement>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TraceEntry {
pub ts: u64,
#[serde(flatten)]
pub event: TraceEvent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WidgetTrace {
pub enabled: bool,
pub events: Vec<TraceEntry>,
pub receipts: Vec<crate::receipt::WidgetRenderReceipt>,
}
#[derive(Default)]
struct TraceInner {
events: VecDeque<TraceEntry>,
dirty: bool,
last_flush: Option<Instant>,
}
#[derive(Default)]
pub struct TraceStore {
inner: Mutex<TraceInner>,
flush_started: AtomicBool,
}
impl TraceStore {
pub fn new() -> Self {
Self::default()
}
pub fn push(&self, event: TraceEvent) {
if !trace_enabled() {
return;
}
let mut g = self.inner.lock().unwrap();
g.events.push_back(TraceEntry {
ts: now_ms(),
event,
});
while g.events.len() > RING_CAP {
g.events.pop_front();
}
g.dirty = true;
}
pub fn list_since(&self, since_ms: Option<u64>) -> Vec<TraceEntry> {
let g = self.inner.lock().unwrap();
g.events
.iter()
.filter(|e| since_ms.map(|s| e.ts >= s).unwrap_or(true))
.cloned()
.collect()
}
pub fn flush_to_path(&self, path: &Path) -> crate::Result<()> {
if !trace_enabled() {
return Ok(());
}
let (events, dirty) = {
let mut g = self.inner.lock().unwrap();
let dirty = g.dirty;
g.dirty = false;
g.last_flush = Some(Instant::now());
(g.events.iter().cloned().collect::<Vec<_>>(), dirty)
};
if !dirty && path.exists() {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&events)?;
let tmp = path.with_extension("tmp");
fs::write(&tmp, json.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
pub fn load_from_path(&self, path: &Path) {
if !trace_enabled() {
return;
}
let Ok(raw) = fs::read_to_string(path) else {
return;
};
let Ok(list): Result<Vec<TraceEntry>, _> = serde_json::from_str(&raw) else {
return;
};
let mut g = self.inner.lock().unwrap();
g.events.clear();
for e in list.into_iter().rev().take(RING_CAP).collect::<Vec<_>>().into_iter().rev() {
g.events.push_back(e);
}
g.dirty = false;
}
pub fn needs_timed_flush(&self) -> bool {
if !trace_enabled() {
return false;
}
let g = self.inner.lock().unwrap();
if !g.dirty {
return false;
}
match g.last_flush {
None => true,
Some(t) => t.elapsed() >= FLUSH_INTERVAL,
}
}
pub fn ensure_flush_thread<F>(&self, path_fn: F)
where
F: Fn() -> Option<PathBuf> + Send + 'static,
{
if !trace_enabled() {
return;
}
if self
.flush_started
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
let _ = path_fn;
}
}
pub fn trace_path(app_data: &Path) -> PathBuf {
app_data.join("widgets").join(TRACE_FILE_NAME)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ring_caps_at_200() {
let store = TraceStore::new();
for i in 0..250 {
store.push(TraceEvent::Poll { count: i });
}
let list = store.list_since(None);
assert!(list.len() <= RING_CAP);
assert_eq!(list.last().unwrap().event, TraceEvent::Poll { count: 249 });
}
#[test]
fn serialize_reload_throttled() {
let e = TraceEvent::Reload {
performed: false,
reason: ReloadOutcome::Throttled {
remaining_secs: 840,
},
};
let v = serde_json::to_value(&e).unwrap();
assert_eq!(v["kind"], "reload");
assert_eq!(v["performed"], false);
assert_eq!(v["reason"]["outcome"], "throttled");
assert_eq!(v["reason"]["remainingSecs"], 840);
}
}