Skip to main content

piquel_log/
store.rs

1use std::sync::Arc;
2
3use parking_lot::RwLock;
4use time::OffsetDateTime;
5
6use crate::{LogLevel, sink::SinkEvent};
7
8/// Thread-safe append-only in-memory log store.
9///
10/// The store keeps all captured entries in memory until it is dropped. It does
11/// not apply retention, eviction, or size limits.
12#[derive(Clone, Debug, Default)]
13pub struct LogStore {
14    inner: Arc<RwLock<Vec<LogEntry>>>,
15}
16
17impl LogStore {
18    /// Create an empty log store.
19    #[must_use]
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Return the number of entries currently stored.
25    #[must_use]
26    pub fn len(&self) -> usize {
27        self.inner.read().len()
28    }
29
30    /// Return `true` when the store contains no entries.
31    #[must_use]
32    pub fn is_empty(&self) -> bool {
33        self.inner.read().is_empty()
34    }
35
36    /// Return a cloned snapshot of all entries in chronological order.
37    #[must_use]
38    pub fn entries(&self) -> Vec<LogEntry> {
39        self.inner.read().clone()
40    }
41
42    /// Return entries matching `filter`.
43    ///
44    /// Results are cloned snapshots and are returned in chronological order.
45    #[must_use]
46    pub fn query(&self, filter: &LogFilter) -> Vec<LogEntry> {
47        let entries = self.inner.read();
48        match filter.limit {
49            Some(0) => Vec::new(),
50            Some(limit) => {
51                let mut recent = Vec::with_capacity(limit.min(entries.len()));
52                for entry in entries.iter().rev() {
53                    if filter.matches(entry) {
54                        recent.push(entry.clone());
55                        if recent.len() == limit {
56                            break;
57                        }
58                    }
59                }
60                recent.reverse();
61                recent
62            }
63            None => entries
64                .iter()
65                .filter(|entry| filter.matches(entry))
66                .cloned()
67                .collect(),
68        }
69    }
70
71    pub(crate) fn push(&self, entry: LogEntry) {
72        self.inner.write().push(entry);
73    }
74}
75
76/// A captured structured log event.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct LogEntry {
79    timestamp: OffsetDateTime,
80    level: LogLevel,
81    target: String,
82    message: String,
83    fields: Vec<LogField>,
84    rendered: String,
85}
86
87impl LogEntry {
88    pub(crate) fn from_sink_event(event: &SinkEvent<'_>) -> Self {
89        Self {
90            timestamp: event.timestamp,
91            level: event.level,
92            target: event.target.to_owned(),
93            message: event.message.to_owned(),
94            fields: event.fields.iter().map(LogField::from).collect(),
95            rendered: event.rendered.to_owned(),
96        }
97    }
98
99    /// Return the timestamp captured for this entry.
100    #[must_use]
101    pub fn timestamp(&self) -> OffsetDateTime {
102        self.timestamp
103    }
104
105    /// Return the entry severity.
106    #[must_use]
107    pub fn level(&self) -> LogLevel {
108        self.level
109    }
110
111    /// Return the entry target.
112    #[must_use]
113    pub fn target(&self) -> &str {
114        &self.target
115    }
116
117    /// Return the entry message.
118    #[must_use]
119    pub fn message(&self) -> &str {
120        &self.message
121    }
122
123    /// Return the structured fields captured for this entry.
124    #[must_use]
125    pub fn fields(&self) -> &[LogField] {
126        &self.fields
127    }
128
129    /// Return the sink-rendered line for this entry.
130    #[must_use]
131    pub fn rendered(&self) -> &str {
132        &self.rendered
133    }
134}
135
136/// A captured structured log field.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct LogField {
139    name: String,
140    value: String,
141}
142
143impl LogField {
144    /// Return the field name.
145    #[must_use]
146    pub fn name(&self) -> &str {
147        &self.name
148    }
149
150    /// Return the string-rendered field value.
151    #[must_use]
152    pub fn value(&self) -> &str {
153        &self.value
154    }
155}
156
157impl From<&crate::format::CapturedField> for LogField {
158    fn from(field: &crate::format::CapturedField) -> Self {
159        Self {
160            name: field.name.clone(),
161            value: field.value.clone(),
162        }
163    }
164}
165
166/// Builder-style filter for querying a [`LogStore`].
167#[derive(Debug, Clone, Default, PartialEq, Eq)]
168pub struct LogFilter {
169    max_level: Option<LogLevel>,
170    target: Option<String>,
171    target_prefix: Option<String>,
172    text: Option<String>,
173    limit: Option<usize>,
174}
175
176impl LogFilter {
177    /// Create an empty filter that matches all entries.
178    #[must_use]
179    pub fn new() -> Self {
180        Self::default()
181    }
182
183    /// Match entries at or above the provided severity threshold.
184    #[must_use]
185    pub fn with_max_level(mut self, level: LogLevel) -> Self {
186        self.max_level = Some(level);
187        self
188    }
189
190    /// Match entries with exactly this target.
191    #[must_use]
192    pub fn with_target(mut self, target: impl Into<String>) -> Self {
193        self.target = Some(target.into());
194        self
195    }
196
197    /// Match entries whose target starts with this prefix.
198    #[must_use]
199    pub fn with_target_prefix(mut self, prefix: impl Into<String>) -> Self {
200        self.target_prefix = Some(prefix.into());
201        self
202    }
203
204    /// Match entries containing this case-sensitive text.
205    ///
206    /// The search checks messages, field names, field values, and rendered
207    /// lines.
208    #[must_use]
209    pub fn containing_text(mut self, text: impl Into<String>) -> Self {
210        self.text = Some(text.into());
211        self
212    }
213
214    /// Keep only the most recent `limit` matches.
215    #[must_use]
216    pub fn with_limit(mut self, limit: usize) -> Self {
217        self.limit = Some(limit);
218        self
219    }
220
221    fn matches(&self, entry: &LogEntry) -> bool {
222        if self
223            .max_level
224            .is_some_and(|max_level| entry.level > max_level)
225        {
226            return false;
227        }
228
229        if self
230            .target
231            .as_ref()
232            .is_some_and(|target| entry.target != *target)
233        {
234            return false;
235        }
236
237        if self
238            .target_prefix
239            .as_ref()
240            .is_some_and(|prefix| !entry.target.starts_with(prefix))
241        {
242            return false;
243        }
244
245        if self
246            .text
247            .as_ref()
248            .is_some_and(|text| !entry_contains_text(entry, text))
249        {
250            return false;
251        }
252
253        true
254    }
255}
256
257fn entry_contains_text(entry: &LogEntry, text: &str) -> bool {
258    entry.message.contains(text)
259        || entry.rendered.contains(text)
260        || entry
261            .fields
262            .iter()
263            .any(|field| field.name.contains(text) || field.value.contains(text))
264}