Skip to main content

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}