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}