detcore/detlog.rs
1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Module contains macroses that help tracing DETLOG entires for the purpose of verifiying determinism
10//! ['detlog'] can be used to write a deterministic log entry at INFO level
11//! ['detlog_debug] can be use to write a deterministic log entry at DEBUG level
12
13use std::fmt;
14use std::sync::OnceLock;
15
16use serde::Deserialize;
17use serde::Serialize;
18
19/// Delimits the machine-readable record appended to a human DETLOG message.
20///
21/// The human text remains available to people and historical readers. Current
22/// verification consumes the JSON after this delimiter for event class and
23/// position rather than recovering those facts from prose.
24pub const RECORD_SEPARATOR: &str = " DETLOG_RECORD=";
25
26/// Current schema written beside each structured DETLOG event.
27pub const RECORD_SCHEMA: u32 = 1;
28
29/// Producer-owned facts needed by log comparison.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
32pub enum DetLogEvent {
33 /// A deterministic record outside the syscall-specific classes below.
34 Other,
35 /// A syscall entered but not yet completed.
36 Syscall,
37 /// A completed syscall, carrying Detcore's own counter.
38 SyscallResult {
39 /// Number of syscalls completed by guest threads at this point.
40 finished_syscall_number: u64,
41 },
42 /// A scheduler turn committed to the guest.
43 SchedulerCommit {
44 /// Detcore's scheduler turn number.
45 scheduler_turn: u64,
46 /// Committed virtual time at this turn, in nanoseconds.
47 virtual_nanoseconds: u64,
48 /// Whether this turn is host-timing-sensitive internal I/O polling.
49 internal_io_poll: bool,
50 /// Whether this turn reads the guest runtime's `/proc/self/maps`.
51 runtime_maps_read: bool,
52 },
53 /// Per-turn committed-time bookkeeping excluded from deterministic comparison.
54 SchedulerCommittedTime,
55 /// The scheduler found no runnable thread and took its established kick path.
56 SchedulerEmptyQueueKick,
57}
58
59/// Versioned serialized form appended to the human log record.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct DetLogRecord {
63 /// Serialized record schema.
64 pub schema: u32,
65 /// Closed event payload.
66 pub event: DetLogEvent,
67}
68
69impl DetLogRecord {
70 /// Construct a record in the current schema.
71 pub fn new(event: DetLogEvent) -> Self {
72 Self {
73 schema: RECORD_SCHEMA,
74 event,
75 }
76 }
77
78 /// Split a human record from its producer-owned structured suffix.
79 ///
80 /// An absent suffix is the historical format. Once the delimiter is
81 /// present, malformed JSON or another schema is an error rather than an
82 /// invitation to fall back to the human text.
83 pub fn split(message: &str) -> Result<(&str, Option<Self>), String> {
84 let Some((human, encoded)) = message.rsplit_once(RECORD_SEPARATOR) else {
85 return Ok((message, None));
86 };
87 let record: Self = serde_json::from_str(encoded)
88 .map_err(|error| format!("malformed DETLOG record: {error}"))?;
89 if record.schema != RECORD_SCHEMA {
90 return Err(format!(
91 "unsupported DETLOG record schema {}; expected {}",
92 record.schema, RECORD_SCHEMA
93 ));
94 }
95 Ok((human, Some(record)))
96 }
97}
98
99/// Serialize one event for appending to its human log message.
100#[doc(hidden)]
101pub fn record_suffix(event: DetLogEvent) -> String {
102 let encoded = serde_json::to_string(&DetLogRecord::new(event))
103 .expect("DETLOG record serialization cannot fail");
104 format!("{RECORD_SEPARATOR}{encoded}")
105}
106
107/// A process-local sink for deterministic INFO records.
108pub type DetlogForwarder = for<'a> fn(&str, fmt::Arguments<'a>);
109
110static FORWARDER: OnceLock<DetlogForwarder> = OnceLock::new();
111
112/// Installs a process-local sink for deterministic INFO records.
113///
114/// Backends whose tool runs in another process can use this to transport the
115/// same records that are normally observed through the coordinator's tracing
116/// subscriber. Only the first sink installed in a process is retained.
117pub fn set_forwarder(forwarder: DetlogForwarder) -> Result<(), DetlogForwarder> {
118 FORWARDER.set(forwarder)
119}
120
121/// Returns whether a process-local deterministic-record sink is installed.
122#[doc(hidden)]
123pub fn forwarding_enabled() -> bool {
124 FORWARDER.get().is_some()
125}
126
127/// Emits one deterministic record through tracing and the process-local sink.
128#[doc(hidden)]
129pub fn emit_forwarded(record_suffix: &str, message: fmt::Arguments<'_>) {
130 tracing::info!("DETLOG {}{}", message, record_suffix);
131 FORWARDER.get().expect("forwarder disappeared")(record_suffix, message);
132}
133
134/// Macro used to encapsulate tracing should-be-deterministic information.
135/// This is currently at the INFO log level.
136#[macro_export]
137macro_rules! detlog {
138 (event = $event:expr; $($arg:tt)+) => {{
139 if $crate::detlog::forwarding_enabled() || ::tracing::enabled!(::tracing::Level::INFO) {
140 let record_suffix = $crate::detlog::record_suffix($event);
141 if $crate::detlog::forwarding_enabled() {
142 $crate::detlog::emit_forwarded(&record_suffix, format_args!($($arg)+));
143 } else {
144 ::tracing::info!("DETLOG {}{}", format_args!($($arg)+), record_suffix);
145 }
146 }
147 }};
148 ($($arg:tt)+) => {{
149 $crate::detlog!(event = $crate::detlog::DetLogEvent::Other; $($arg)+);
150 }};
151}
152
153/// Whether a [`detlog!`] record emitted at this point would reach anything.
154///
155/// WHY THIS IS NEEDED, and why it is a macro rather than a function.
156///
157/// `detlog!` routes to the process-local forwarder when one is installed and to
158/// `tracing::info!` otherwise. `tracing` does not evaluate a macro's value
159/// expressions when the level is disabled, so work done *inside* a `detlog!`
160/// argument is already free when nothing observes the record. Work done
161/// *before* the macro is not, and callers that must prepare something expensive
162/// to pass in have no way to know they can skip it.
163///
164/// `tracing`'s level check is per-callsite and keyed on the *calling module's*
165/// target, so this has to expand at the caller rather than resolve inside
166/// `detcore::detlog`; otherwise a target-scoped filter could enable one and
167/// disable the other.
168///
169/// Use it only to skip preparatory work. It is not a substitute for `detlog!`'s
170/// own gating, and a caller that guards a record with it must still emit that
171/// record through `detlog!`.
172#[macro_export]
173macro_rules! detlog_observed {
174 () => {
175 $crate::detlog::forwarding_enabled() || ::tracing::enabled!(::tracing::Level::INFO)
176 };
177}
178
179/// Macro used to encapsulate tracing should-be-deterministic information.
180/// This variant is at a higher log level and requires that logging verbosity is
181/// set to DEBUG.
182#[macro_export]
183macro_rules! detlog_debug {
184 (event = $event:expr; $($arg:tt)+) => {{
185 if ::tracing::enabled!(::tracing::Level::DEBUG) {
186 let record_suffix = $crate::detlog::record_suffix($event);
187 ::tracing::debug!("DETLOG {}{}", format_args!($($arg)+), record_suffix);
188 }
189 }};
190 ($($arg:tt)+) => {{
191 $crate::detlog_debug!(event = $crate::detlog::DetLogEvent::Other; $($arg)+);
192 }};
193}
194
195#[cfg(test)]
196mod tests {
197 use tracing::Metadata;
198 use tracing::span;
199 use tracing::subscriber::Interest;
200
201 use super::DetLogEvent;
202 use super::DetLogRecord;
203 use super::RECORD_SEPARATOR;
204 use super::record_suffix;
205
206 #[test]
207 fn test_detlog() {
208 detlog!("Hello : {}. From {:?}", "World", 31337);
209 }
210
211 #[test]
212 fn structured_record_round_trips_and_refuses_an_incomplete_current_shape() {
213 let suffix = record_suffix(DetLogEvent::SyscallResult {
214 finished_syscall_number: 37,
215 });
216 let line = format!("INFO detcore: DETLOG finish syscall #999{suffix}");
217 let (human, record) = DetLogRecord::split(&line).unwrap();
218 assert_eq!(human, "INFO detcore: DETLOG finish syscall #999");
219 assert_eq!(
220 record.unwrap().event,
221 DetLogEvent::SyscallResult {
222 finished_syscall_number: 37
223 }
224 );
225
226 let missing_number = format!(
227 "INFO detcore: DETLOG finish syscall #999{RECORD_SEPARATOR}{{\"schema\":1,\"event\":{{\"kind\":\"syscall_result\"}}}}"
228 );
229 assert!(
230 DetLogRecord::split(&missing_number)
231 .unwrap_err()
232 .contains("finished_syscall_number"),
233 "an incomplete current record must fail by field name"
234 );
235 }
236
237 /// Minimal subscriber that reports every callsite as enabled.
238 ///
239 /// `register_callsite` deliberately answers `sometimes()` rather than
240 /// letting the default derive `always()`: an `always`/`never` answer is
241 /// cached per callsite for the life of the process, which would leak
242 /// between tests.
243 struct AlwaysEnabled;
244
245 impl tracing::Subscriber for AlwaysEnabled {
246 fn register_callsite(&self, _: &'static Metadata<'static>) -> Interest {
247 Interest::sometimes()
248 }
249 fn enabled(&self, _: &Metadata<'_>) -> bool {
250 true
251 }
252 fn new_span(&self, _: &span::Attributes<'_>) -> span::Id {
253 span::Id::from_u64(1)
254 }
255 fn record(&self, _: &span::Id, _: &span::Record<'_>) {}
256 fn record_follows_from(&self, _: &span::Id, _: &span::Id) {}
257 fn event(&self, _: &tracing::Event<'_>) {}
258 fn enter(&self, _: &span::Id) {}
259 fn exit(&self, _: &span::Id) {}
260 }
261
262 /// `detlog_observed!` must answer true when a subscriber would take the
263 /// record. Callers use it to decide whether to prepare data for a
264 /// `detlog!`, so a false negative silently drops determinism evidence.
265 #[test]
266 fn detlog_observed_is_true_when_a_subscriber_is_listening() {
267 tracing::subscriber::with_default(AlwaysEnabled, || {
268 assert!(detlog_observed!());
269 });
270 }
271
272 /// ...and false when nothing is listening, which is the whole point: it is
273 /// what lets `detlog_memory_maps` skip enumerating `/proc/<pid>/maps` on
274 /// every syscall of a run that writes no log. Measured before this gate
275 /// existed, on a QEMU/Linux boot with `RUST_LOG` unset (123 bytes of log
276 /// produced): `--detlog-stack` cost 4.36x and `--detlog-heap` 4.76x the
277 /// no-flag baseline.
278 ///
279 /// Uses a distinct callsite from the enabled test above on purpose --
280 /// `tracing` caches per-callsite interest, so sharing one callsite between
281 /// the two cases would make them order-dependent.
282 #[test]
283 fn detlog_observed_is_false_when_nothing_is_listening() {
284 tracing::subscriber::with_default(tracing::subscriber::NoSubscriber::default(), || {
285 assert!(!detlog_observed!());
286 });
287 }
288}