├── .gitignore ├── .craft.yml ├── src ├── lib.rs ├── snapshots │ └── anylog__types__common_log_entry.snap ├── types.rs └── parser.rs ├── README.md ├── scripts └── bump-version.sh ├── Cargo.toml ├── .github └── workflows │ ├── release.yml │ └── ci.yml └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | **/*.rs.bk 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /.craft.yml: -------------------------------------------------------------------------------- 1 | minVersion: 0.34.1 2 | github: 3 | owner: getsentry 4 | repo: rust-anylog 5 | changelogPolicy: none 6 | artifactProvider: 7 | name: none 8 | targets: 9 | - name: github 10 | - name: crates 11 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `anylog` is a crate that tries to parse any potential log message it 2 | //! would encounter and extract timestamp and message from it. It supports a 3 | //! wide range of formats and tries them all. 4 | //! 5 | //! This crate is used by [Sentry](https://sentry.io/) to parse logfiles into 6 | //! breadcrumbs. 7 | 8 | mod parser; 9 | mod types; 10 | 11 | pub use crate::types::LogEntry; 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rust-anylog 2 | 3 | A simple rust library that parses log lines into log records. This supports a range 4 | of common log formats and parses out the timestamp and rest of the line. 5 | 6 | [Documentation](https://docs.rs/anylog) 7 | 8 | ## Tests 9 | 10 | Tests require the timezone to be set to "CEST". The easiest way to do this is by 11 | exporting the `TZ` environment variable: 12 | 13 | ```bash 14 | TZ=CET cargo test 15 | ``` 16 | -------------------------------------------------------------------------------- /src/snapshots/anylog__types__common_log_entry.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/types.rs 3 | expression: "LogEntry::parse(b\"2015-05-13 17:39:16 +0200: Repaired 'Library/Printers/Canon/IJScanner/Resources/Parameters/CNQ9601'\")" 4 | --- 5 | LogEntry { 6 | timestamp: Some( 7 | Fixed( 8 | 2015-05-13T17:39:16+02:00, 9 | ), 10 | ), 11 | message: "Repaired 'Library/Printers/Canon/IJScanner/Resources/Parameters/CNQ9601'", 12 | } 13 | -------------------------------------------------------------------------------- /scripts/bump-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | cd $SCRIPT_DIR/.. 6 | 7 | OLD_VERSION="${1}" 8 | NEW_VERSION="${2}" 9 | 10 | echo "Current version: ${OLD_VERSION}" 11 | echo "Bumping version: ${NEW_VERSION}" 12 | 13 | function replace() { 14 | ! grep "$2" $3 15 | perl -i -pe "s/$1/$2/g" $3 16 | grep "$2" $3 # verify that replacement was successful 17 | } 18 | 19 | replace "^version = \".*?\"" "version = \"$NEW_VERSION\"" Cargo.toml 20 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Armin Ronacher "] 3 | name = "anylog" 4 | version = "0.6.4" 5 | keywords = ["log", "parse"] 6 | description = "A library for Rust that attempts to parse single log lines into records." 7 | homepage = "https://github.com/mitsuhiko/rust-anylog" 8 | documentation = "https://docs.rs/anylog" 9 | license = "BSD-3-Clause" 10 | readme = "README.md" 11 | edition = "2018" 12 | 13 | [dependencies] 14 | chrono = { version = "0.4.10", default-features = false, features = ["clock", "std"] } 15 | lazy_static = "1.4.0" 16 | regex = { version = "1.3.3", default-features = false, features = ["std"] } 17 | 18 | [dev-dependencies] 19 | insta = "1.21.0" 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: Version to release 8 | required: true 9 | force: 10 | description: Force a release even when there are release-blockers (optional) 11 | required: false 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | name: "Release a new version" 17 | steps: 18 | - name: Get auth token 19 | id: token 20 | uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 21 | with: 22 | app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} 23 | private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} 24 | - uses: actions/checkout@v3 25 | with: 26 | token: ${{ steps.token.outputs.token }} 27 | fetch-depth: 0 28 | 29 | - name: Prepare release 30 | uses: getsentry/action-prepare-release@v1 31 | env: 32 | GITHUB_TOKEN: ${{ steps.token.outputs.token }} 33 | with: 34 | version: ${{ github.event.inputs.version }} 35 | force: ${{ github.event.inputs.force }} 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - release/** 8 | 9 | pull_request: 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | jobs: 15 | lint: 16 | name: Lint 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Install Rust Toolchain 23 | run: rustup toolchain install stable --profile minimal --component clippy rustfmt --no-self-update 24 | 25 | - name: Run Rustfmt 26 | run: cargo fmt --all -- --check 27 | 28 | - name: Run Clippy 29 | run: cargo clippy --workspace --all-targets --all-features --no-deps -- -D warnings 30 | 31 | - name: Check Docs 32 | run: cargo doc --workspace --all-features --no-deps 33 | env: 34 | RUSTDOCFLAGS: -Dwarnings 35 | 36 | test: 37 | name: Test 38 | runs-on: ubuntu-latest 39 | 40 | steps: 41 | - uses: actions/checkout@v3 42 | 43 | - name: Install Rust Toolchain 44 | run: rustup toolchain install stable --profile minimal --no-self-update 45 | 46 | - name: Run Cargo Tests 47 | run: cargo test --workspace --all-features 48 | env: 49 | TZ: CET 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 by Armin Ronacher. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 24 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::fmt; 3 | 4 | use chrono::prelude::*; 5 | use lazy_static::lazy_static; 6 | use regex::Regex; 7 | 8 | use crate::parser; 9 | 10 | lazy_static! { 11 | static ref COMPONENT_RE: Regex = Regex::new(r#"^([^:]+): ?(.*)$"#).unwrap(); 12 | } 13 | 14 | #[derive(Debug)] 15 | pub enum Timestamp { 16 | Utc(DateTime), 17 | Local(DateTime), 18 | Fixed(DateTime), 19 | } 20 | 21 | impl Timestamp { 22 | pub fn to_utc(&self) -> DateTime { 23 | match *self { 24 | Timestamp::Utc(utc) => utc, 25 | Timestamp::Local(local) => local.with_timezone(&Utc), 26 | Timestamp::Fixed(fixed) => fixed.with_timezone(&Utc), 27 | } 28 | } 29 | 30 | pub fn to_local(&self) -> DateTime { 31 | match *self { 32 | Timestamp::Utc(utc) => utc.with_timezone(&Local), 33 | Timestamp::Local(local) => local, 34 | Timestamp::Fixed(fixed) => fixed.with_timezone(&Local), 35 | } 36 | } 37 | } 38 | 39 | /// Represents a parsed log entry. 40 | pub struct LogEntry<'a> { 41 | timestamp: Option, 42 | message: Cow<'a, str>, 43 | } 44 | 45 | impl<'a> fmt::Debug for LogEntry<'a> { 46 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 47 | f.debug_struct("LogEntry") 48 | .field("timestamp", &self.timestamp) 49 | .field("message", &self.message()) 50 | .finish() 51 | } 52 | } 53 | 54 | impl<'a> LogEntry<'a> { 55 | /// Parses a well known log line into a log entry. 56 | pub fn parse(bytes: &[u8]) -> LogEntry { 57 | parser::parse_log_entry(bytes, None).unwrap_or_else(|| LogEntry::from_message_only(bytes)) 58 | } 59 | 60 | /// Similar to `parse` but uses the given timezone for local time. 61 | pub fn parse_with_local_timezone(bytes: &[u8], offset: Option) -> LogEntry { 62 | parser::parse_log_entry(bytes, offset).unwrap_or_else(|| LogEntry::from_message_only(bytes)) 63 | } 64 | 65 | /// Constructs a log entry from a UTC timestamp and message. 66 | pub fn from_utc_time(ts: DateTime, message: &'a [u8]) -> LogEntry<'a> { 67 | LogEntry { 68 | timestamp: Some(Timestamp::Utc(ts)), 69 | message: String::from_utf8_lossy(message), 70 | } 71 | } 72 | 73 | /// Constructs a log entry from a local timestamp and message. 74 | pub fn from_local_time(ts: DateTime, message: &'a [u8]) -> LogEntry<'a> { 75 | LogEntry { 76 | timestamp: Some(Timestamp::Local(ts)), 77 | message: String::from_utf8_lossy(message), 78 | } 79 | } 80 | 81 | /// Constructs a log entry from a timestamp in a specific timezone and message. 82 | pub fn from_fixed_time(ts: DateTime, message: &'a [u8]) -> LogEntry<'a> { 83 | LogEntry { 84 | timestamp: Some(Timestamp::Fixed(ts)), 85 | message: String::from_utf8_lossy(message), 86 | } 87 | } 88 | 89 | /// Creates a log entry from only a message. 90 | pub fn from_message_only(message: &'a [u8]) -> LogEntry<'a> { 91 | LogEntry { 92 | timestamp: None, 93 | message: String::from_utf8_lossy(message), 94 | } 95 | } 96 | 97 | /// Returns the timestamp in local timezone. 98 | pub fn local_timestamp(&self) -> Option> { 99 | self.timestamp.as_ref().map(|x| x.to_local()) 100 | } 101 | 102 | /// Returns the timestamp in UTC timezone. 103 | pub fn utc_timestamp(&self) -> Option> { 104 | self.timestamp.as_ref().map(|x| x.to_utc()) 105 | } 106 | 107 | /// Returns the message. 108 | pub fn message(&'a self) -> &str { 109 | &self.message 110 | } 111 | 112 | /// Like `message` but chops off a leading component. 113 | pub fn component_and_message(&'a self) -> (Option<&str>, &str) { 114 | if let Some(caps) = COMPONENT_RE.captures(self.message()) { 115 | ( 116 | Some(caps.get(1).unwrap().as_str()), 117 | caps.get(2).unwrap().as_str(), 118 | ) 119 | } else { 120 | (None, self.message()) 121 | } 122 | } 123 | } 124 | 125 | #[cfg(test)] 126 | use insta::assert_debug_snapshot; 127 | 128 | #[test] 129 | fn test_parse_c_log_entry() { 130 | assert_debug_snapshot!( 131 | LogEntry::parse(b"Tue Nov 21 00:30:05 2017 More stuff here"), 132 | @r###" 133 | LogEntry { 134 | timestamp: Some( 135 | Local( 136 | 2017-11-21T00:30:05+01:00, 137 | ), 138 | ), 139 | message: "More stuff here", 140 | } 141 | "### 142 | ); 143 | } 144 | 145 | #[test] 146 | fn test_parse_short_log_entry() { 147 | assert_debug_snapshot!( 148 | LogEntry::parse(b"Nov 20 21:56:01 herzog com.apple.xpc.launchd[1] (com.apple.preference.displays.MirrorDisplays): Service only ran for 0 seconds. Pushing respawn out by 10 seconds."), 149 | @r###" 150 | LogEntry { 151 | timestamp: Some( 152 | Local( 153 | 2017-11-20T21:56:01+01:00, 154 | ), 155 | ), 156 | message: "herzog com.apple.xpc.launchd[1] (com.apple.preference.displays.MirrorDisplays): Service only ran for 0 seconds. Pushing respawn out by 10 seconds.", 157 | } 158 | "### 159 | ); 160 | } 161 | 162 | #[test] 163 | fn test_parse_short_log_entry_extra() { 164 | assert_debug_snapshot!( 165 | LogEntry::parse( 166 | b"Mon Nov 20 00:31:19.005 en0: Received EAPOL packet (length = 161)", 167 | ), 168 | @r###" 169 | LogEntry { 170 | timestamp: Some( 171 | Local( 172 | 2017-11-20T00:31:19+01:00, 173 | ), 174 | ), 175 | message: " en0: Received EAPOL packet (length = 161)", 176 | } 177 | "### 178 | ); 179 | } 180 | 181 | #[test] 182 | fn test_parse_simple_log_entry() { 183 | assert_debug_snapshot!( 184 | LogEntry::parse( 185 | b"22:07:10 server | detected binary path: /Users/mitsuhiko/.virtualenvs/sentry/bin/uwsgi", 186 | ), 187 | @r###" 188 | LogEntry { 189 | timestamp: Some( 190 | Local( 191 | 2017-01-01T22:07:10+01:00, 192 | ), 193 | ), 194 | message: "server | detected binary path: /Users/mitsuhiko/.virtualenvs/sentry/bin/uwsgi", 195 | } 196 | "### 197 | ); 198 | } 199 | 200 | #[test] 201 | fn test_parse_common_log_entry() { 202 | assert_debug_snapshot!( 203 | "common_log_entry", 204 | LogEntry::parse(b"2015-05-13 17:39:16 +0200: Repaired 'Library/Printers/Canon/IJScanner/Resources/Parameters/CNQ9601'") 205 | ); 206 | } 207 | 208 | #[test] 209 | fn test_parse_common_alt_log_entry() { 210 | assert_debug_snapshot!( 211 | LogEntry::parse( 212 | b"Mon Oct 5 11:40:10 2015 [INFO] PDApp.ExternalGateway - NativePlatformHandler destructed", 213 | ), 214 | @r###" 215 | LogEntry { 216 | timestamp: Some( 217 | Local( 218 | 2015-10-05T11:40:10+02:00, 219 | ), 220 | ), 221 | message: "[INFO] PDApp.ExternalGateway - NativePlatformHandler destructed", 222 | } 223 | "### 224 | ); 225 | } 226 | 227 | #[test] 228 | fn test_parse_common_alt2_log_entry() { 229 | assert_debug_snapshot!( 230 | LogEntry::parse(b"Jan 03, 2016 22:29:55 [0x70000073b000] DEBUG - Responding HTTP/1.1 200"), 231 | @r###" 232 | LogEntry { 233 | timestamp: Some( 234 | Local( 235 | 2016-01-03T22:29:55+01:00, 236 | ), 237 | ), 238 | message: "[0x70000073b000] DEBUG - Responding HTTP/1.1 200", 239 | } 240 | "### 241 | ); 242 | } 243 | 244 | #[test] 245 | fn test_parse_unreal_log_entry() { 246 | assert_debug_snapshot!( 247 | LogEntry::parse( 248 | b"[2018.10.29-16.56.37:542][ 0]LogInit: Selected Device Profile: [WindowsNoEditor]", 249 | ), 250 | @r###" 251 | LogEntry { 252 | timestamp: Some( 253 | Utc( 254 | 2018-10-29T16:56:37Z, 255 | ), 256 | ), 257 | message: "LogInit: Selected Device Profile: [WindowsNoEditor]", 258 | } 259 | "### 260 | ); 261 | } 262 | 263 | #[test] 264 | fn test_parse_unreal_log_entry_no_timestamp() { 265 | assert_debug_snapshot!( 266 | LogEntry::parse( 267 | b"LogDevObjectVersion: Dev-Enterprise (9DFFBCD6-494F-0158-E221-12823C92A888): 1", 268 | ), 269 | @r###" 270 | LogEntry { 271 | timestamp: None, 272 | message: "LogDevObjectVersion: Dev-Enterprise (9DFFBCD6-494F-0158-E221-12823C92A888): 1", 273 | } 274 | "### 275 | ); 276 | } 277 | 278 | #[test] 279 | fn test_simple_component_extraction() { 280 | assert_debug_snapshot!( 281 | LogEntry::parse(b"foo: bar").component_and_message(), 282 | @r###" 283 | ( 284 | Some( 285 | "foo", 286 | ), 287 | "bar", 288 | ) 289 | "### 290 | ); 291 | } 292 | -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | use std::str; 2 | 3 | use chrono::prelude::*; 4 | use lazy_static::lazy_static; 5 | use regex::bytes::Regex; 6 | 7 | use crate::types::LogEntry; 8 | 9 | fn now() -> DateTime { 10 | #[cfg(test)] 11 | { 12 | Local.with_ymd_and_hms(2017, 1, 1, 0, 0, 0).unwrap() 13 | } 14 | #[cfg(not(test))] 15 | { 16 | Local::now() 17 | } 18 | } 19 | 20 | fn today(offset: Option) -> (i32, u32, u32) { 21 | match offset { 22 | None => { 23 | let today = { 24 | #[cfg(test)] 25 | { 26 | Local.with_ymd_and_hms(2017, 1, 1, 0, 0, 0).unwrap() 27 | } 28 | #[cfg(not(test))] 29 | { 30 | Local::now() 31 | } 32 | }; 33 | (today.year(), today.month(), today.day()) 34 | } 35 | Some(offset) => { 36 | let today = { 37 | #[cfg(test)] 38 | { 39 | Utc.with_ymd_and_hms(2017, 1, 1, 0, 0, 0).unwrap() 40 | } 41 | #[cfg(not(test))] 42 | { 43 | Utc::now() 44 | } 45 | } 46 | .with_timezone(&offset); 47 | (today.year(), today.month(), today.day()) 48 | } 49 | } 50 | } 51 | 52 | lazy_static! { 53 | static ref C_LOG_RE: Regex = Regex::new( 54 | r#"(?x) 55 | ^ 56 | \[? 57 | (?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\x20 58 | (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) 59 | \x20 60 | ([0-9]+) 61 | \x20 62 | ([0-9]{2}):([0-9]{2}):([0-9]{2}) 63 | (?:\.[0-9]+)? 64 | \x20 65 | ([0-9]+) 66 | \]? 67 | [\t\x20] 68 | (.*) 69 | $ 70 | "# 71 | ).unwrap(); 72 | static ref SHORT_LOG_RE: Regex = Regex::new( 73 | r#"(?x) 74 | ^ 75 | \[? 76 | (?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\x20)? 77 | (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) 78 | \x20 79 | ([0-9]+) 80 | \x20 81 | ([0-9]{2}):([0-9]{2}):([0-9]{2}) 82 | (?:\.[0-9]+)? 83 | \]? 84 | [\t\x20] 85 | (.*) 86 | $ 87 | "# 88 | ).unwrap(); 89 | static ref SIMPLE_LOG_RE: Regex = Regex::new( 90 | r#"(?x) 91 | ^ 92 | \[? 93 | ([0-9]+): 94 | ([0-9]+): 95 | ([0-9]+) 96 | \]? 97 | [\t\x20] 98 | (.*) 99 | $ 100 | "# 101 | ).unwrap(); 102 | static ref COMMON_LOG_RE: Regex = Regex::new( 103 | r#"(?x) 104 | ^ 105 | \[? 106 | ([0-9]{4}?)-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]) 107 | \x20 108 | ([0-9]{2}):([0-9]{2}):([0-9]{2}) 109 | \x20 110 | ([+-]) 111 | ([0-9]{2})([0-9]{2}) 112 | :? 113 | \]? 114 | [\t\x20] 115 | (.*) 116 | $ 117 | "# 118 | ).unwrap(); 119 | static ref COMMON_ALT_LOG_RE: Regex = Regex::new( 120 | r#"(?x) 121 | ^ 122 | \[? 123 | (?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\x20)? 124 | (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) 125 | \x20+ 126 | ([0-9]+) 127 | \x20 128 | ([0-9]{2}):([0-9]{2}):([0-9]{2}) 129 | (?:\.[0-9]+)? 130 | \x20 131 | ([0-9]{4}) 132 | \]? 133 | [\t\x20] 134 | (.*) 135 | $ 136 | "# 137 | ).unwrap(); 138 | static ref COMMON_ALT2_LOG_RE: Regex = Regex::new( 139 | r#"(?x) 140 | ^ 141 | \[? 142 | (?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\x20)? 143 | (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) 144 | \x20+ 145 | ([0-9]+),? 146 | \x20 147 | ([0-9]{4}) 148 | \x20 149 | ([0-9]{2}):([0-9]{2}):([0-9]{2}) 150 | (?:\.[0-9]+)? 151 | \]? 152 | [\t\x20] 153 | (.*) 154 | $ 155 | "# 156 | ).unwrap(); 157 | static ref UE4_LOG_RE: Regex = Regex::new( 158 | // [2018.10.29-16.56.37:542][ 0]LogInit: Selected Device Profile: [WindowsNoEditor] 159 | r#"(?x) 160 | ^ 161 | \[ 162 | ([0-9]{4}?)\.(0[1-9]|1[0-2])\.(0[1-9]|[12][0-9]|3[01]) 163 | - 164 | ([0-9]+)\.([0-9]+)\.([0-9]+) 165 | : 166 | (?:[0-9]+) 167 | \] 168 | \[\x20*[0-9]+\] 169 | (.*) 170 | $ 171 | "# 172 | ).unwrap(); 173 | } 174 | 175 | #[allow(clippy::too_many_arguments)] 176 | fn log_entry_from_local_time( 177 | offset: Option, 178 | year: i32, 179 | month: u32, 180 | day: u32, 181 | hh: u32, 182 | mm: u32, 183 | ss: u32, 184 | message: &[u8], 185 | ) -> Option { 186 | match offset { 187 | Some(offset) => offset 188 | .with_ymd_and_hms(year, month, day, hh, mm, ss) 189 | .latest() 190 | .map(|date| LogEntry::from_fixed_time(date, message)), 191 | None => Local 192 | .with_ymd_and_hms(year, month, day, hh, mm, ss) 193 | .latest() 194 | .map(|date| LogEntry::from_local_time(date, message)), 195 | } 196 | } 197 | 198 | fn get_month(bytes: &[u8]) -> Option { 199 | Some(match bytes { 200 | b"Jan" => 1, 201 | b"Feb" => 2, 202 | b"Mar" => 3, 203 | b"Apr" => 4, 204 | b"May" => 5, 205 | b"Jun" => 6, 206 | b"Jul" => 7, 207 | b"Aug" => 8, 208 | b"Sep" => 9, 209 | b"Oct" => 10, 210 | b"Nov" => 11, 211 | b"Dec" => 12, 212 | _ => return None, 213 | }) 214 | } 215 | 216 | pub fn parse_c_log_entry(bytes: &[u8], offset: Option) -> Option { 217 | let caps = match C_LOG_RE.captures(bytes) { 218 | Some(caps) => caps, 219 | None => return None, 220 | }; 221 | 222 | let month = get_month(&caps[1]).unwrap(); 223 | let day: u32 = str::from_utf8(&caps[2]).unwrap().parse().unwrap(); 224 | let h: u32 = str::from_utf8(&caps[3]).unwrap().parse().unwrap(); 225 | let m: u32 = str::from_utf8(&caps[4]).unwrap().parse().unwrap(); 226 | let s: u32 = str::from_utf8(&caps[5]).unwrap().parse().unwrap(); 227 | let year: i32 = str::from_utf8(&caps[6]).unwrap().parse().unwrap(); 228 | 229 | log_entry_from_local_time( 230 | offset, 231 | year, 232 | month, 233 | day, 234 | h, 235 | m, 236 | s, 237 | caps.get(7).map(|x| x.as_bytes()).unwrap(), 238 | ) 239 | } 240 | 241 | pub fn parse_short_log_entry(bytes: &[u8], offset: Option) -> Option { 242 | let caps = match SHORT_LOG_RE.captures(bytes) { 243 | Some(caps) => caps, 244 | None => return None, 245 | }; 246 | 247 | let year = now().year(); 248 | let month = get_month(&caps[1]).unwrap(); 249 | let day: u32 = str::from_utf8(&caps[2]).unwrap().parse().unwrap(); 250 | let h: u32 = str::from_utf8(&caps[3]).unwrap().parse().unwrap(); 251 | let m: u32 = str::from_utf8(&caps[4]).unwrap().parse().unwrap(); 252 | let s: u32 = str::from_utf8(&caps[5]).unwrap().parse().unwrap(); 253 | 254 | log_entry_from_local_time( 255 | offset, 256 | year, 257 | month, 258 | day, 259 | h, 260 | m, 261 | s, 262 | caps.get(6).map(|x| x.as_bytes()).unwrap(), 263 | ) 264 | } 265 | 266 | pub fn parse_simple_log_entry(bytes: &[u8], offset: Option) -> Option { 267 | let caps = match SIMPLE_LOG_RE.captures(bytes) { 268 | Some(caps) => caps, 269 | None => return None, 270 | }; 271 | 272 | let h: u32 = str::from_utf8(&caps[1]).unwrap().parse().unwrap(); 273 | let m: u32 = str::from_utf8(&caps[2]).unwrap().parse().unwrap(); 274 | let s: u32 = str::from_utf8(&caps[3]).unwrap().parse().unwrap(); 275 | 276 | let (year, month, day) = today(offset); 277 | log_entry_from_local_time( 278 | offset, 279 | year, 280 | month, 281 | day, 282 | h, 283 | m, 284 | s, 285 | caps.get(4).map(|x| x.as_bytes()).unwrap(), 286 | ) 287 | } 288 | 289 | pub fn parse_common_log_entry(bytes: &[u8], _offset: Option) -> Option { 290 | let caps = match COMMON_LOG_RE.captures(bytes) { 291 | Some(caps) => caps, 292 | None => return None, 293 | }; 294 | 295 | let year: i32 = str::from_utf8(&caps[1]).unwrap().parse().unwrap(); 296 | let month: u32 = str::from_utf8(&caps[2]).unwrap().parse().unwrap(); 297 | let day: u32 = str::from_utf8(&caps[3]).unwrap().parse().unwrap(); 298 | let h: u32 = str::from_utf8(&caps[4]).unwrap().parse().unwrap(); 299 | let m: u32 = str::from_utf8(&caps[5]).unwrap().parse().unwrap(); 300 | let s: u32 = str::from_utf8(&caps[6]).unwrap().parse().unwrap(); 301 | 302 | let offset = FixedOffset::east_opt( 303 | ((if &caps[7] == b"+" { 1i32 } else { -1i32 }) 304 | * str::from_utf8(&caps[8]).unwrap().parse::().unwrap() 305 | * 60 306 | + str::from_utf8(&caps[9]).unwrap().parse::().unwrap()) 307 | * 60, 308 | )?; 309 | 310 | Some(LogEntry::from_fixed_time( 311 | offset 312 | .with_ymd_and_hms(year, month, day, h, m, s) 313 | .single()?, 314 | caps.get(10).map(|x| x.as_bytes()).unwrap(), 315 | )) 316 | } 317 | 318 | pub fn parse_common_alt_log_entry(bytes: &[u8], offset: Option) -> Option { 319 | let caps = match COMMON_ALT_LOG_RE.captures(bytes) { 320 | Some(caps) => caps, 321 | None => return None, 322 | }; 323 | 324 | let month = get_month(&caps[1]).unwrap(); 325 | let day: u32 = str::from_utf8(&caps[2]).unwrap().parse().unwrap(); 326 | let h: u32 = str::from_utf8(&caps[3]).unwrap().parse().unwrap(); 327 | let m: u32 = str::from_utf8(&caps[4]).unwrap().parse().unwrap(); 328 | let s: u32 = str::from_utf8(&caps[5]).unwrap().parse().unwrap(); 329 | let year: i32 = str::from_utf8(&caps[6]).unwrap().parse().unwrap(); 330 | 331 | log_entry_from_local_time( 332 | offset, 333 | year, 334 | month, 335 | day, 336 | h, 337 | m, 338 | s, 339 | caps.get(7).map(|x| x.as_bytes()).unwrap(), 340 | ) 341 | } 342 | 343 | pub fn parse_common_alt2_log_entry(bytes: &[u8], offset: Option) -> Option { 344 | let caps = match COMMON_ALT2_LOG_RE.captures(bytes) { 345 | Some(caps) => caps, 346 | None => return None, 347 | }; 348 | 349 | let month = get_month(&caps[1]).unwrap(); 350 | let day: u32 = str::from_utf8(&caps[2]).unwrap().parse().unwrap(); 351 | let year: i32 = str::from_utf8(&caps[3]).unwrap().parse().unwrap(); 352 | let h: u32 = str::from_utf8(&caps[4]).unwrap().parse().unwrap(); 353 | let m: u32 = str::from_utf8(&caps[5]).unwrap().parse().unwrap(); 354 | let s: u32 = str::from_utf8(&caps[6]).unwrap().parse().unwrap(); 355 | 356 | log_entry_from_local_time( 357 | offset, 358 | year, 359 | month, 360 | day, 361 | h, 362 | m, 363 | s, 364 | caps.get(7).map(|x| x.as_bytes()).unwrap(), 365 | ) 366 | } 367 | 368 | pub fn parse_ue4_log_entry(bytes: &[u8], _offset: Option) -> Option { 369 | let caps = match UE4_LOG_RE.captures(bytes) { 370 | Some(caps) => caps, 371 | None => return None, 372 | }; 373 | 374 | let year: i32 = str::from_utf8(&caps[1]).unwrap().parse().unwrap(); 375 | let month: u32 = str::from_utf8(&caps[2]).unwrap().parse().unwrap(); 376 | let day: u32 = str::from_utf8(&caps[3]).unwrap().parse().unwrap(); 377 | let h: u32 = str::from_utf8(&caps[4]).unwrap().parse().unwrap(); 378 | let m: u32 = str::from_utf8(&caps[5]).unwrap().parse().unwrap(); 379 | let s: u32 = str::from_utf8(&caps[6]).unwrap().parse().unwrap(); 380 | 381 | Some(LogEntry::from_utc_time( 382 | Utc.with_ymd_and_hms(year, month, day, h, m, s).single()?, 383 | caps.get(7).map(|x| x.as_bytes()).unwrap(), 384 | )) 385 | } 386 | 387 | pub fn parse_log_entry(bytes: &[u8], offset: Option) -> Option { 388 | macro_rules! attempt { 389 | ($func:ident) => { 390 | if let Some(rv) = $func(bytes, offset) { 391 | return Some(rv); 392 | } 393 | }; 394 | } 395 | 396 | attempt!(parse_c_log_entry); 397 | attempt!(parse_short_log_entry); 398 | attempt!(parse_simple_log_entry); 399 | attempt!(parse_common_log_entry); 400 | attempt!(parse_common_alt_log_entry); 401 | attempt!(parse_common_alt2_log_entry); 402 | attempt!(parse_ue4_log_entry); 403 | 404 | None 405 | } 406 | 407 | #[cfg(test)] 408 | use insta::assert_debug_snapshot; 409 | 410 | #[test] 411 | fn test_parse_c_log_entry() { 412 | assert_debug_snapshot!( 413 | parse_c_log_entry(b"Tue Nov 21 00:30:05 2017 More stuff here", None), 414 | @r###" 415 | Some( 416 | LogEntry { 417 | timestamp: Some( 418 | Local( 419 | 2017-11-21T00:30:05+01:00, 420 | ), 421 | ), 422 | message: "More stuff here", 423 | }, 424 | ) 425 | "### 426 | ); 427 | } 428 | 429 | #[test] 430 | fn test_parse_short_log_entry() { 431 | assert_debug_snapshot!( 432 | parse_short_log_entry( 433 | b"Nov 20 21:56:01 herzog com.apple.xpc.launchd[1] (com.apple.preference.displays.MirrorDisplays): Service only ran for 0 seconds. Pushing respawn out by 10 seconds.", 434 | None 435 | ), 436 | @r###" 437 | Some( 438 | LogEntry { 439 | timestamp: Some( 440 | Local( 441 | 2017-11-20T21:56:01+01:00, 442 | ), 443 | ), 444 | message: "herzog com.apple.xpc.launchd[1] (com.apple.preference.displays.MirrorDisplays): Service only ran for 0 seconds. Pushing respawn out by 10 seconds.", 445 | }, 446 | ) 447 | "### 448 | ); 449 | } 450 | 451 | #[test] 452 | fn test_parse_short_log_entry_extra() { 453 | assert_debug_snapshot!( 454 | parse_short_log_entry( 455 | b"Mon Nov 20 00:31:19.005 en0: Received EAPOL packet (length = 161)", 456 | None 457 | ), 458 | @r###" 459 | Some( 460 | LogEntry { 461 | timestamp: Some( 462 | Local( 463 | 2017-11-20T00:31:19+01:00, 464 | ), 465 | ), 466 | message: " en0: Received EAPOL packet (length = 161)", 467 | }, 468 | ) 469 | "### 470 | ); 471 | } 472 | 473 | #[test] 474 | fn test_parse_simple_log_entry() { 475 | assert_debug_snapshot!( 476 | parse_simple_log_entry( 477 | b"22:07:10 server | detected binary path: /Users/mitsuhiko/.virtualenvs/sentry/bin/uwsgi", 478 | None 479 | ), 480 | @r###" 481 | Some( 482 | LogEntry { 483 | timestamp: Some( 484 | Local( 485 | 2017-01-01T22:07:10+01:00, 486 | ), 487 | ), 488 | message: "server | detected binary path: /Users/mitsuhiko/.virtualenvs/sentry/bin/uwsgi", 489 | }, 490 | ) 491 | "### 492 | ); 493 | } 494 | 495 | #[test] 496 | fn test_parse_common_log_entry() { 497 | assert_debug_snapshot!( 498 | parse_common_log_entry( 499 | b"2015-05-13 17:39:16 +0200: Repaired 'Library/Printers/Canon/IJScanner/Resources/Parameters/CNQ9601'", 500 | None 501 | ), 502 | @r###" 503 | Some( 504 | LogEntry { 505 | timestamp: Some( 506 | Fixed( 507 | 2015-05-13T17:39:16+02:00, 508 | ), 509 | ), 510 | message: "Repaired 'Library/Printers/Canon/IJScanner/Resources/Parameters/CNQ9601'", 511 | }, 512 | ) 513 | "### 514 | ); 515 | } 516 | 517 | #[test] 518 | fn test_parse_common_alt_log_entry() { 519 | assert_debug_snapshot!( 520 | parse_common_alt_log_entry( 521 | b"Mon Oct 5 11:40:10 2015 [INFO] PDApp.ExternalGateway - NativePlatformHandler destructed", 522 | None 523 | ), 524 | @r###" 525 | Some( 526 | LogEntry { 527 | timestamp: Some( 528 | Local( 529 | 2015-10-05T11:40:10+02:00, 530 | ), 531 | ), 532 | message: "[INFO] PDApp.ExternalGateway - NativePlatformHandler destructed", 533 | }, 534 | ) 535 | "### 536 | ); 537 | } 538 | 539 | #[test] 540 | fn test_parse_common_alt2_log_entry() { 541 | assert_debug_snapshot!( 542 | parse_common_alt2_log_entry( 543 | b"Jan 03, 2016 22:29:55 [0x70000073b000] DEBUG - Responding HTTP/1.1 200", 544 | None 545 | ), 546 | @r###" 547 | Some( 548 | LogEntry { 549 | timestamp: Some( 550 | Local( 551 | 2016-01-03T22:29:55+01:00, 552 | ), 553 | ), 554 | message: "[0x70000073b000] DEBUG - Responding HTTP/1.1 200", 555 | }, 556 | ) 557 | "### 558 | ); 559 | } 560 | 561 | #[test] 562 | fn test_parse_webserver_log() { 563 | assert_debug_snapshot!( 564 | parse_common_alt_log_entry(b"[Sun Feb 25 06:11:12.043123448 2018] [:notice] [pid 1:tid 2] process manager initialized (pid 1)", None), 565 | @r###" 566 | Some( 567 | LogEntry { 568 | timestamp: Some( 569 | Local( 570 | 2018-02-25T06:11:12+01:00, 571 | ), 572 | ), 573 | message: "[:notice] [pid 1:tid 2] process manager initialized (pid 1)", 574 | }, 575 | ) 576 | "### 577 | ) 578 | } 579 | 580 | #[test] 581 | fn test_parse_invalid_time() { 582 | // same as test_parse_c_log_entry, except for invalid timestamp 583 | assert_debug_snapshot!( 584 | parse_c_log_entry(b"Tue Nov 21 99:99:99 2017 More stuff here", None), 585 | @"None" 586 | ); 587 | } 588 | 589 | #[test] 590 | fn test_parse_ue4_log() { 591 | assert_debug_snapshot!( 592 | parse_ue4_log_entry(b"[2018.10.29-16.56.37:542][ 0]LogInit: Selected Device Profile: [WindowsNoEditor]", None), 593 | @r###" 594 | Some( 595 | LogEntry { 596 | timestamp: Some( 597 | Utc( 598 | 2018-10-29T16:56:37Z, 599 | ), 600 | ), 601 | message: "LogInit: Selected Device Profile: [WindowsNoEditor]", 602 | }, 603 | ) 604 | "### 605 | ); 606 | assert_debug_snapshot!( 607 | parse_ue4_log_entry(b"[2022.09.14-11.13.24:829][316]LogShaderCompilers: Display: ================================================", None), 608 | @r###" 609 | Some( 610 | LogEntry { 611 | timestamp: Some( 612 | Utc( 613 | 2022-09-14T11:13:24Z, 614 | ), 615 | ), 616 | message: "LogShaderCompilers: Display: ================================================", 617 | }, 618 | ) 619 | "### 620 | ); 621 | } 622 | 623 | #[test] 624 | fn test_parse_ue4_log_fail() { 625 | assert_debug_snapshot!( 626 | parse_ue4_log_entry(b"[2022.13.29-16.63.27:542][ 0]LogInit: Selected Device Profile: [WindowsNoEditor]", None), 627 | @r###" 628 | None 629 | "### 630 | ); 631 | } 632 | --------------------------------------------------------------------------------