Skip to main content

enya_client/logs/
types.rs

1//! Shared types for log query responses.
2
3use rustc_hash::FxHashMap;
4use serde::{Deserialize, Serialize};
5
6/// A single log entry.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct LogEntry {
9    /// Timestamp in nanoseconds since Unix epoch.
10    pub timestamp_ns: i64,
11    /// The log message content.
12    pub message: String,
13    /// Labels/metadata associated with this log line.
14    pub labels: FxHashMap<String, String>,
15    /// Parsed log level, if detectable.
16    pub level: Option<LogLevel>,
17}
18
19/// Log severity level.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum LogLevel {
23    Trace,
24    Debug,
25    Info,
26    Warn,
27    Error,
28}
29
30impl LogLevel {
31    /// Parse a log level from common string formats.
32    ///
33    /// Recognizes: trace, debug, info, warn/warning, error/err, and uppercase variants.
34    #[must_use]
35    pub fn parse(s: &str) -> Option<Self> {
36        match s.to_lowercase().as_str() {
37            "trace" | "trc" => Some(Self::Trace),
38            "debug" | "dbg" => Some(Self::Debug),
39            "info" | "inf" => Some(Self::Info),
40            "warn" | "warning" | "wrn" => Some(Self::Warn),
41            "error" | "err" => Some(Self::Error),
42            _ => None,
43        }
44    }
45
46    /// Try to detect log level from a log message.
47    ///
48    /// Looks for common patterns like `[INFO]`, `level=info`, `INFO:`, etc.
49    #[must_use]
50    pub fn detect_from_message(message: &str) -> Option<Self> {
51        let upper = message.to_uppercase();
52
53        // Check for bracketed format: [INFO], [ERROR], etc.
54        for level in ["TRACE", "DEBUG", "INFO", "WARN", "WARNING", "ERROR", "ERR"] {
55            if upper.contains(&format!("[{level}]")) || upper.contains(&format!("{level}:")) {
56                return Self::parse(level);
57            }
58        }
59
60        // Check for key=value format: level=info, level="error"
61        if let Some(pos) = upper.find("LEVEL=") {
62            let after = &message[pos + 6..];
63            let value: String = after
64                .trim_start_matches('"')
65                .chars()
66                .take_while(|c| c.is_alphabetic())
67                .collect();
68            if let Some(level) = Self::parse(&value) {
69                return Some(level);
70            }
71        }
72
73        None
74    }
75}
76
77/// Direction for log query results.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum QueryDirection {
81    /// Oldest entries first.
82    Forward,
83    /// Newest entries first (default).
84    #[default]
85    Backward,
86}
87
88/// A query for fetching logs.
89#[derive(Debug, Clone)]
90pub struct LogsQuery {
91    /// Backend-specific query string (e.g., LogQL for Loki).
92    /// If None, uses labels and contains for filtering.
93    pub query: Option<String>,
94    /// Filter by these label key-value pairs.
95    pub labels: FxHashMap<String, String>,
96    /// Simple text search within log messages.
97    pub contains: Option<String>,
98    /// Start of time range (nanoseconds since Unix epoch).
99    pub start_ns: i64,
100    /// End of time range (nanoseconds since Unix epoch).
101    pub end_ns: i64,
102    /// Maximum number of entries to return.
103    pub limit: usize,
104    /// Sort direction for results.
105    pub direction: QueryDirection,
106}
107
108impl LogsQuery {
109    /// Create a new logs query for the given time range.
110    #[must_use]
111    pub fn new(start_ns: i64, end_ns: i64) -> Self {
112        Self {
113            query: None,
114            labels: FxHashMap::default(),
115            contains: None,
116            start_ns,
117            end_ns,
118            limit: 1000,
119            direction: QueryDirection::Backward,
120        }
121    }
122
123    /// Set a backend-specific query string.
124    #[must_use]
125    pub fn with_query(mut self, query: impl Into<String>) -> Self {
126        self.query = Some(query.into());
127        self
128    }
129
130    /// Add a label filter.
131    #[must_use]
132    pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
133        self.labels.insert(key.into(), value.into());
134        self
135    }
136
137    /// Set a text search filter.
138    #[must_use]
139    pub fn with_contains(mut self, text: impl Into<String>) -> Self {
140        self.contains = Some(text.into());
141        self
142    }
143
144    /// Set the maximum number of results.
145    #[must_use]
146    pub fn with_limit(mut self, limit: usize) -> Self {
147        self.limit = limit;
148        self
149    }
150
151    /// Set the sort direction.
152    #[must_use]
153    pub fn with_direction(mut self, direction: QueryDirection) -> Self {
154        self.direction = direction;
155        self
156    }
157}
158
159/// Response from a logs query.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct LogsResponse {
162    /// The log entries returned.
163    pub entries: Vec<LogEntry>,
164    /// Number of distinct streams that matched.
165    pub streams_count: usize,
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_log_level_parse() {
174        assert_eq!(LogLevel::parse("info"), Some(LogLevel::Info));
175        assert_eq!(LogLevel::parse("INFO"), Some(LogLevel::Info));
176        assert_eq!(LogLevel::parse("warn"), Some(LogLevel::Warn));
177        assert_eq!(LogLevel::parse("warning"), Some(LogLevel::Warn));
178        assert_eq!(LogLevel::parse("error"), Some(LogLevel::Error));
179        assert_eq!(LogLevel::parse("err"), Some(LogLevel::Error));
180        assert_eq!(LogLevel::parse("unknown"), None);
181    }
182
183    #[test]
184    fn test_log_level_detect_bracketed() {
185        assert_eq!(
186            LogLevel::detect_from_message("[INFO] Starting server"),
187            Some(LogLevel::Info)
188        );
189        assert_eq!(
190            LogLevel::detect_from_message("[ERROR] Connection failed"),
191            Some(LogLevel::Error)
192        );
193        assert_eq!(
194            LogLevel::detect_from_message("[WARN] Deprecated API"),
195            Some(LogLevel::Warn)
196        );
197    }
198
199    #[test]
200    fn test_log_level_detect_colon() {
201        assert_eq!(
202            LogLevel::detect_from_message("INFO: Starting server"),
203            Some(LogLevel::Info)
204        );
205        assert_eq!(
206            LogLevel::detect_from_message("ERROR: Connection failed"),
207            Some(LogLevel::Error)
208        );
209    }
210
211    #[test]
212    fn test_log_level_detect_key_value() {
213        assert_eq!(
214            LogLevel::detect_from_message("level=info msg=\"hello\""),
215            Some(LogLevel::Info)
216        );
217        assert_eq!(
218            LogLevel::detect_from_message("level=\"error\" msg=\"failed\""),
219            Some(LogLevel::Error)
220        );
221    }
222
223    #[test]
224    fn test_logs_query_builder() {
225        let query = LogsQuery::new(1000, 2000)
226            .with_query("{app=\"myservice\"}")
227            .with_label("env", "prod")
228            .with_contains("SELECT")
229            .with_limit(500)
230            .with_direction(QueryDirection::Forward);
231
232        assert_eq!(query.query, Some("{app=\"myservice\"}".to_string()));
233        assert_eq!(query.labels.get("env"), Some(&"prod".to_string()));
234        assert_eq!(query.contains, Some("SELECT".to_string()));
235        assert_eq!(query.limit, 500);
236        assert_eq!(query.direction, QueryDirection::Forward);
237        assert_eq!(query.start_ns, 1000);
238        assert_eq!(query.end_ns, 2000);
239    }
240}