symbolic_unreal/
logs.rs

1use anylog::LogEntry;
2use chrono::{DateTime, Utc};
3use lazy_static::lazy_static;
4use regex::Regex;
5#[cfg(any(test, feature = "serde"))]
6use time::format_description::well_known::Rfc3339;
7
8use crate::error::Unreal4Error;
9use crate::Unreal4ErrorKind;
10
11#[cfg(test)]
12use similar_asserts::assert_eq;
13
14lazy_static! {
15    /// https://github.com/EpicGames/UnrealEngine/blob/f509bb2d6c62806882d9a10476f3654cf1ee0634/Engine/Source/Runtime/Core/Private/GenericPlatform/GenericPlatformTime.cpp#L79-L93
16    /// Note: Date is always in US format (dd/MM/yyyy) and time is local
17    /// Example: Log file open, 12/13/18 15:54:53
18    static ref LOG_FIRST_LINE: Regex = Regex::new(r"Log file open, (?P<month>\d\d)/(?P<day>\d\d)/(?P<year>\d\d) (?P<hour>\d\d):(?P<minute>\d\d):(?P<second>\d\d)$").unwrap();
19}
20
21#[cfg(feature = "serde")]
22fn serialize_timestamp<S: serde::Serializer>(
23    timestamp: &Option<time::OffsetDateTime>,
24    serializer: S,
25) -> Result<S::Ok, S::Error> {
26    use serde::ser::Error;
27    match timestamp {
28        Some(timestamp) => serializer.serialize_str(&match timestamp.format(&Rfc3339) {
29            Ok(s) => s,
30            Err(_) => return Err(S::Error::custom("failed formatting `OffsetDateTime`")),
31        }),
32        None => serializer.serialize_none(),
33    }
34}
35
36/// A log entry from an Unreal Engine 4 crash.
37#[cfg_attr(feature = "serde", derive(serde::Serialize))]
38pub struct Unreal4LogEntry {
39    /// The timestamp of the message, when available.
40    #[cfg_attr(
41        feature = "serde",
42        serde(
43            skip_serializing_if = "Option::is_none",
44            serialize_with = "serialize_timestamp"
45        )
46    )]
47    pub timestamp: Option<time::OffsetDateTime>,
48
49    /// The component that issued the log, when available.
50    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
51    pub component: Option<String>,
52
53    /// The log message.
54    pub message: String,
55}
56
57fn parse_log_datetime(text: &str) -> Option<time::OffsetDateTime> {
58    let captures = LOG_FIRST_LINE.captures(text)?;
59
60    // https://github.com/EpicGames/UnrealEngine/blob/f7626ddd147fe20a6144b521a26739c863546f4a/Engine/Source/Runtime/Core/Private/GenericPlatform/GenericPlatformTime.cpp#L46
61    let month = time::Month::try_from(captures["month"].parse::<u8>().ok()?).ok()?;
62    let date = time::Date::from_calendar_date(
63        captures["year"].parse::<i32>().ok()? + 2000,
64        month,
65        captures["day"].parse::<u8>().ok()?,
66    )
67    .ok()?;
68
69    let datetime = date
70        .with_hms(
71            captures["hour"].parse::<u8>().ok()?,
72            captures["minute"].parse::<u8>().ok()?,
73            captures["second"].parse::<u8>().ok()?,
74        )
75        .ok()?;
76
77    // Using UTC but this entry is local time. Unfortunately there's no way to find the offset.
78    Some(datetime.assume_utc())
79}
80
81fn convert_chrono(dt: DateTime<Utc>) -> Option<time::OffsetDateTime> {
82    time::OffsetDateTime::from_unix_timestamp(dt.timestamp()).ok()
83}
84
85impl Unreal4LogEntry {
86    /// Tries to parse a blob normally coming from a logs file inside an Unreal4Crash into
87    /// a vector of Unreal4LogEntry.
88    pub fn parse(log_slice: &[u8], limit: usize) -> Result<Vec<Self>, Unreal4Error> {
89        let mut fallback_timestamp = None;
90        let logs_utf8 = std::str::from_utf8(log_slice)
91            .map_err(|e| Unreal4Error::new(Unreal4ErrorKind::InvalidLogEntry, e))?;
92
93        if let Some(first_line) = logs_utf8.lines().next() {
94            // First line includes the timestamp of the following 100 and some lines until
95            // log entries actually include timestamps
96            fallback_timestamp = parse_log_datetime(first_line);
97        }
98
99        let mut logs: Vec<_> = logs_utf8
100            .lines()
101            .rev()
102            .take(limit + 1) // read one more that we need, will be dropped at the end
103            .map(|line| {
104                let entry = LogEntry::parse(line.as_bytes());
105                let (component, message) = entry.component_and_message();
106                // Reads in reverse where logs include timestamp. If it never reached the point of
107                // adding timestamp to log entries, the first record's timestamp (local time, above)
108                // will be used on all records.
109                fallback_timestamp = entry
110                    .utc_timestamp()
111                    .and_then(convert_chrono)
112                    .or(fallback_timestamp);
113
114                Unreal4LogEntry {
115                    timestamp: fallback_timestamp,
116                    component: component.map(Into::into),
117                    message: message.into(),
118                }
119            })
120            .collect();
121
122        // drops either the first line in the file, which is the file header and therefore
123        // not a valid log, or the (limit+1)-th entry from the bottom which we are not
124        // interested in (since we only want (limit) entries).
125        logs.pop();
126        logs.reverse();
127        Ok(logs)
128    }
129}
130
131#[test]
132fn test_parse_logs_no_entries_with_timestamp() {
133    let log_bytes = br"Log file open, 12/13/18 15:54:53
134LogWindows: Failed to load 'aqProf.dll' (GetLastError=126)
135LogWindows: File 'aqProf.dll' does not exist";
136
137    let logs = Unreal4LogEntry::parse(log_bytes, 1000).expect("logs");
138
139    assert_eq!(logs.len(), 2);
140    assert_eq!(logs[1].component.as_ref().expect("component"), "LogWindows");
141    assert_eq!(
142        logs[1]
143            .timestamp
144            .expect("timestamp")
145            .format(&Rfc3339)
146            .unwrap(),
147        "2018-12-13T15:54:53Z"
148    );
149    assert_eq!(logs[1].message, "File 'aqProf.dll' does not exist");
150}
151
152#[test]
153fn test_parse_logs_invalid_time() {
154    let log_bytes = br"Log file open, 12/13/18 99:99:99
155LogWindows: Failed to load 'aqProf.dll' (GetLastError=126)
156LogWindows: File 'aqProf.dll' does not exist";
157
158    let logs = Unreal4LogEntry::parse(log_bytes, 1000).expect("logs");
159
160    assert_eq!(logs.len(), 2);
161    assert_eq!(logs[1].timestamp, None);
162}