Skip to main content

nxd_core/
profiling.rs

1//! Opt-in, process-local phase timing for operator performance diagnosis.
2
3use serde::Serialize;
4use std::borrow::Cow;
5use std::cell::RefCell;
6use std::collections::BTreeMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Mutex, OnceLock};
9use std::time::{Duration, Instant};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum Format {
13	Human,
14	Structured,
15}
16
17#[derive(Clone)]
18struct ActiveSpan {
19	id: u64,
20	worker: Option<String>,
21}
22
23#[derive(Serialize)]
24#[serde(rename_all = "camelCase")]
25struct Event {
26	id: u64,
27	phase: Cow<'static, str>,
28	start_offset_ms: u128,
29	duration_ms: u128,
30	parent: Option<u64>,
31	worker: Option<String>,
32}
33
34struct State {
35	format: Format,
36	origin: Instant,
37	events: Vec<Event>,
38}
39
40static STATE: OnceLock<Mutex<Option<State>>> = OnceLock::new();
41static NEXT_ID: AtomicU64 = AtomicU64::new(1);
42
43thread_local! {
44	static ACTIVE: RefCell<Vec<ActiveSpan>> = const { RefCell::new(Vec::new()) };
45}
46
47pub fn set_format(format: Option<Format>, origin: Instant) {
48	*STATE.get_or_init(|| Mutex::new(None)).lock().expect("profile state lock poisoned") =
49		format.map(|format| State { format, origin, events: Vec::new() });
50	ACTIVE.with(|active| active.borrow_mut().clear());
51}
52
53pub struct Span {
54	id: u64,
55	name: Cow<'static, str>,
56	started: Option<Instant>,
57	start_offset: Duration,
58	parent: Option<u64>,
59	worker: Option<String>,
60	format: Option<Format>,
61}
62
63impl Span {
64	pub fn new(name: &'static str) -> Self {
65		Self::start(name.into(), None)
66	}
67
68	pub fn named(name: impl Into<String>) -> Self {
69		Self::start(name.into().into(), None)
70	}
71
72	pub fn named_worker(name: impl Into<String>, worker: impl Into<String>) -> Self {
73		Self::start(name.into().into(), Some(worker.into()))
74	}
75
76	fn start(name: Cow<'static, str>, worker_name: Option<String>) -> Self {
77		let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
78		let mut format = None;
79		let mut start_offset = Duration::ZERO;
80		let mut worker = None;
81		if let Some(state) = STATE.get() {
82			let mut state = state.lock().expect("profile state lock poisoned");
83			if let Some(state) = state.as_mut() {
84				format = Some(state.format);
85				start_offset = state.origin.elapsed();
86				worker = worker_name;
87			}
88		}
89		let (parent, inherited_worker) = ACTIVE.with(|active| {
90			active.borrow().last().map_or((None, None), |span| (Some(span.id), span.worker.clone()))
91		});
92		worker = worker.or(inherited_worker);
93		let started = format.map(|_| Instant::now());
94		if started.is_some() {
95			ACTIVE.with(|active| active.borrow_mut().push(ActiveSpan { id, worker: worker.clone() }));
96		}
97		Self { id, name, started, start_offset, parent, worker, format }
98	}
99}
100
101impl Drop for Span {
102	fn drop(&mut self) {
103		let Some(started) = self.started else { return };
104		let duration = started.elapsed();
105		ACTIVE.with(|active| {
106			let mut active = active.borrow_mut();
107			if let Some(index) = active.iter().rposition(|span| span.id == self.id) {
108				active.remove(index);
109			}
110		});
111		match self.format {
112			Some(Format::Human) => {
113				eprintln!("PROFILE phase={} duration_ms={}", self.name, duration.as_millis());
114			}
115			Some(Format::Structured) => record(Event {
116				id: self.id,
117				phase: self.name.clone(),
118				start_offset_ms: self.start_offset.as_millis(),
119				duration_ms: duration.as_millis(),
120				parent: self.parent,
121				worker: self.worker.clone(),
122			}),
123			None => {}
124		}
125	}
126}
127
128pub fn record_duration(name: &'static str, start_offset: Duration, duration: Duration) {
129	let Some(state) = STATE.get() else { return };
130	let format =
131		state.lock().expect("profile state lock poisoned").as_ref().map(|state| state.format);
132	match format {
133		Some(Format::Human) => {
134			eprintln!("PROFILE phase={name} duration_ms={}", duration.as_millis());
135		}
136		Some(Format::Structured) => record(Event {
137			id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
138			phase: name.into(),
139			start_offset_ms: start_offset.as_millis(),
140			duration_ms: duration.as_millis(),
141			parent: None,
142			worker: None,
143		}),
144		None => {}
145	}
146}
147
148fn record(event: Event) {
149	if let Some(state) = STATE.get()
150		&& let Some(state) = state.lock().expect("profile state lock poisoned").as_mut()
151	{
152		state.events.push(event);
153	}
154}
155
156#[derive(Serialize)]
157#[serde(rename_all = "camelCase")]
158struct Summary<'a> {
159	phase: &'a str,
160	count: usize,
161	total_duration_ms: u128,
162}
163
164#[derive(Serialize)]
165#[serde(rename_all = "camelCase")]
166struct Envelope<'a> {
167	version: u8,
168	stability: &'static str,
169	events: &'a [Event],
170	summary: Vec<Summary<'a>>,
171}
172
173fn summarize(events: &[Event]) -> Vec<Summary<'_>> {
174	let mut totals = BTreeMap::<&str, (usize, u128)>::new();
175	for event in events {
176		let total = totals.entry(&event.phase).or_default();
177		total.0 += 1;
178		total.1 += event.duration_ms;
179	}
180	let mut summary = totals
181		.into_iter()
182		.map(|(phase, (count, total_duration_ms))| Summary { phase, count, total_duration_ms })
183		.collect::<Vec<_>>();
184	summary.sort_by(|left, right| {
185		right.total_duration_ms.cmp(&left.total_duration_ms).then(left.phase.cmp(right.phase))
186	});
187	summary
188}
189
190fn structured_value(state: &mut State) -> serde_json::Value {
191	state.events.sort_by_key(|event| (event.start_offset_ms, event.id));
192	let summary = summarize(&state.events);
193	serde_json::to_value(Envelope {
194		version: 1,
195		stability: "unstable",
196		events: &state.events,
197		summary,
198	})
199	.expect("profile envelope contains serializable values")
200}
201
202/// Return the current structured profile without consuming terminal emission.
203pub fn structured_snapshot() -> Option<serde_json::Value> {
204	let mut state = STATE.get()?.lock().expect("profile state lock poisoned");
205	let state = state.as_mut()?;
206	(state.format == Format::Structured).then(|| structured_value(state))
207}
208
209pub fn finish() {
210	let Some(state) = STATE.get() else { return };
211	let mut state = state.lock().expect("profile state lock poisoned");
212	let Some(mut state) = state.take() else { return };
213	if state.format != Format::Structured {
214		return;
215	}
216	eprintln!("{}", structured_value(&mut state));
217}
218
219#[cfg(test)]
220mod tests {
221	use super::*;
222
223	#[test]
224	fn structured_envelope_marks_instability_and_aggregates_slowest_first() {
225		set_format(Some(Format::Structured), Instant::now());
226		let parent = Span::named_worker("repeat", "host-a");
227		let parent_id = parent.id;
228		{
229			let _child = Span::new("repeat");
230		}
231		drop(parent);
232		let value = structured_snapshot().unwrap();
233		assert_eq!(structured_snapshot(), Some(value.clone()));
234		assert_eq!(value["stability"], "unstable");
235		assert_eq!(value["version"], 1);
236		let child = value["events"]
237			.as_array()
238			.unwrap()
239			.iter()
240			.find(|event| event["parent"] == parent_id)
241			.unwrap();
242		assert_eq!(child["worker"], "host-a");
243		assert_eq!(value["summary"][0]["count"], 2);
244	}
245}