Skip to main content

log_io/
record.rs

1//! Log records and their constituent parts.
2//!
3//! The data model is intentionally small. A [`Record`] is the
4//! immutable unit that flows from logger to filter to formatter to
5//! sink. All its parts borrow, so a record can be constructed on the
6//! stack without touching the allocator.
7
8use crate::level::Level;
9use crate::value::Value;
10
11/// Keys that every built-in structured format writes itself. A user
12/// field with one of these names would shadow (or, in last-wins JSON
13/// parsers, override) the real value, so the JSON and logfmt formats
14/// write it with [`RESERVED_PREFIX`] instead.
15#[cfg(any(feature = "std", feature = "json", feature = "logfmt"))]
16#[inline]
17pub(crate) fn is_reserved_key(key: &str) -> bool {
18    matches!(
19        key,
20        "timestamp" | "level" | "target" | "message" | "file" | "line"
21    )
22}
23
24/// Prefix applied to user keys that collide with a reserved key.
25#[cfg(any(feature = "std", feature = "json", feature = "logfmt"))]
26pub(crate) const RESERVED_PREFIX: &str = "fields.";
27
28/// `true` if the structured formats write `a` and `b` under the same
29/// key: the keys are equal, or one is a reserved key and the other is
30/// that key with [`RESERVED_PREFIX`] (`level` is written as
31/// `fields.level`).
32///
33/// Runs for every pair of call-site fields on every record, so it
34/// rejects on length and on the first and last bytes before comparing
35/// whole keys: distinct keys almost never pay for a full comparison.
36#[cfg(feature = "std")]
37#[inline]
38pub(crate) fn same_output_key(a: &str, b: &str) -> bool {
39    let (x, y) = (a.as_bytes(), b.as_bytes());
40    if x.len() == y.len() {
41        x.first() == y.first() && x.last() == y.last() && x == y
42    } else if x.len().abs_diff(y.len()) == RESERVED_PREFIX.len() {
43        is_prefixed_alias(a, b) || is_prefixed_alias(b, a)
44    } else {
45        false
46    }
47}
48
49#[cfg(feature = "std")]
50#[inline]
51fn is_prefixed_alias(plain: &str, prefixed: &str) -> bool {
52    prefixed.len() == plain.len() + RESERVED_PREFIX.len()
53        && prefixed.strip_prefix(RESERVED_PREFIX) == Some(plain)
54        && is_reserved_key(plain)
55}
56
57/// A single key-value pair attached to a [`Record`].
58///
59/// Keys are borrowed strings. Values are borrowed via [`Value`]. Keys
60/// may contain any UTF-8; the built-in formats sanitize or escape them
61/// so a hostile key cannot break the output framing.
62///
63/// # Examples
64///
65/// ```
66/// use log_io::{Field, Value};
67///
68/// let f = Field::new("port", 8080_u32);
69/// assert_eq!(f.key, "port");
70/// assert_eq!(f.value, Value::U64(8080));
71/// ```
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct Field<'a> {
74    /// The field name.
75    pub key: &'a str,
76    /// The field value.
77    pub value: Value<'a>,
78}
79
80impl<'a> Field<'a> {
81    /// Construct a field from a key and anything convertible to a
82    /// [`Value`], so callers can write `Field::new("port", 8080_u32)`.
83    pub fn new<V: Into<Value<'a>>>(key: &'a str, value: V) -> Self {
84        Self {
85            key,
86            value: value.into(),
87        }
88    }
89}
90
91/// Metadata describing the origin and severity of a record.
92///
93/// `level` and `target` are required; the rest is best-effort context.
94/// The logging macros populate `file` and `line` automatically and the
95/// [`crate::Logger`] fills in `timestamp_unix_nanos` when timestamps
96/// are enabled.
97///
98/// The struct is `#[non_exhaustive]` so new metadata can be added in a
99/// minor release. Build it with [`Metadata::new`] and the `with_*`
100/// methods; every field stays publicly readable.
101///
102/// # Examples
103///
104/// ```
105/// use log_io::{Level, Metadata};
106///
107/// let meta = Metadata::new(Level::Warn, "app::db")
108///     .with_location("src/db.rs", 42)
109///     .with_timestamp(1_700_000_000_000_000_000);
110/// assert_eq!(meta.target, "app::db");
111/// assert_eq!(meta.line, Some(42));
112/// ```
113#[derive(Debug, Clone, Copy, PartialEq)]
114#[non_exhaustive]
115pub struct Metadata<'a> {
116    /// Severity of the record.
117    pub level: Level,
118    /// Logical target. Typically the module path, but callers are free
119    /// to use a domain-specific identifier for filtering.
120    pub target: &'a str,
121    /// Source file path, if known.
122    pub file: Option<&'a str>,
123    /// Source line number, if known.
124    pub line: Option<u32>,
125    /// Nanoseconds since the Unix epoch, if known. Populated by the
126    /// logger when std is available and timestamps are enabled. `None`
127    /// means the consumer should supply its own clock (typical in
128    /// `no_std` builds).
129    pub timestamp_unix_nanos: Option<u128>,
130}
131
132impl<'a> Metadata<'a> {
133    /// Build minimal metadata containing only `level` and `target`.
134    /// All optional fields are `None`.
135    #[must_use]
136    pub const fn new(level: Level, target: &'a str) -> Self {
137        Self {
138            level,
139            target,
140            file: None,
141            line: None,
142            timestamp_unix_nanos: None,
143        }
144    }
145
146    /// Replace the source location.
147    #[must_use]
148    pub const fn with_location(mut self, file: &'a str, line: u32) -> Self {
149        self.file = Some(file);
150        self.line = Some(line);
151        self
152    }
153
154    /// Replace the timestamp (nanoseconds since the Unix epoch).
155    #[must_use]
156    pub const fn with_timestamp(mut self, ts_unix_nanos: u128) -> Self {
157        self.timestamp_unix_nanos = Some(ts_unix_nanos);
158        self
159    }
160}
161
162/// A complete, immutable log record.
163///
164/// Records borrow their data; the lifetime parameter is the shortest
165/// of the borrows. A [`crate::Sink`] receives a `&Record` and must not
166/// retain it past the call.
167///
168/// `fields` holds the per-call fields and `context` holds ambient
169/// fields (logger defaults and thread-local context). When a record is
170/// produced by a [`crate::Logger`], keys are unique across both slices:
171/// a call field shadows a context field with the same key, and a
172/// context field shadows a default field. Hand-built records are
173/// rendered exactly as given.
174///
175/// The struct is `#[non_exhaustive]`; construct it with
176/// [`Record::new`] or [`Record::with_context`].
177///
178/// # Examples
179///
180/// ```
181/// use log_io::{Field, Level, Metadata, Record};
182///
183/// let fields = [Field::new("port", 80_u16)];
184/// let ctx = [Field::new("trace_id", "abc")];
185/// let record = Record::with_context(Metadata::new(Level::Info, "app"), "up", &fields, &ctx);
186/// let keys: Vec<&str> = record.all_fields().map(|f| f.key).collect();
187/// assert_eq!(keys, ["trace_id", "port"]);
188/// ```
189#[derive(Debug, Clone, Copy)]
190#[non_exhaustive]
191pub struct Record<'a> {
192    /// Static origin and severity information.
193    pub metadata: Metadata<'a>,
194    /// Free-form message.
195    pub message: &'a str,
196    /// Borrowed per-call structured fields. May be empty.
197    pub fields: &'a [Field<'a>],
198    /// Borrowed context fields (defaults and thread-local context).
199    /// May be empty. Kept apart from `fields` so formatters can label
200    /// or order them differently.
201    pub context: &'a [Field<'a>],
202}
203
204impl<'a> Record<'a> {
205    /// Build a record with no context fields.
206    #[must_use]
207    pub const fn new(metadata: Metadata<'a>, message: &'a str, fields: &'a [Field<'a>]) -> Self {
208        Self {
209            metadata,
210            message,
211            fields,
212            context: &[],
213        }
214    }
215
216    /// Build a record with explicit context fields.
217    #[must_use]
218    pub const fn with_context(
219        metadata: Metadata<'a>,
220        message: &'a str,
221        fields: &'a [Field<'a>],
222        context: &'a [Field<'a>],
223    ) -> Self {
224        Self {
225            metadata,
226            message,
227            fields,
228            context,
229        }
230    }
231
232    /// Iterate over context fields followed by per-call fields, the
233    /// order every built-in formatter uses.
234    pub fn all_fields(&self) -> impl Iterator<Item = &Field<'a>> {
235        self.context.iter().chain(self.fields.iter())
236    }
237}
238
239#[cfg(all(test, feature = "std"))]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_field_new_accepts_typed_values() {
245        let f = Field::new("port", 8080_u32);
246        assert_eq!(f.key, "port");
247        assert_eq!(f.value, Value::U64(8080));
248    }
249
250    #[test]
251    fn test_metadata_chain_setters_populate_options() {
252        let m = Metadata::new(Level::Info, "tgt")
253            .with_location("file.rs", 12)
254            .with_timestamp(1_700_000_000_000_000_000);
255        assert_eq!(m.file, Some("file.rs"));
256        assert_eq!(m.line, Some(12));
257        assert_eq!(m.timestamp_unix_nanos, Some(1_700_000_000_000_000_000));
258        let bare = Metadata::new(Level::Info, "tgt");
259        assert_eq!(bare.file, None);
260        assert_eq!(bare.timestamp_unix_nanos, None);
261    }
262
263    #[test]
264    fn test_record_all_fields_orders_context_first() {
265        let ctx = [Field::new("trace_id", "abc")];
266        let fields = [Field::new("port", 80_u32)];
267        let record = Record::with_context(Metadata::new(Level::Info, "tgt"), "msg", &fields, &ctx);
268        let keys: Vec<_> = record.all_fields().map(|f| f.key).collect();
269        assert_eq!(keys, vec!["trace_id", "port"]);
270        let plain = Record::new(Metadata::new(Level::Info, "tgt"), "msg", &fields);
271        assert!(plain.context.is_empty());
272    }
273}