Skip to main content

dora_message/
common.rs

1use core::fmt;
2use std::{borrow::Cow, collections::BTreeMap};
3
4use aligned_vec::{AVec, ConstAlign};
5use chrono::{DateTime, Utc};
6use eyre::Context as _;
7use serde::Deserialize;
8use uuid::Uuid;
9
10use crate::{BuildId, DataflowId, daemon_to_daemon::InterDaemonEvent, id::NodeId};
11
12pub use log::Level as LogLevel;
13
14/// A single log record delivered to `dora/logs` subscribers.
15///
16/// One of these is produced for every captured log line — a `tracing` event
17/// from a dora component or a line a node wrote to stdout/stderr — and carries
18/// both the message and the routing/provenance context a log consumer needs to
19/// attribute it (which dataflow, node, and daemon it came from).
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
21#[must_use]
22pub struct LogMessage {
23    /// The build this record belongs to, when it originated during a build.
24    pub build_id: Option<BuildId>,
25    /// The dataflow the emitting component was part of, if any.
26    pub dataflow_id: Option<DataflowId>,
27    /// The node that emitted the record, if it came from a node.
28    pub node_id: Option<NodeId>,
29    /// The daemon that captured and forwarded the record.
30    pub daemon_id: Option<DaemonId>,
31    /// The severity, or [`LogLevelOrStdout::Stdout`] for a captured stdout line.
32    pub level: LogLevelOrStdout,
33    /// The `tracing` target (typically the emitting module path), when known.
34    pub target: Option<String>,
35    /// Source module path of the emitting code, when known.
36    pub module_path: Option<String>,
37    /// Source file of the emitting code, when known.
38    pub file: Option<String>,
39    /// Line within [`file`](Self::file) of the emitting code, when known.
40    pub line: Option<u32>,
41    /// The log text itself.
42    pub message: String,
43    /// When the record was produced.
44    pub timestamp: DateTime<Utc>,
45    /// Structured key/value fields attached to a `tracing` event, if any.
46    pub fields: Option<BTreeMap<String, String>>,
47}
48
49#[derive(Deserialize)]
50pub struct LogMessageHelper {
51    build_id: Option<BuildId>,
52    dataflow_id: Option<DataflowId>,
53    node_id: Option<NodeId>,
54    daemon_id: Option<DaemonId>,
55    level: LogLevelOrStdout,
56    target: Option<String>,
57    module_path: Option<String>,
58    file: Option<String>,
59    line: Option<u32>,
60    message: Option<String>,
61    timestamp: DateTime<Utc>,
62    fields: Option<BTreeMap<String, String>>,
63}
64
65impl From<LogMessageHelper> for LogMessage {
66    fn from(helper: LogMessageHelper) -> Self {
67        let fields = helper.fields.as_ref();
68        // Use `or_else` throughout so each fallback (a `BTreeMap` lookup plus a
69        // `String` clone, and sometimes a parse) is computed only when the
70        // typed field is absent. `or` would evaluate every fallback eagerly and
71        // discard it whenever the typed field is already `Some`.
72        LogMessage {
73            build_id: helper.build_id.or_else(|| {
74                fields
75                    .and_then(|f| f.get("build_id").cloned())
76                    .and_then(|id| BuildId::from_display_str(&id))
77            }),
78            dataflow_id: helper.dataflow_id.or_else(|| {
79                fields
80                    .and_then(|f| f.get("dataflow_id").cloned())
81                    .and_then(|id| Uuid::parse_str(&id).ok())
82            }),
83            node_id: helper.node_id.or_else(|| {
84                fields
85                    .and_then(|f| f.get("node_id").cloned())
86                    .and_then(|id| id.parse::<NodeId>().ok())
87            }),
88            daemon_id: helper.daemon_id.or_else(|| {
89                fields
90                    .and_then(|f| f.get("daemon_id").cloned())
91                    .and_then(|id| DaemonId::from_display_str(&id))
92            }),
93            level: helper.level,
94            target: helper
95                .target
96                .or_else(|| fields.and_then(|f| f.get("target").cloned())),
97            module_path: helper
98                .module_path
99                .or_else(|| fields.and_then(|f| f.get("module_path").cloned())),
100            file: helper
101                .file
102                .or_else(|| fields.and_then(|f| f.get("file").cloned())),
103            line: helper.line.or_else(|| {
104                fields
105                    .and_then(|f| f.get("line").cloned())
106                    .and_then(|s| s.parse().ok())
107            }),
108            message: helper
109                .message
110                .or_else(|| fields.and_then(|f| f.get("message").cloned()))
111                .unwrap_or_default(),
112            fields: helper.fields,
113            timestamp: helper.timestamp,
114        }
115    }
116}
117
118#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, PartialOrd, Ord)]
119#[serde(rename_all = "UPPERCASE")]
120pub enum LogLevelOrStdout {
121    #[serde(rename = "stdout")]
122    Stdout,
123    #[serde(untagged)]
124    LogLevel(LogLevel),
125}
126
127impl LogLevelOrStdout {
128    /// Returns true if a message at this level passes the given minimum level filter.
129    ///
130    /// `Stdout` is treated as a distinct channel rather than a severity, so it
131    /// does not participate in the `Error < Warn < Info < Debug < Trace`
132    /// ordering (where `Error` is the most severe and `Trace` the least):
133    ///
134    /// - A `Stdout` message passes only a `Stdout` filter; it is never
135    ///   delivered to a severity filter.
136    /// - A `LogLevel` message always passes a `Stdout` filter (the most
137    ///   permissive), and passes a `LogLevel` filter `min` when its severity is
138    ///   at least as severe as `min` (`msg <= min` in log-crate ordering).
139    ///
140    /// ```
141    /// use dora_message::common::{LogLevel, LogLevelOrStdout};
142    ///
143    /// let stdout = LogLevelOrStdout::Stdout;
144    /// let info = LogLevelOrStdout::LogLevel(LogLevel::Info);
145    /// let error = LogLevelOrStdout::LogLevel(LogLevel::Error);
146    ///
147    /// // Severity: `Error` is more severe than `Info`, so it passes an `Info` filter...
148    /// assert!(error.passes(&info));
149    /// // ...while a less severe message is filtered out.
150    /// assert!(!info.passes(&error));
151    /// // A message passes a filter at its own level.
152    /// assert!(info.passes(&info));
153    ///
154    /// // A `LogLevel` message always passes the (most permissive) `Stdout` filter,
155    /// // but a `Stdout` message passes only a `Stdout` filter, never a severity one.
156    /// assert!(info.passes(&stdout));
157    /// assert!(stdout.passes(&stdout));
158    /// assert!(!stdout.passes(&info));
159    /// ```
160    pub fn passes(&self, min: &LogLevelOrStdout) -> bool {
161        match (self, min) {
162            (LogLevelOrStdout::Stdout, LogLevelOrStdout::Stdout) => true,
163            (LogLevelOrStdout::Stdout, _) => false,
164            (LogLevelOrStdout::LogLevel(_), LogLevelOrStdout::Stdout) => true,
165            (LogLevelOrStdout::LogLevel(msg), LogLevelOrStdout::LogLevel(max)) => msg <= max,
166        }
167    }
168}
169
170impl From<LogLevel> for LogLevelOrStdout {
171    fn from(level: LogLevel) -> Self {
172        Self::LogLevel(level)
173    }
174}
175
176#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
177pub struct NodeError {
178    pub timestamp: uhlc::Timestamp,
179    pub cause: NodeErrorCause,
180    pub exit_status: NodeExitStatus,
181}
182
183impl std::fmt::Display for NodeError {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        if let NodeErrorCause::FailedToSpawn(err) = &self.cause {
186            return write!(f, "failed to spawn node: {err}");
187        }
188        match &self.exit_status {
189            NodeExitStatus::Success => write!(f, "<success>"),
190            NodeExitStatus::IoError(err) => write!(f, "I/O error while reading exit status: {err}"),
191            NodeExitStatus::ExitCode(code) => write!(f, "exited with code {code}"),
192            NodeExitStatus::Signal(signal) => {
193                let signal_str: Cow<_> = match signal {
194                    1 => "SIGHUP".into(),
195                    2 => "SIGINT".into(),
196                    3 => "SIGQUIT".into(),
197                    4 => "SIGILL".into(),
198                    6 => "SIGABRT".into(),
199                    8 => "SIGFPE".into(),
200                    9 => "SIGKILL".into(),
201                    11 => "SIGSEGV".into(),
202                    13 => "SIGPIPE".into(),
203                    14 => "SIGALRM".into(),
204                    15 => "SIGTERM".into(),
205                    22 => "SIGTTOU".into(),
206                    23 => "SIGURG".into(),
207                    other => other.to_string().into(),
208                };
209                if matches!(self.cause, NodeErrorCause::GraceDuration) {
210                    write!(
211                        f,
212                        "node was killed by dora because it didn't react to a stop message in time ({signal_str})"
213                    )
214                } else {
215                    write!(f, "exited because of signal {signal_str}")
216                }
217            }
218            NodeExitStatus::Unknown => write!(f, "unknown exit status"),
219        }?;
220
221        match &self.cause {
222            NodeErrorCause::GraceDuration => {} // handled above
223            NodeErrorCause::Cascading { caused_by_node } => write!(
224                f,
225                ". This error occurred because node `{caused_by_node}` exited before connecting to dora."
226            )?,
227            // Rendered by the early return at the top of this function. Render
228            // it here too rather than `unreachable!()`: a `Display` impl must
229            // never panic (it runs implicitly on every log/`to_string()` of a
230            // `NodeError`, including on shutdown paths), and nothing structural
231            // ties this arm to that distant early return.
232            NodeErrorCause::FailedToSpawn(err) => write!(f, "failed to spawn node: {err}")?,
233            NodeErrorCause::Other { stderr } if stderr.is_empty() => {}
234            NodeErrorCause::Other { stderr } => {
235                let line: &str = "---------------------------------------------------------------------------------\n";
236                let stderr = stderr.trim_end();
237                write!(f, " with stderr output:\n{line}{stderr}\n{line}")?
238            }
239        }
240
241        Ok(())
242    }
243}
244
245#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
246pub enum NodeErrorCause {
247    /// Node was killed because it didn't react to a stop message in time.
248    GraceDuration,
249    /// Node failed because another node failed before,
250    Cascading {
251        caused_by_node: NodeId,
252    },
253    FailedToSpawn(String),
254    Other {
255        stderr: String,
256    },
257}
258
259/// How a node process ended.
260///
261/// Built from the node process's [`ExitStatus`](std::process::ExitStatus) (see
262/// the [`From`] impl), so it distinguishes a clean exit, a non-zero exit code,
263/// a killing signal (Unix), and the case where the daemon could not even wait
264/// on the process ([`IoError`](Self::IoError)).
265#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
266pub enum NodeExitStatus {
267    /// The process exited successfully (exit code 0).
268    Success,
269    /// The daemon failed to launch or wait on the process.
270    IoError(String),
271    /// The process exited with this non-zero code.
272    ExitCode(i32),
273    /// The process was terminated by this signal (Unix only).
274    Signal(i32),
275    /// The process ended in a way that is neither a code nor a signal.
276    Unknown,
277}
278
279impl NodeExitStatus {
280    /// Whether the node exited cleanly (i.e. this is [`Success`](Self::Success)).
281    ///
282    /// ```
283    /// use dora_message::common::NodeExitStatus;
284    ///
285    /// assert!(NodeExitStatus::Success.is_success());
286    /// assert!(!NodeExitStatus::ExitCode(1).is_success());
287    /// ```
288    pub fn is_success(&self) -> bool {
289        matches!(self, NodeExitStatus::Success)
290    }
291}
292
293impl From<Result<std::process::ExitStatus, std::io::Error>> for NodeExitStatus {
294    fn from(result: Result<std::process::ExitStatus, std::io::Error>) -> Self {
295        match result {
296            Ok(status) => {
297                if status.success() {
298                    NodeExitStatus::Success
299                } else if let Some(code) = status.code() {
300                    Self::ExitCode(code)
301                } else {
302                    #[cfg(unix)]
303                    {
304                        use std::os::unix::process::ExitStatusExt;
305                        if let Some(signal) = status.signal() {
306                            return Self::Signal(signal);
307                        }
308                    }
309                    Self::Unknown
310                }
311            }
312            Err(err) => Self::IoError(err.to_string()),
313        }
314    }
315}
316
317#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
318pub struct Timestamped<T> {
319    pub inner: T,
320    pub timestamp: uhlc::Timestamp,
321}
322
323impl Timestamped<InterDaemonEvent> {
324    /// Encode this event for the zenoh data plane and `dora record` files.
325    pub fn serialize(&self) -> eyre::Result<Vec<u8>> {
326        crate::encode_presized(self, self.inner.encode_size_hint())
327            .wrap_err("failed to serialize timestamped message")
328    }
329
330    pub fn deserialize_inter_daemon_event(bytes: &[u8]) -> eyre::Result<Self> {
331        crate::decode(bytes).wrap_err("failed to deserialize InterDaemonEvent")
332    }
333}
334
335pub type SharedMemoryId = String;
336
337#[derive(serde::Serialize, serde::Deserialize, Clone)]
338pub enum DataMessage {
339    Vec(#[serde(with = "crate::bulk_bytes")] AVec<u8, ConstAlign<128>>),
340}
341
342impl DataMessage {
343    /// Byte length of the carried payload.
344    pub fn len(&self) -> usize {
345        match self {
346            Self::Vec(v) => v.len(),
347        }
348    }
349
350    /// Whether the carried payload is empty (zero bytes).
351    pub fn is_empty(&self) -> bool {
352        self.len() == 0
353    }
354}
355
356impl fmt::Debug for DataMessage {
357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358        match self {
359            Self::Vec(_) => f
360                .debug_struct("Vec")
361                .field("len", &self.len())
362                .finish_non_exhaustive(),
363        }
364    }
365}
366
367/// Identity of a running daemon: an optional operator-supplied machine id
368/// paired with a per-process time-ordered UUIDv7.
369///
370/// The machine id (typically a hostname) makes the [`Display`](std::fmt::Display)
371/// form human-readable and lets the coordinator route by machine, while the UUID
372/// keeps the id unique even when two daemons share a machine id (or none is set).
373/// The `Display` form is `"{machine_id}-{uuid}"`, or a bare `"{uuid}"` when there
374/// is no machine id; [`from_display_str`](Self::from_display_str) is its inverse.
375#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
376pub struct DaemonId {
377    machine_id: Option<String>,
378    uuid: Uuid,
379}
380
381impl DaemonId {
382    /// Create a fresh daemon id for the given machine, assigning a new
383    /// time-ordered UUIDv7. Pass `None` when no machine id is configured.
384    pub fn new(machine_id: Option<String>) -> Self {
385        DaemonId {
386            machine_id,
387            uuid: Uuid::now_v7(),
388        }
389    }
390
391    /// Whether this daemon was created with exactly the given machine id.
392    ///
393    /// Always `false` for a daemon created without a machine id.
394    pub fn matches_machine_id(&self, machine_id: &str) -> bool {
395        self.machine_id
396            .as_ref()
397            .map(|id| id == machine_id)
398            .unwrap_or_default()
399    }
400
401    /// The machine id this daemon was created with, or `None` if none was set.
402    pub fn machine_id(&self) -> Option<&str> {
403        self.machine_id.as_deref()
404    }
405
406    /// Reverse of [`Display`](std::fmt::Display): parse `"{machine_id}-{uuid}"`, or a bare
407    /// `"{uuid}"` when there is no machine id.
408    ///
409    /// Both the machine id (hostnames) and the canonical UUID contain `-`, so
410    /// splitting on a hyphen drops or corrupts a hyphenated machine id. Split
411    /// off the fixed-width 36-char canonical UUID suffix instead
412    /// (dora-rs/dora#2027).
413    ///
414    /// Expects exact `Display` output (no surrounding whitespace). The
415    /// machine-id path requires the canonical 36-char UUID suffix that
416    /// `Display` emits; the bare path accepts any form `Uuid::parse_str`
417    /// recognizes (canonical / simple / urn / braced).
418    ///
419    /// ```
420    /// use dora_message::common::DaemonId;
421    ///
422    /// // A hyphenated machine id round-trips through `Display` (dora-rs/dora#2027)...
423    /// let id = DaemonId::new(Some("my-host".to_string()));
424    /// assert_eq!(DaemonId::from_display_str(&id.to_string()), Some(id.clone()));
425    /// assert_eq!(id.machine_id(), Some("my-host"));
426    /// assert!(id.matches_machine_id("my-host"));
427    ///
428    /// // ...as does a daemon with no machine id (a bare UUID).
429    /// let anon = DaemonId::new(None);
430    /// assert_eq!(DaemonId::from_display_str(&anon.to_string()), Some(anon.clone()));
431    /// assert_eq!(anon.machine_id(), None);
432    ///
433    /// // Garbage does not parse to a bogus id.
434    /// assert_eq!(DaemonId::from_display_str("not-a-daemon-id"), None);
435    /// ```
436    pub fn from_display_str(s: &str) -> Option<Self> {
437        // No machine id: the whole string is the UUID.
438        if let Ok(uuid) = Uuid::parse_str(s) {
439            return Some(DaemonId {
440                machine_id: None,
441                uuid,
442            });
443        }
444        // `Display` writes the UUID via `{}` (canonical 36-char hyphenated
445        // form), preceded by `"{machine_id}-"`.
446        const UUID_LEN: usize = 36;
447        let split = s.len().checked_sub(UUID_LEN)?;
448        let uuid = Uuid::parse_str(s.get(split..)?).ok()?;
449        let machine_id = s.get(..split)?.strip_suffix('-')?;
450        Some(DaemonId {
451            machine_id: Some(machine_id.to_string()),
452            uuid,
453        })
454    }
455}
456
457impl std::fmt::Display for DaemonId {
458    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459        if let Some(id) = &self.machine_id {
460            write!(f, "{id}-")?;
461        }
462        write!(f, "{}", self.uuid)
463    }
464}
465
466#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)]
467pub struct GitSource {
468    pub repo: String,
469    pub commit_hash: String,
470    /// Subdirectory of the repository the node lives in (monorepo support).
471    /// Build, env preparation, and spawn are rooted at `<clone>/<subdir>`.
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub subdir: Option<String>,
474    /// Hub provenance marker. Set when this git source was desugared from a
475    /// `hub:` reference — it tells the daemon to use confined path
476    /// resolution (no ambient `$PATH` fallback) for the node, and feeds the
477    /// lockfile and `dora hub` commands.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub hub: Option<HubProvenance>,
480}
481
482/// Identity of the hub package a git source was resolved from.
483#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)]
484pub struct HubProvenance {
485    /// Index key (`namespace/name`).
486    pub name: String,
487    /// Resolved version.
488    pub version: String,
489    /// Digest of the index entry's manifest at lock time. The commit hash pins
490    /// the *source tree*, but the entrypoint, build command, and typed contract
491    /// (inputs/outputs/types) all live in the (mutable) index entry — so a
492    /// rewritten entry could change any of them for an already-pinned version.
493    /// `--locked` hard-errors if this digest no longer matches. `Option` for
494    /// back-compat with lockfiles written before this field existed.
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub manifest_digest: Option<String>,
497}
498
499/// Lockfile pin for a `hub:` node resolved to a prebuilt binary artifact
500/// (spec §8.2). Mirrors [`GitSource`] for the binary source form: the
501/// `url`+`sha256` pin the bytes (re-verified on download), and `hub` records
502/// the package/version/manifest provenance for `--locked` tamper detection.
503#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, PartialEq, Eq)]
504pub struct BinaryPin {
505    /// Platform the artifact was selected for at lock time (`<os>-<arch>`).
506    pub platform: String,
507    /// Download URL of the prebuilt artifact.
508    pub url: String,
509    /// SHA-256 the download must match.
510    pub sha256: String,
511    /// Hub provenance (index key, version, manifest digest).
512    pub hub: HubProvenance,
513}
514
515// Test roundtrip serialization of LogMessage
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn node_error_with_signal(signal: i32) -> NodeError {
521        NodeError {
522            timestamp: uhlc::HLC::default().new_timestamp(),
523            cause: NodeErrorCause::Other {
524                stderr: String::new(),
525            },
526            exit_status: NodeExitStatus::Signal(signal),
527        }
528    }
529
530    #[test]
531    fn node_error_signal_display_uses_linux_signal_names() {
532        for (signal, name) in [(6, "SIGABRT"), (22, "SIGTTOU"), (23, "SIGURG")] {
533            assert_eq!(
534                node_error_with_signal(signal).to_string(),
535                format!("exited because of signal {name}")
536            );
537        }
538    }
539
540    #[test]
541    fn node_error_signal_display_falls_back_to_signal_number() {
542        assert_eq!(
543            node_error_with_signal(40).to_string(),
544            "exited because of signal 40"
545        );
546    }
547
548    #[test]
549    fn test_log_message_serialization() {
550        let log_message = LogMessage {
551            build_id: Some(BuildId(Uuid::new_v4())),
552            dataflow_id: Some(DataflowId::from(Uuid::new_v4())),
553            node_id: Some(NodeId("node-1".to_string())),
554            daemon_id: Some(DaemonId::new(Some("machine-1".to_string()))),
555            level: LogLevelOrStdout::LogLevel(LogLevel::Info),
556            target: Some("target".to_string()),
557            module_path: Some("module::path".to_string()),
558            file: Some("file.rs".to_string()),
559            line: Some(42),
560            message: "This is a log message".to_string(),
561            timestamp: Utc::now(),
562            fields: Some(BTreeMap::from([("key".to_string(), "value".to_string())])),
563        };
564        let serialized = serde_yaml::to_string(&log_message).unwrap();
565        let deserialized: LogMessageHelper = serde_yaml::from_str(&serialized).unwrap();
566        assert_eq!(log_message, LogMessage::from(deserialized));
567    }
568
569    #[test]
570    fn stdout_passes_stdout_filter() {
571        let stdout = LogLevelOrStdout::Stdout;
572        assert!(stdout.passes(&LogLevelOrStdout::Stdout));
573    }
574
575    /// A `node_id` recovered from the untrusted `fields` map must go through
576    /// `NodeId` validation. It was previously wrapped via the raw tuple
577    /// constructor (`.map(NodeId)`), bypassing `validate_node_id` and yielding
578    /// a `NodeId` that could contain `/` or dot-segments -- the very
579    /// path-traversal characters the newtype exists to forbid. Every other
580    /// field recovered here (`build_id`, `dataflow_id`, `daemon_id`) already
581    /// parses defensively; `node_id` was the odd one out.
582    #[test]
583    fn node_id_recovered_from_fields_is_validated() {
584        let make = |node_id: &str| LogMessageHelper {
585            build_id: None,
586            dataflow_id: None,
587            node_id: None,
588            daemon_id: None,
589            level: LogLevelOrStdout::Stdout,
590            target: None,
591            module_path: None,
592            file: None,
593            line: None,
594            message: Some("m".to_string()),
595            timestamp: Utc::now(),
596            fields: Some(BTreeMap::from([(
597                "node_id".to_string(),
598                node_id.to_string(),
599            )])),
600        };
601
602        // A valid id is promoted to the typed field.
603        assert_eq!(
604            LogMessage::from(make("sensor")).node_id,
605            Some("sensor".parse().unwrap())
606        );
607
608        // Invalid ids (path separator / dot-segment) are rejected rather than
609        // silently wrapped into an unvalidated NodeId.
610        assert_eq!(LogMessage::from(make("a/b")).node_id, None);
611        assert_eq!(LogMessage::from(make("../evil")).node_id, None);
612    }
613
614    /// A `build_id` back-filled from the `fields` map must survive a
615    /// `Display` round trip. `BuildId`'s `Display` wraps the UUID as
616    /// `BuildId(<uuid>)` -- the exact string emitted by the idiomatic
617    /// `tracing::info!(build_id = %build_id, ...)` (e.g. the coordinator's
618    /// build warnings). Recovery previously used `Uuid::parse_str`, which
619    /// rejects that wrapper and silently dropped the id; it now goes through
620    /// `BuildId::from_display_str`, matching how `daemon_id` is recovered.
621    #[test]
622    fn build_id_recovered_from_fields_survives_display_round_trip() {
623        let build_id = BuildId(Uuid::new_v4());
624        let make = |value: &str| LogMessageHelper {
625            build_id: None,
626            dataflow_id: None,
627            node_id: None,
628            daemon_id: None,
629            level: LogLevelOrStdout::Stdout,
630            target: None,
631            module_path: None,
632            file: None,
633            line: None,
634            message: Some("m".to_string()),
635            timestamp: Utc::now(),
636            fields: Some(BTreeMap::from([(
637                "build_id".to_string(),
638                value.to_string(),
639            )])),
640        };
641
642        // The `Display` form (`BuildId(<uuid>)`) is recovered...
643        assert_eq!(
644            LogMessage::from(make(&build_id.to_string())).build_id,
645            Some(build_id)
646        );
647        // ...and a bare UUID still works, for backward compatibility.
648        assert_eq!(
649            LogMessage::from(make(&build_id.uuid().to_string())).build_id,
650            Some(build_id)
651        );
652        // Garbage is rejected rather than wrapped into a bogus id.
653        assert_eq!(LogMessage::from(make("not-a-build-id")).build_id, None);
654    }
655
656    /// #2027: `DaemonId` must survive a `Display` -> `from_display_str` round
657    /// trip even when the machine id contains `-` (hostnames do). The old
658    /// `splitn(2, '-')` parse split on the first hyphen, which corrupted the
659    /// UUID (itself hyphenated) and silently dropped the daemon id.
660    #[test]
661    fn daemon_id_display_roundtrips_through_parse() {
662        let uuid = Uuid::new_v4();
663        for machine in [None, Some("host"), Some("my-host"), Some("a-b-c-d")] {
664            let id = DaemonId {
665                machine_id: machine.map(str::to_string),
666                uuid,
667            };
668            let parsed = DaemonId::from_display_str(&id.to_string())
669                .unwrap_or_else(|| panic!("failed to parse {id}"));
670            assert_eq!(parsed, id, "round-trip failed for machine_id={machine:?}");
671        }
672    }
673
674    /// A bare (machine-id-less) daemon id round-trips, and garbage does not
675    /// parse to a bogus id.
676    #[test]
677    fn daemon_id_parse_edge_cases() {
678        let uuid = Uuid::new_v4();
679        let bare = DaemonId {
680            machine_id: None,
681            uuid,
682        };
683        assert_eq!(DaemonId::from_display_str(&bare.to_string()), Some(bare));
684        assert_eq!(DaemonId::from_display_str("not-a-daemon-id"), None);
685        assert_eq!(DaemonId::from_display_str(""), None);
686    }
687
688    #[test]
689    fn stdout_fails_non_stdout_filters() {
690        let stdout = LogLevelOrStdout::Stdout;
691        assert!(!stdout.passes(&LogLevelOrStdout::LogLevel(LogLevel::Info)));
692        assert!(!stdout.passes(&LogLevelOrStdout::LogLevel(LogLevel::Warn)));
693        assert!(!stdout.passes(&LogLevelOrStdout::LogLevel(LogLevel::Error)));
694    }
695
696    #[test]
697    fn any_log_level_passes_stdout_filter() {
698        let stdout_filter = LogLevelOrStdout::Stdout;
699        for level in [
700            LogLevel::Error,
701            LogLevel::Warn,
702            LogLevel::Info,
703            LogLevel::Debug,
704            LogLevel::Trace,
705        ] {
706            assert!(
707                LogLevelOrStdout::LogLevel(level).passes(&stdout_filter),
708                "{level:?} should pass stdout filter"
709            );
710        }
711    }
712
713    #[test]
714    fn error_passes_less_verbose_filters() {
715        let error = LogLevelOrStdout::LogLevel(LogLevel::Error);
716        assert!(error.passes(&LogLevelOrStdout::LogLevel(LogLevel::Error)));
717        assert!(error.passes(&LogLevelOrStdout::LogLevel(LogLevel::Warn)));
718        assert!(error.passes(&LogLevelOrStdout::LogLevel(LogLevel::Info)));
719    }
720
721    #[test]
722    fn debug_fails_info_filter() {
723        let debug = LogLevelOrStdout::LogLevel(LogLevel::Debug);
724        assert!(!debug.passes(&LogLevelOrStdout::LogLevel(LogLevel::Info)));
725    }
726
727    #[test]
728    fn same_level_passes_itself() {
729        for level in [
730            LogLevel::Error,
731            LogLevel::Warn,
732            LogLevel::Info,
733            LogLevel::Debug,
734            LogLevel::Trace,
735        ] {
736            let l = LogLevelOrStdout::LogLevel(level);
737            assert!(l.passes(&l), "{level:?} should pass itself");
738        }
739    }
740
741    #[test]
742    fn trace_passes_trace_fails_debug() {
743        let trace = LogLevelOrStdout::LogLevel(LogLevel::Trace);
744        assert!(trace.passes(&LogLevelOrStdout::LogLevel(LogLevel::Trace)));
745        assert!(!trace.passes(&LogLevelOrStdout::LogLevel(LogLevel::Debug)));
746    }
747
748    #[test]
749    fn daemon_id_uses_v7_uuid() {
750        let id = DaemonId::new(None);
751        // v7 UUIDs have version nibble = 7
752        assert_eq!(id.uuid.get_version_num(), 7);
753    }
754}