1use std::{
7 fs::{self, File, OpenOptions},
8 io::{self, BufWriter, Write},
9 path::Path,
10 sync::{
11 Arc, Weak,
12 atomic::{AtomicBool, Ordering},
13 },
14 thread,
15 time::Duration,
16};
17
18use parking_lot::Mutex;
19use time::OffsetDateTime;
20use tracing::warn;
21
22pub type LeaderTracerHandle = Arc<dyn LeaderTracer>;
23
24pub trait LeaderTracer: Send + Sync + 'static {
25 fn record(&self, view: u64, event: LeaderEvent, ts_ns: i128);
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum LeaderEvent {
32 ProposalValidatedVMinus1,
34 RequestBlockHeaderQueued,
35
36 BlockRequestStarted,
39 BlockPayloadStart,
41 BlockCommitmentsStart,
44 BlockCommitmentsEnd,
46
47 HeaderCreatedApplied,
49 BlockBuiltApplied,
50
51 NsDisperseStart,
55 NsDisperseEnd,
57 VidFragmentsStart,
59 VidFragmentsEnd,
61 VidSharesUnicastStart,
62 VidSharesUnicastEnd,
63
64 Vote1VMinus1Arrived,
66 ThresholdShareReachedVMinus1,
67 RecoverVMinus1Start,
70 RecoverVMinus1DecodeEnd,
73 RecoverVMinus1End,
74
75 Cert1VMinus1Obtained,
78 Vote2VMinus1Queued,
79 Cert2VMinus1Obtained,
82 LeafDecided,
83
84 ProposalAssembled,
87 ProposalQueued,
88
89 ProposalBroadcastStart,
91 ProposalBroadcastEnd,
92 Vote2VMinus1BroadcastStart,
93 Vote2VMinus1BroadcastEnd,
94 Cert1VMinus1BroadcastStart,
95 Cert1VMinus1BroadcastEnd,
96 Vote1VMinus1BroadcastStart,
97 Vote1VMinus1BroadcastEnd,
98}
99
100impl LeaderEvent {
101 pub fn name(self) -> &'static str {
103 use LeaderEvent::*;
104 match self {
105 ProposalValidatedVMinus1 => "proposal_validated_v_minus_1",
106 RequestBlockHeaderQueued => "request_block_header_queued",
107 BlockRequestStarted => "block_request_started",
108 BlockPayloadStart => "block_payload_start",
109 BlockCommitmentsStart => "block_commitments_start",
110 BlockCommitmentsEnd => "block_commitments_end",
111 HeaderCreatedApplied => "header_created_applied",
112 BlockBuiltApplied => "block_built_applied",
113 NsDisperseStart => "ns_disperse_start",
114 NsDisperseEnd => "ns_disperse_end",
115 VidFragmentsStart => "vid_fragments_start",
116 VidFragmentsEnd => "vid_fragments_end",
117 VidSharesUnicastStart => "vid_shares_unicast_start",
118 VidSharesUnicastEnd => "vid_shares_unicast_end",
119 Vote1VMinus1Arrived => "vote1_v_minus_1_arrived",
120 ThresholdShareReachedVMinus1 => "threshold_share_reached_v_minus_1",
121 RecoverVMinus1Start => "recover_v_minus_1_start",
122 RecoverVMinus1DecodeEnd => "recover_v_minus_1_decode_end",
123 RecoverVMinus1End => "recover_v_minus_1_end",
124 Cert1VMinus1Obtained => "cert1_v_minus_1_obtained",
125 Vote2VMinus1Queued => "vote2_v_minus_1_queued",
126 Cert2VMinus1Obtained => "cert2_v_minus_1_obtained",
127 LeafDecided => "leaf_decided",
128 ProposalAssembled => "proposal_assembled",
129 ProposalQueued => "proposal_queued",
130 ProposalBroadcastStart => "proposal_broadcast_start",
131 ProposalBroadcastEnd => "proposal_broadcast_end",
132 Vote2VMinus1BroadcastStart => "vote2_v_minus_1_broadcast_start",
133 Vote2VMinus1BroadcastEnd => "vote2_v_minus_1_broadcast_end",
134 Cert1VMinus1BroadcastStart => "cert1_v_minus_1_broadcast_start",
135 Cert1VMinus1BroadcastEnd => "cert1_v_minus_1_broadcast_end",
136 Vote1VMinus1BroadcastStart => "vote1_v_minus_1_broadcast_start",
137 Vote1VMinus1BroadcastEnd => "vote1_v_minus_1_broadcast_end",
138 }
139 }
140}
141
142#[inline(always)]
143pub fn now_ns() -> i128 {
144 OffsetDateTime::now_utc().unix_timestamp_nanos()
145}
146
147const FLUSH_INTERVAL: Duration = Duration::from_secs(1);
148
149pub struct CsvLeaderTracer {
154 node_id: u64,
155 shared: Arc<Shared>,
156}
157
158struct Shared {
159 writer: Mutex<BufWriter<File>>,
160 failed: AtomicBool,
161}
162
163impl Shared {
164 fn fail(&self, err: io::Error) {
165 if !self.failed.swap(true, Ordering::Relaxed) {
166 warn!(%err, "leader trace write failed, disabling leader trace");
167 }
168 }
169
170 fn flush(&self) {
171 if self.failed.load(Ordering::Relaxed) {
172 return;
173 }
174 let result = self.writer.lock().flush();
175 if let Err(err) = result {
176 self.fail(err);
177 }
178 }
179}
180
181impl Drop for CsvLeaderTracer {
182 fn drop(&mut self) {
183 self.shared.flush();
184 }
185}
186
187impl CsvLeaderTracer {
188 pub fn new(node_id: u64, path: impl AsRef<Path>) -> io::Result<Self> {
189 let path = path.as_ref();
190 if let Some(parent) = path.parent() {
191 fs::create_dir_all(parent)?;
192 }
193 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
194 if file.metadata()?.len() == 0 {
195 file.write_all(b"view,node_id,event,ts_ns\n")?;
196 }
197 let shared = Arc::new(Shared {
198 writer: Mutex::new(BufWriter::new(file)),
199 failed: AtomicBool::new(false),
200 });
201 spawn_flusher(Arc::downgrade(&shared))?;
202 Ok(Self { node_id, shared })
203 }
204}
205
206fn spawn_flusher(shared: Weak<Shared>) -> io::Result<()> {
207 thread::Builder::new()
208 .name("leader-trace-flush".into())
209 .spawn(move || {
210 loop {
211 thread::sleep(FLUSH_INTERVAL);
212 let Some(shared) = shared.upgrade() else {
213 return;
214 };
215 shared.flush();
216 }
217 })?;
218 Ok(())
219}
220
221impl LeaderTracer for CsvLeaderTracer {
222 fn record(&self, view: u64, event: LeaderEvent, ts_ns: i128) {
223 if self.shared.failed.load(Ordering::Relaxed) {
224 return;
225 }
226 let row = format!("{view},{},{},{ts_ns}\n", self.node_id, event.name());
227 let result = self.shared.writer.lock().write_all(row.as_bytes());
228 if let Err(err) = result {
229 self.shared.fail(err);
230 }
231 }
232}
233
234pub trait AsViewU64 {
235 fn as_view_u64(&self) -> u64;
236}
237
238impl AsViewU64 for hotshot_types::data::ViewNumber {
239 fn as_view_u64(&self) -> u64 {
240 **self
241 }
242}
243
244impl AsViewU64 for u64 {
245 fn as_view_u64(&self) -> u64 {
246 *self
247 }
248}
249
250#[macro_export]
252macro_rules! trace_leader_event {
253 ($tracer:expr, $view:expr, $event:expr) => {
254 if let ::core::option::Option::Some(ref t) = $tracer {
255 let v = $crate::leader_trace::AsViewU64::as_view_u64(&$view);
256 t.record(v, $event, $crate::leader_trace::now_ns());
257 }
258 };
259}
260
261#[cfg(test)]
262mod tests {
263 use std::time::Instant;
264
265 use super::*;
266
267 const HEADER: &str = "view,node_id,event,ts_ns\n";
268
269 #[test]
270 fn row_reaches_disk_without_flush_or_drop() {
271 let dir = tempfile::tempdir().unwrap();
272 let path = dir.path().join("sub/trace.csv");
273 let tracer = CsvLeaderTracer::new(7, &path).unwrap();
274 tracer.record(1, LeaderEvent::NsDisperseStart, 100);
275 let expected = format!("{HEADER}1,7,ns_disperse_start,100\n");
276 let deadline = Instant::now() + Duration::from_secs(10);
277 while fs::read_to_string(&path).unwrap() != expected {
278 assert!(Instant::now() < deadline, "row not flushed within 10s");
279 thread::sleep(Duration::from_millis(20));
280 }
281 }
282
283 #[test]
284 fn reopen_appends_without_second_header() {
285 let dir = tempfile::tempdir().unwrap();
286 let path = dir.path().join("trace.csv");
287 let first = CsvLeaderTracer::new(0, &path).unwrap();
288 first.record(1, LeaderEvent::NsDisperseStart, 1);
289 drop(first);
290 let second = CsvLeaderTracer::new(0, &path).unwrap();
291 second.record(2, LeaderEvent::VidSharesUnicastEnd, 2);
292 drop(second);
293 assert_eq!(
294 fs::read_to_string(&path).unwrap(),
295 format!("{HEADER}1,0,ns_disperse_start,1\n2,0,vid_shares_unicast_end,2\n")
296 );
297 }
298}