Skip to main content

ignition_core/client/
logs.rs

1//! Log capability models (02-04, HLTH-03/04) — log entries, the query
2//! (with the tail cursor), the logger registry, and the archive
3//! download. Field names match the live 8.3.6 captures (02-RESEARCH
4//! §Logs + loggers) and the gateway's openapi schema; every model rides
5//! `#[serde(flatten)] extra` passthrough so `--json` stays complete as
6//! gateway responses evolve.
7//!
8//! THREE wire facts are pinned here (all live-verified):
9//! - `startTime` (epoch ms) IS the tail cursor: only entries with
10//!   `timestamp >= startTime` return, and there is NO server push —
11//!   polling this query is the tail primitive (Don't-Hand-Roll table).
12//! - `logs/download` answers a SQLite database
13//!   (`application/x-sqlite3`, filename from `Content-Disposition`) —
14//!   NOT a zip; the bytes ship exactly as received (Pitfall 7).
15//! - An UNSET `limit` means the server's UNLIMITED default (metadata
16//!   showed `limit: -1` with everything returned) — every request this
17//!   CLI sends carries an EXPLICIT limit ([`DEFAULT_LOG_LIMIT`] = 200;
18//!   Pitfall 9: a 2M-entry gateway log must not flood agents).
19//!
20//! Logger names are Java identifiers (`[A-Za-z0-9._]` — openapi), so
21//! they embed URL-safe in the set-level path as-is; documented rather
22//! than percent-encoded.
23
24use std::collections::BTreeMap;
25
26use serde::{Deserialize, Serialize};
27
28/// GET path of the log query — the tail primitive.
29pub(crate) const LOGS_PATH: &str = "/data/api/v1/logs";
30
31/// GET path of the archive download (SQLite `.idb` bytes).
32pub(crate) const LOGS_DOWNLOAD_PATH: &str = "/data/api/v1/logs/download";
33
34/// GET path of the logger registry (~1250 loggers on a fresh gateway).
35pub(crate) const LOGGERS_PATH: &str = "/data/api/v1/logs/loggers";
36
37/// POST path that resets all custom logger levels to defaults.
38pub(crate) const LEVEL_RESET_PATH: &str = "/data/api/v1/logs/levelreset";
39
40/// POST path of the set-level route (`?level=X` query param).
41pub(crate) fn logger_set_path(logger: &str) -> String {
42    format!("/data/api/v1/logs/loggers/{logger}")
43}
44
45/// The explicit limit every logs request carries (Pitfall 9) — the
46/// server default is UNLIMITED and a 2M-entry gateway log would flood
47/// agents and terminals alike.
48pub const DEFAULT_LOG_LIMIT: i64 = 200;
49
50/// One item of `GET /data/api/v1/logs` — the shape of the live capture
51/// (camelCase keys, serde-renamed). `timestamp` is epoch **MILLISECONDS**
52/// and doubles as the tail cursor.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct LogEntry {
55    /// Epoch **MILLISECONDS** — the tail cursor (`startTime` param).
56    pub timestamp: i64,
57    /// Logger name (`loggerName` on the wire), e.g.
58    /// `"GatewayManager"` or `"Common.BasicExecutionEngine.Thread$"`.
59    #[serde(rename = "loggerName", alias = "logger_name")]
60    pub logger_name: String,
61    /// `"TRACE"` / `"DEBUG"` / `"INFO"` / `"WARN"` / `"ERROR"` /
62    /// `"FATAL"` (the wire keeps them uppercase).
63    #[serde(default)]
64    pub level: String,
65    /// The rendered log message.
66    #[serde(default)]
67    pub message: String,
68    /// Stack-trace lines when the entry carries a throwable (absent on
69    /// the wire for plain entries — `default` + skip keeps output clean).
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub stack: Vec<String>,
72    /// Mapped diagnostic context, when present.
73    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
74    pub mdc: serde_json::Map<String, serde_json::Value>,
75    /// Unknown keys round-trip (passthrough-shaped `--json`).
76    #[serde(flatten)]
77    pub extra: BTreeMap<String, serde_json::Value>,
78}
79
80/// The logs query — every param optional server-side, but `limit` is
81/// ALWAYS sent explicitly by this CLI (Pitfall 9). `start_time` is the
82/// tail cursor; `end_time` bounds historical windows; `sort_by` uses
83/// the gateway's own `asc(field)` / `desc(field)` syntax (openapi).
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct LogQuery {
86    /// Include results from this epoch-ms timestamp (the tail cursor).
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub start_time: Option<i64>,
89    /// Include results up to this epoch-ms timestamp.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub end_time: Option<i64>,
92    /// Only entries of `min_level` OR HIGHER
93    /// (`minLevel` on the wire; TRACE..OFF, server-side filtering).
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub min_level: Option<String>,
96    /// Filter to one logger name prefix (`logger` on the wire).
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub logger: Option<String>,
99    /// Max items — ALWAYS explicit ([`DEFAULT_LOG_LIMIT`]); `-1` would
100    /// mean the server's unlimited default (Pitfall 9).
101    pub limit: i64,
102    /// Skip the first `offset` items.
103    pub offset: i64,
104    /// Server-side sort: `asc(fieldName)` / `desc(fieldName)`.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub sort_by: Option<String>,
107}
108
109impl Default for LogQuery {
110    fn default() -> Self {
111        Self {
112            start_time: None,
113            end_time: None,
114            min_level: None,
115            logger: None,
116            limit: DEFAULT_LOG_LIMIT,
117            offset: 0,
118            sort_by: None,
119        }
120    }
121}
122
123impl LogQuery {
124    /// Serialize into query pairs under the gateway-native param names;
125    /// `limit`/`offset` always present, optional keys only when `Some`.
126    pub fn to_query_pairs(&self) -> Vec<(String, String)> {
127        let mut pairs = Vec::with_capacity(7);
128        if let Some(start_time) = self.start_time {
129            pairs.push(("startTime".to_string(), start_time.to_string()));
130        }
131        if let Some(end_time) = self.end_time {
132            pairs.push(("endTime".to_string(), end_time.to_string()));
133        }
134        if let Some(min_level) = &self.min_level {
135            pairs.push(("minLevel".to_string(), min_level.clone()));
136        }
137        if let Some(logger) = &self.logger {
138            pairs.push(("logger".to_string(), logger.clone()));
139        }
140        pairs.push(("limit".to_string(), self.limit.to_string()));
141        pairs.push(("offset".to_string(), self.offset.to_string()));
142        if let Some(sort_by) = &self.sort_by {
143            pairs.push(("sortBy".to_string(), sort_by.clone()));
144        }
145        pairs
146    }
147}
148
149/// A page of log entries in the standard list envelope.
150pub type LogPage = crate::client::query::ListEnvelope<LogEntry>;
151
152/// One item of `GET /data/api/v1/logs/loggers` — `{name, level,
153/// context}`; `level` is `None` for inherited loggers and `context` is
154/// modeled as passthrough (its populated shape was not captured).
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct LoggerInfo {
157    /// Logger name, e.g. `"Common.BasicExecutionEngine.Thread$"`.
158    pub name: String,
159    /// Explicit level, when the logger carries one.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub level: Option<String>,
162    /// Context block, passthrough (shape not live-captured).
163    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
164    pub context: serde_json::Value,
165    /// Unknown keys round-trip.
166    #[serde(flatten)]
167    pub extra: BTreeMap<String, serde_json::Value>,
168}
169
170/// The archive download result — raw SQLite bytes EXACTLY as received
171/// (never zipped, never extracted) plus the response metadata the CLI
172/// needs for naming. Not serialized into envelopes (the bytes are the
173/// artifact; the command output model lives in the actions layer).
174#[derive(Debug, Clone)]
175pub struct LogDownload {
176    /// The `.idb` payload, byte-for-byte as the gateway sent it.
177    pub bytes: Vec<u8>,
178    /// Filename from `Content-Disposition`, when the header carries one
179    /// (the live gateway always sends `<Gateway>_Ignition_logs_<ts>.idb`).
180    pub filename: Option<String>,
181    /// Response `Content-Type` — `application/x-sqlite3` (verified).
182    pub content_type: Option<String>,
183}
184
185/// Extract the filename from a `Content-Disposition` header value —
186/// supports both the classic `filename="..."` (quoted or bare) and
187/// RFC 5987 `filename*=UTF-8''...` forms via substring scan (a header
188/// is not HTML; 20 lines beat a MIME crate).
189pub fn filename_from_content_disposition(value: &str) -> Option<String> {
190    // RFC 5987 extended form wins when present.
191    if let Some(start) = value.find("filename*=") {
192        let rest = &value[start + "filename*=".len()..];
193        // charset''name — split twice at most; the name runs to ; or end.
194        let name = rest.split(';').next().unwrap_or(rest);
195        let decoded = match name.split_once('\'') {
196            Some((_, after_charset)) => after_charset
197                .split_once('\'')
198                .map(|(_, raw)| raw)
199                .unwrap_or(name),
200            None => name,
201        };
202        if !decoded.is_empty() {
203            return Some(decoded.to_string());
204        }
205    }
206    let start = value.find("filename=")? + "filename=".len();
207    let rest = value[start..].split(';').next().unwrap_or(&value[start..]);
208    let trimmed = rest.trim().trim_matches('"');
209    (!trimmed.is_empty()).then(|| trimmed.to_string())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::{DEFAULT_LOG_LIMIT, LogEntry, LogQuery, filename_from_content_disposition};
215
216    /// The live-captured entry shape parses: camelCase renames, a stack
217    /// trace entry, an MDC map — and a plain entry (no stack/mdc).
218    #[test]
219    fn log_entry_parses_the_live_capture_incl_stack() {
220        let entry: LogEntry = serde_json::from_value(serde_json::json!({
221            "timestamp": 1787346747022i64,
222            "loggerName": "Common.BasicExecutionEngine.Thread$",
223            "level": "ERROR",
224            "message": "Execution halted by exception",
225            "stack": [
226                "java.lang.RuntimeException: boom",
227                "\tat com.inductiveautomation.ignition.common.Sample.run(Sample.java:42)"
228            ],
229            "mdc": {"thread": "Thread-12"}
230        }))
231        .expect("live capture shape must parse");
232        assert_eq!(entry.timestamp, 1787346747022, "epoch ms — the tail cursor");
233        assert_eq!(entry.logger_name, "Common.BasicExecutionEngine.Thread$");
234        assert_eq!(entry.level, "ERROR");
235        assert_eq!(entry.stack.len(), 2, "stack trace lines parse");
236        assert_eq!(entry.mdc["thread"], "Thread-12");
237
238        // Wire-faithful round-trip: gateway-native keys on the way out,
239        // empty stack/mdc omitted (as the wire omits them).
240        let round = serde_json::to_value(&entry).expect("serialize");
241        assert_eq!(round["loggerName"], "Common.BasicExecutionEngine.Thread$");
242        assert_eq!(round["stack"].as_array().unwrap().len(), 2);
243
244        let plain: LogEntry = serde_json::from_value(serde_json::json!({
245            "timestamp": 1787346747030i64,
246            "loggerName": "GatewayManager",
247            "level": "INFO",
248            "message": "Gateway started"
249        }))
250        .expect("plain entry (no stack, no mdc) must parse");
251        assert!(plain.stack.is_empty());
252        assert!(plain.mdc.is_empty());
253        let round = serde_json::to_value(&plain).expect("serialize");
254        assert!(
255            round.get("stack").is_none() && round.get("mdc").is_none(),
256            "empty stack/mdc stay absent on the way out"
257        );
258    }
259
260    /// Pitfall 9: the default query carries an EXPLICIT limit (200) and
261    /// offset 0; every optional key serializes under its gateway-native
262    /// name only when present.
263    #[test]
264    fn query_pairs_carry_explicit_limit_and_native_names() {
265        let pairs = LogQuery::default().to_query_pairs();
266        assert_eq!(
267            pairs,
268            vec![
269                ("limit".to_string(), DEFAULT_LOG_LIMIT.to_string()),
270                ("offset".to_string(), "0".to_string()),
271            ],
272            "default = explicit limit 200 + offset 0 (Pitfall 9)"
273        );
274
275        let full = LogQuery {
276            start_time: Some(1787346747022),
277            end_time: Some(1787346757022),
278            min_level: Some("INFO".into()),
279            logger: Some("GatewayManager".into()),
280            limit: 50,
281            offset: 100,
282            sort_by: Some("desc(timestamp)".into()),
283        };
284        let pairs = full.to_query_pairs();
285        assert_eq!(
286            pairs,
287            vec![
288                ("startTime".to_string(), "1787346747022".to_string()),
289                ("endTime".to_string(), "1787346757022".to_string()),
290                ("minLevel".to_string(), "INFO".to_string()),
291                ("logger".to_string(), "GatewayManager".to_string()),
292                ("limit".to_string(), "50".to_string()),
293                ("offset".to_string(), "100".to_string()),
294                ("sortBy".to_string(), "desc(timestamp)".to_string()),
295            ],
296            "camelCase param names, declared order"
297        );
298    }
299
300    /// The verified header shape yields the `.idb` filename; quoted,
301    /// bare, RFC 5987, and absent forms all behave.
302    #[test]
303    fn content_disposition_filename_extraction() {
304        assert_eq!(
305            filename_from_content_disposition(
306                "attachment; filename=MyGateway_Ignition_logs_20260822-0307.idb"
307            ),
308            Some("MyGateway_Ignition_logs_20260822-0307.idb".to_string())
309        );
310        assert_eq!(
311            filename_from_content_disposition("attachment; filename=\"quoted.idb\""),
312            Some("quoted.idb".to_string())
313        );
314        assert_eq!(
315            filename_from_content_disposition("attachment; filename*=UTF-8''encoded.idb"),
316            Some("encoded.idb".to_string())
317        );
318        assert_eq!(filename_from_content_disposition("attachment"), None);
319        assert_eq!(filename_from_content_disposition("inline"), None);
320    }
321}