use std::borrow::Cow;
use crate::event::{EventContext, Message, Severity, StructuredEvent, Value};
use crate::redact::Redactor;
mod channel;
mod collapse;
mod filter;
mod group;
mod parse;
mod time;
pub use channel::ChannelSink;
pub use collapse::{Collapser, Push, Repeat};
pub use filter::{Filter, FilterError, LevelFilter, Op, Predicate, severity_of};
pub use group::{Group, GroupBy, GroupHeader, Groups, LevelCounts, Summary};
#[cfg(feature = "data")]
pub use parse::JsonParser;
pub use parse::{
AccessLog, Assembled, Assembler, LevelLine, LineParser, Logfmt, Parsers, Timestamped,
logfmt_pairs,
};
pub use time::{Clock, TimeMode, format_offset, parse_timestamp};
pub const MESSAGE_KEYS: &[&str] = &["message", "msg"];
pub const LEVEL_KEYS: &[&str] = &["level", "severity"];
pub const TIME_KEYS: &[&str] = &["timestamp", "time", "@timestamp"];
pub const STYLES: &[(&str, &str)] = &[
("log.group", "bold"),
("log.group.key", "bold magenta"),
("log.group.count", "dim"),
("log.repeat", "dim"),
("log.continuation", "dim"),
("log.summary", "bold"),
("log.summary.error", "bold red"),
];
#[derive(Clone, Debug, PartialEq)]
pub struct Record {
pub fields: Vec<(String, Value)>,
pub format: Cow<'static, str>,
pub continuation: Vec<String>,
}
impl Record {
pub fn new(format: impl Into<Cow<'static, str>>) -> Record {
Record {
fields: Vec::new(),
format: format.into(),
continuation: Vec::new(),
}
}
pub fn text(line: impl Into<String>) -> Record {
Record::new("text").field("message", Value::String(line.into()))
}
#[must_use]
pub fn field(mut self, key: impl Into<String>, value: Value) -> Record {
self.set(key, value);
self
}
pub fn set(&mut self, key: impl Into<String>, value: Value) {
let key = key.into();
match self.fields.iter_mut().find(|(name, _)| *name == key) {
Some((_, old)) => *old = value,
None => self.fields.push((key, value)),
}
}
pub fn is_text(&self) -> bool {
self.format == "text"
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.fields
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value)
}
pub fn lookup(&self, path: &str) -> Option<&Value> {
lookup_fields(&self.fields, path)
}
pub fn first_of<'a>(&'a self, keys: &[&str]) -> Option<(&'a str, &'a Value)> {
keys.iter().find_map(|key| {
self.fields
.iter()
.find(|(name, _)| name == key)
.map(|(name, value)| (name.as_str(), value))
})
}
pub fn message(&self) -> Option<Cow<'_, str>> {
self.first_of(MESSAGE_KEYS).map(|(_, value)| display(value))
}
pub fn level(&self) -> Option<&Value> {
self.first_of(LEVEL_KEYS).map(|(_, value)| value)
}
pub fn severity(&self) -> Option<Severity> {
self.level().and_then(severity_of)
}
pub fn time(&self) -> Option<Cow<'_, str>> {
self.first_of(TIME_KEYS).map(|(_, value)| display(value))
}
pub fn instant(&self) -> Option<i64> {
let (_, value) = self.first_of(TIME_KEYS)?;
match value {
Value::String(text) => parse_timestamp(text),
other => time::epoch(other),
}
}
pub fn takes_continuation(&self) -> bool {
!self.is_text()
&& self
.fields
.iter()
.any(|(key, _)| !MESSAGE_KEYS.contains(&key.as_str()))
}
pub fn to_event(&self) -> StructuredEvent {
let message_key = self.first_of(MESSAGE_KEYS).map(|(key, _)| key);
let time_key = self.first_of(TIME_KEYS).map(|(key, _)| key);
let severity = self.severity();
let level_key = severity.and(self.first_of(LEVEL_KEYS).map(|(key, _)| key));
let mut event =
StructuredEvent::new(Message::Literal(self.message().unwrap_or_default().into()))
.context(EventContext {
severity,
timestamp: self.time().map(Cow::into_owned),
..Default::default()
});
for (key, value) in &self.fields {
let name = Some(key.as_str());
if name == message_key || name == time_key || name == level_key {
continue;
}
event.fields.push((key.clone(), value.clone()));
}
event
}
pub fn from_event(event: &StructuredEvent) -> Record {
let context = &event.context;
let text = |value: &str| Value::String(value.to_string());
let mut record = Record::new("event");
if let Some(time) = &context.timestamp {
record.fields.push(("timestamp".into(), text(time)));
}
if let Some(severity) = context.severity {
record
.fields
.push(("level".into(), text(severity.as_str())));
}
if let Some(target) = &context.target {
record.fields.push(("target".into(), text(target)));
}
let message = match &event.message {
Message::Literal(message) | Message::Markup(message) => message,
};
record.fields.push(("message".into(), text(message)));
for (key, value) in &event.fields {
record.set(key.clone(), value.clone());
}
for (key, value) in [
("thread", &context.thread),
("task", &context.task),
("correlation_id", &context.correlation_id),
] {
if let Some(value) = value {
record.set(key, text(value));
}
}
let spans = event.span_context();
if !spans.is_empty() {
let names: Vec<&str> = spans.iter().map(|span| span.name.as_str()).collect();
record.set("spans", text(&names.join(":")));
}
match event.span_marker() {
Some(crate::event::SpanEvent::Open) => record.set("span", text("open")),
Some(crate::event::SpanEvent::Close { elapsed }) => {
record.set("span", text("close"));
let micros = elapsed.as_micros().min(u64::MAX as u128) as u64;
record.set("elapsed_us", Value::Unsigned(micros));
}
None => {}
}
record
}
pub fn same_as(&self, other: &Record) -> bool {
fn untimed(record: &Record) -> impl Iterator<Item = &(String, Value)> {
record
.fields
.iter()
.filter(|(key, _)| !TIME_KEYS.contains(&key.as_str()))
}
self.continuation == other.continuation && untimed(self).eq(untimed(other))
}
pub fn redact(&mut self, redactor: &Redactor) {
if redactor.is_empty() {
return;
}
for (key, value) in &mut self.fields {
redact_value(redactor, key, value);
}
for line in &mut self.continuation {
*line = redactor.redact_str(line);
}
}
pub fn sanitize(&mut self) {
fn key(key: &mut String) {
if key.chars().any(char::is_control) {
*key = crate::sanitize_terminal_controls(key);
}
}
fn walk(value: &mut Value) {
match value {
Value::String(text) => *text = crate::sanitize_terminal_controls(text),
Value::List(items) => items.iter_mut().for_each(walk),
Value::Map(entries) => entries.iter_mut().for_each(|(name, value)| {
key(name);
walk(value)
}),
_ => {}
}
}
for (name, value) in &mut self.fields {
key(name);
walk(value);
}
for line in &mut self.continuation {
*line = crate::sanitize_terminal_controls(line);
}
}
}
pub(crate) fn lookup_fields<'a>(fields: &'a [(String, Value)], path: &str) -> Option<&'a Value> {
let get = |key: &str| {
fields
.iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value)
};
if let Some(value) = get(path) {
return Some(value);
}
for group in [MESSAGE_KEYS, LEVEL_KEYS, TIME_KEYS] {
if group.contains(&path)
&& let Some(value) = group.iter().find_map(|key| get(key))
{
return Some(value);
}
}
if !path.contains('.') {
return None;
}
let parts: Vec<&str> = path.split('.').collect();
nested(fields, &parts)
}
pub(crate) fn display(value: &Value) -> Cow<'_, str> {
match value {
Value::String(text) => Cow::Borrowed(text),
other => Cow::Owned(other.format(false, 0)),
}
}
fn nested<'a>(fields: &'a [(String, Value)], parts: &[&str]) -> Option<&'a Value> {
for take in (1..=parts.len()).rev() {
let key = parts[..take].join(".");
let Some((_, value)) = fields.iter().find(|(name, _)| *name == key) else {
continue;
};
if take == parts.len() {
return Some(value);
}
if let Some(found) = within(value, &parts[take..]) {
return Some(found);
}
}
None
}
fn within<'a>(value: &'a Value, parts: &[&str]) -> Option<&'a Value> {
match value {
Value::Map(entries) => nested(entries, parts),
Value::List(items) => {
let index: usize = parts[0].parse().ok()?;
let item = items.get(index)?;
if parts.len() == 1 {
Some(item)
} else {
within(item, &parts[1..])
}
}
_ => None,
}
}
fn redact_value(redactor: &Redactor, key: &str, value: &mut Value) {
match value {
Value::String(text) => {
let redacted = redactor.redact_field(key, text);
if redacted != *text {
*text = redacted;
}
}
Value::List(items) => {
for item in items {
redact_value(redactor, key, item);
}
}
Value::Map(entries) => {
for (key, value) in entries {
redact_value(redactor, key, value);
}
}
Value::Null => {}
other => {
let text = other.format(false, 0);
let redacted = redactor.redact_field(key, &text);
if redacted != text {
*other = Value::String(redacted);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn record() -> Record {
Record::new("json")
.field("time", Value::String("2026-10-18T10:00:00Z".into()))
.field("level", Value::String("WARN".into()))
.field("msg", Value::String("slow".into()))
.field(
"http",
Value::Map(vec![
("status".into(), Value::Integer(503)),
(
"headers".into(),
Value::List(vec![Value::String("a".into())]),
),
]),
)
.field("db.table", Value::String("users".into()))
}
#[test]
fn lookup_finds_aliases_dotted_keys_and_nested_values() {
let record = record();
assert_eq!(
record.lookup("message"),
Some(&Value::String("slow".into()))
);
assert_eq!(record.lookup("timestamp"), record.get("time"));
assert_eq!(record.lookup("severity"), record.get("level"));
assert_eq!(record.lookup("http.status"), Some(&Value::Integer(503)));
assert_eq!(
record.lookup("http.headers.0"),
Some(&Value::String("a".into()))
);
assert_eq!(
record.lookup("db.table"),
Some(&Value::String("users".into()))
);
assert_eq!(record.lookup("http.headers.9"), None);
assert_eq!(record.lookup("http.nope"), None);
assert_eq!(record.lookup("nope"), None);
}
#[test]
fn reads_message_severity_and_time() {
let record = record();
assert_eq!(record.message().as_deref(), Some("slow"));
assert_eq!(record.severity(), Some(Severity::Warn));
assert_eq!(record.time().as_deref(), Some("2026-10-18T10:00:00Z"));
assert_eq!(record.instant(), Some(1_792_317_600_000_000));
let event = record.to_event();
assert_eq!(event.context.severity, Some(Severity::Warn));
assert_eq!(
event.context.timestamp.as_deref(),
Some("2026-10-18T10:00:00Z")
);
let keys: Vec<&str> = event.fields.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, ["http", "db.table"]);
let odd = Record::new("json")
.field("level", Value::String("loud".into()))
.to_event();
assert_eq!(odd.context.severity, None);
assert_eq!(odd.fields[0].0, "level");
}
#[test]
fn same_as_ignores_only_the_time() {
let a = record();
let mut b = record().field("time", Value::String("2026-10-18T10:00:05Z".into()));
assert!(a.same_as(&b));
b.continuation.push(" at main".into());
assert!(!a.same_as(&b));
let c = record().field("msg", Value::String("fast".into()));
assert!(!a.same_as(&c));
}
#[test]
fn text_records_take_no_continuation() {
assert!(!Record::text("hello").takes_continuation());
assert!(record().takes_continuation());
let bare = Record::new("json").field("message", Value::String("42".into()));
assert!(!bare.takes_continuation());
}
#[test]
fn redaction_masks_secret_keys_and_secrets_in_text() {
let mut record = Record::new("json")
.field("password", Value::String("hunter 2".into()))
.field("api_key", Value::Integer(1234))
.field(
"msg",
Value::String("login token=ghp_0123456789abcdefghijklmnopqrstuvwxyzAB ok".into()),
)
.field(
"nested",
Value::Map(vec![("secret".into(), Value::String("x".into()))]),
)
.field("user", Value::String("ann".into()));
record
.continuation
.push("Authorization: Bearer abcdef0123456789".into());
record.redact(&Redactor::secrets());
assert_eq!(
record.get("password"),
Some(&Value::String("********".into()))
);
assert_eq!(
record.get("api_key"),
Some(&Value::String("********".into()))
);
assert_eq!(record.message().as_deref(), Some("login token=******** ok"));
assert_eq!(
record.lookup("nested.secret"),
Some(&Value::String("********".into()))
);
assert_eq!(record.get("user"), Some(&Value::String("ann".into())));
assert_eq!(record.continuation[0], "Authorization: Bearer ********");
let mut record = Record::new("json")
.field("password", Value::String("hunter2".into()))
.field("msg", Value::String("card 4111-1111".into()));
record.redact(&Redactor::new().pattern(r"\d{4}-\d{4}").unwrap());
assert_eq!(
record.get("password"),
Some(&Value::String("hunter2".into()))
);
assert_eq!(record.message().as_deref(), Some("card ********"));
}
#[test]
fn sanitize_shows_controls_in_values_and_continuations() {
let mut record = Record::text("a\x1b[2Jb");
record.continuation.push("\x07".into());
record.sanitize();
assert!(!record.message().unwrap().contains('\x1b'));
assert!(!record.continuation[0].contains('\x07'));
}
#[test]
fn sanitize_shows_controls_in_keys_nested_ones_too() {
let mut record = Record::new("json")
.field("\x1b]52;c;eA==\x07", Value::Integer(1))
.field(
"outer",
Value::Map(vec![(
"in\x1b[2Jner".into(),
Value::List(vec![Value::Map(vec![(
"\u{9b}31m".into(),
Value::String("ok".into()),
)])]),
)]),
);
record.sanitize();
fn controls(value: &Value) -> bool {
match value {
Value::Map(entries) => entries
.iter()
.any(|(key, value)| key.chars().any(char::is_control) || controls(value)),
Value::List(items) => items.iter().any(controls),
Value::String(text) => text.chars().any(char::is_control),
_ => false,
}
}
assert!(controls(&Value::Map(vec![("\x07".into(), Value::Null)])));
assert!(!controls(&Value::Map(record.fields.clone())), "{record:?}");
assert_eq!(record.fields[1].0, "outer");
}
}