| 1 | use std::{ |
| 2 | collections::BTreeMap, |
| 3 | fs, |
| 4 | io::{self, BufRead, Write}, |
| 5 | ops::Sub, |
| 6 | path, |
| 7 | time::Duration, |
| 8 | }; |
| 9 | |
| 10 | use chrono::{DateTime, FixedOffset, TimeDelta}; |
| 11 | use indexmap::{IndexMap, map::Entry}; |
| 12 | use regex::RegexBuilder; |
| 13 | use url::{Position as UrlPosition, Url}; |
| 14 | |
| 15 | use crate::Result; |
| 16 | |
| 17 | const OUTPUT_TEMPLATE: &str = include_str!("sync.template.html"); |
| 18 | |
| 19 | pub(super) fn run(log_path: path::PathBuf, output_path: path::PathBuf) -> Result<()> { |
| 20 | let find_sync = RegexBuilder::new( |
| 21 | r#" |
| 22 | # Datetime of the log line. |
| 23 | (?<datetime>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z) |
| 24 | |
| 25 | # Ensure it's about the `http_client` scope. |
| 26 | .*matrix_sdk::http_client |
| 27 | |
| 28 | # Ensure it's about a sync. |
| 29 | .*>\ssync_once\{ |
| 30 | conn_id="(?<connection_id>[^"]+)" |
| 31 | |
| 32 | # Since https://github.com/matrix-org/matrix-rust-sdk/pull/6118, |
| 33 | # we have `pos` and `timeout`. We must consider they are |
| 34 | # optional. |
| 35 | ( |
| 36 | .*? |
| 37 | (\spos="(?<pos>[^"]+)")? |
| 38 | \stimeout=(?<timeout>\d+) |
| 39 | )? |
| 40 | \} |
| 41 | |
| 42 | # Let's capture some data about `send()`! |
| 43 | \s>\ssend\{ |
| 44 | request_id="REQ-(?<request_id>\d+)" |
| 45 | \smethod=(?<method>\S+) |
| 46 | \suri="(?<uri>[^"]+)" |
| 47 | # If there is a `request_size`. |
| 48 | (.*\srequest_size="(?<request_size>[^"]+)")? |
| 49 | # If this is a response, there is a `status`. |
| 50 | (.*\sstatus=(?<status>\d+))? |
| 51 | # If there is a `response_size`. |
| 52 | (.*\sresponse_size="(?<response_size>[^"]+)")? |
| 53 | "#, |
| 54 | ) |
| 55 | .ignore_whitespace(true) |
| 56 | .build() |
| 57 | .expect("Failed to build the `find_sync` regex"); |
| 58 | |
| 59 | let log_file = fs::File::open(&log_path)?; |
| 60 | let mut output_file = fs::File::create(&output_path)?; |
| 61 | |
| 62 | let reader = io::BufReader::new(log_file); |
| 63 | let mut number_of_analysed_lines = 0; |
| 64 | let mut number_of_matched_lines = 0; |
| 65 | let mut smallest_start_at = None; |
| 66 | let mut largest_end_at = None; |
| 67 | |
| 68 | let mut spans: BTreeMap<ConnectionId, IndexMap<RequestId, Span>> = BTreeMap::new(); |
| 69 | |
| 70 | for (line_nth, line) in reader.lines().enumerate().map(|(nth, line)| { |
| 71 | ( |
| 72 | nth + 1, |
| 73 | line.unwrap_or_else(|error| { |
| 74 | panic!("Failed to read line #{nth}\n{error}"); |
| 75 | }), |
| 76 | ) |
| 77 | }) { |
| 78 | number_of_analysed_lines += 1; |
| 79 | |
| 80 | if let Some(captures) = find_sync.captures(&line) { |
| 81 | number_of_matched_lines += 1; |
| 82 | |
| 83 | let date_time = DateTime::parse_from_rfc3339( |
| 84 | captures.name("datetime").expect("Failed to capture `datetime`").as_str(), |
| 85 | ) |
| 86 | .expect("Failed to parse `datetime`"); |
| 87 | let connection_id = |
| 88 | captures.name("connection_id").expect("Failed to capture `connection_id`").as_str(); |
| 89 | let pos = captures.name("pos").map(|pos| pos.as_str()); |
| 90 | let timeout = captures.name("timeout").and_then(|timeout| { |
| 91 | timeout.as_str().parse::<u64>().ok().map(Duration::from_millis) |
| 92 | }); |
| 93 | let request_id = captures |
| 94 | .name("request_id") |
| 95 | .expect("Failed to capture `request_id`") |
| 96 | .as_str() |
| 97 | .parse() |
| 98 | .expect("Failed to parse `request_id`"); |
| 99 | let method = captures.name("method").expect("Failed to capture `method`").as_str(); |
| 100 | let uri = captures.name("uri").expect("Failed to capture `uri`").as_str(); |
| 101 | let request_size = |
| 102 | captures.name("request_size").map(|request_size| request_size.as_str()); |
| 103 | let response_size = |
| 104 | captures.name("response_size").map(|response_size| response_size.as_str()); |
| 105 | let status = captures.name("status").and_then(|status| status.as_str().parse().ok()); |
| 106 | |
| 107 | if let Some(smallest_start_at_inner) = smallest_start_at { |
| 108 | if smallest_start_at_inner > date_time { |
| 109 | smallest_start_at = Some(date_time); |
| 110 | } |
| 111 | } else { |
| 112 | smallest_start_at = Some(date_time); |
| 113 | } |
| 114 | |
| 115 | if let Some(largest_end_at_inner) = largest_end_at { |
| 116 | if largest_end_at_inner < date_time { |
| 117 | largest_end_at = Some(date_time); |
| 118 | } |
| 119 | } else { |
| 120 | largest_end_at = Some(date_time); |
| 121 | } |
| 122 | |
| 123 | let spans_for_connection_id = spans.entry(connection_id.to_owned()).or_default(); |
| 124 | |
| 125 | match spans_for_connection_id.entry(request_id) { |
| 126 | Entry::Vacant(entry) => { |
| 127 | entry.insert(Span { |
| 128 | status: None, |
| 129 | method: method.to_owned(), |
| 130 | uri: uri.to_owned(), |
| 131 | pos: pos.map(ToOwned::to_owned), |
| 132 | timeout, |
| 133 | request_size: request_size.map(ToOwned::to_owned), |
| 134 | response_size: response_size.map(ToOwned::to_owned), |
| 135 | start_at: date_time, |
| 136 | duration: TimeDelta::zero(), |
| 137 | request_log_line: line_nth, |
| 138 | response_log_line: None, |
| 139 | }); |
| 140 | } |
| 141 | Entry::Occupied(mut entry) => { |
| 142 | let span = entry.get_mut(); |
| 143 | |
| 144 | if let Some(status) = status { |
| 145 | span.status = Some(status); |
| 146 | } |
| 147 | |
| 148 | span.duration = date_time.sub(&span.start_at); |
| 149 | |
| 150 | if let Some(request_size) = request_size { |
| 151 | span.request_size = Some(request_size.to_owned()); |
| 152 | } |
| 153 | |
| 154 | if let Some(response_size) = response_size { |
| 155 | span.response_size = Some(response_size.to_owned()); |
| 156 | } |
| 157 | |
| 158 | span.response_log_line = Some(line_nth); |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | let smallest_start_at = |
| 165 | smallest_start_at.map(|date_time| date_time.timestamp_millis()).unwrap_or_default(); |
| 166 | let largest_end_at = |
| 167 | largest_end_at.map(|date_time| date_time.timestamp_millis()).unwrap_or_default(); |
| 168 | let end_at = largest_end_at.saturating_sub(smallest_start_at).to_string(); |
| 169 | let rows = spans |
| 170 | .iter() |
| 171 | .flat_map(|(connection_id, spans)| { |
| 172 | spans.iter().map( |
| 173 | |( |
| 174 | request_id, |
| 175 | Span { |
| 176 | status, |
| 177 | method, |
| 178 | uri, |
| 179 | pos, |
| 180 | timeout, |
| 181 | request_size, |
| 182 | response_size, |
| 183 | start_at, |
| 184 | duration, |
| 185 | request_log_line, |
| 186 | response_log_line |
| 187 | }, |
| 188 | )| { |
| 189 | let uri = Url::parse(uri); |
| 190 | let duration = duration.num_milliseconds().unsigned_abs(); |
| 191 | let timeout: Option<u64> = timeout.map(|timeout| timeout.as_millis().try_into().expect("Failed to cast a u128 to u64")); |
| 192 | |
| 193 | format!( |
| 194 | " <tr id=\"{connection_id}-{request_id}\"> |
| 195 | <td><code>{connection_id}</code></td> |
| 196 | <td><a href=\"#{connection_id}-{request_id}\" title=\"Permalink to this line\"><code>{request_id}</code></a></td> |
| 197 | <td data-status-family=\"{status_family}\"><span>{status}</span></td> |
| 198 | <td><code>{method}</code></td> |
| 199 | <td title=\"{domain}\">{domain}</td> |
| 200 | <td title=\"{path}\">{path}</td> |
| 201 | <td>{timeout_label}</td> |
| 202 | <td>{request_size}</td> |
| 203 | <td>{response_size}</td> |
| 204 | <td><time datetime=\"{date_time}\">{time}</time></td> |
| 205 | <td> |
| 206 | <div class=\"span\" style=\"--start-at: {start_at}; --timeout: {timeout}; --duration: {duration}\"><span>{duration_label}</span></div> |
| 207 | <details> |
| 208 | <summary><span class=\"hidden\">information</span></summary> |
| 209 | <ul> |
| 210 | <li>Request log line number: {request_log_line}</li> |
| 211 | <li>Response log line number: {response_log_line}</li> |
| 212 | <li>Sliding Sync <code>pos</code>: <span class=\"text-overflow\">{pos}</span></li> |
| 213 | </ul> |
| 214 | </details> |
| 215 | </td> |
| 216 | </tr> |
| 217 | ", |
| 218 | connection_id = connection_id.clone(), |
| 219 | status = status |
| 220 | .map(|status| status.to_string()) |
| 221 | .unwrap_or_else(|| "×".to_owned()), |
| 222 | status_family = status |
| 223 | .map(|status| (if status > 0 { status / 100 } else { 0 } ).to_string()) |
| 224 | .unwrap_or_else(|| "cancelled".to_owned()), |
| 225 | domain = uri |
| 226 | .as_ref() |
| 227 | .map(|uri| uri[UrlPosition::BeforeHost..UrlPosition::BeforePath].to_owned()) |
| 228 | .unwrap_or_default(), |
| 229 | path = uri |
| 230 | .as_ref() |
| 231 | .map(|uri| uri[UrlPosition::BeforePath..].to_owned()) |
| 232 | .unwrap_or_default(), |
| 233 | timeout_label = timeout |
| 234 | .map(|timeout| format!("{timeout}ms")) |
| 235 | .unwrap_or_else(|| "(unknown)".to_owned()), |
| 236 | timeout = timeout |
| 237 | .map(|timeout| { |
| 238 | // The response was received before hitting the timeout. |
| 239 | if duration < timeout { |
| 240 | 0 |
| 241 | } else { |
| 242 | timeout |
| 243 | } |
| 244 | }) |
| 245 | .unwrap_or(0), |
| 246 | request_size = request_size |
| 247 | .clone() |
| 248 | .map(|request_size| request_size.to_string()) |
| 249 | .unwrap_or_else(|| "".to_owned()), |
| 250 | response_size = response_size |
| 251 | .clone() |
| 252 | .map(|response_size| response_size.to_string()) |
| 253 | .unwrap_or_else(|| "".to_owned()), |
| 254 | date_time = start_at.format("%+"), |
| 255 | time = start_at.format("%H:%M:%S%.3f"), |
| 256 | start_at = start_at |
| 257 | .timestamp_millis() |
| 258 | .saturating_sub(smallest_start_at), |
| 259 | duration_label = if duration > 0 { format!("{duration}ms") } else { "<em>cancelled</em>".to_owned() }, |
| 260 | response_log_line = response_log_line.map(|line| line.to_string()).unwrap_or_else(|| "(none)".to_owned()), |
| 261 | pos = pos.clone().unwrap_or_else(|| "(unknown)".to_owned()) |
| 262 | ) |
| 263 | }, |
| 264 | ) |
| 265 | }) |
| 266 | .collect::<String>(); |
| 267 | |
| 268 | let output = OUTPUT_TEMPLATE |
| 269 | .replace("{log_file}", &log_path.to_string_lossy()) |
| 270 | .replace("{end_at}", &end_at) |
| 271 | .replace("{rows}", &rows); |
| 272 | |
| 273 | output_file.write_all(output.as_bytes()).expect("Failed to write the output"); |
| 274 | |
| 275 | println!( |
| 276 | "\nNumber of analysed log lines: {number_of_analysed_lines}\n\ |
| 277 | Number of matched lines: {number_of_matched_lines}\n\ |
| 278 | Output file: {output_path:?}\n\ |
| 279 | Done!" |
| 280 | ); |
| 281 | |
| 282 | Ok(()) |
| 283 | } |
| 284 | |
| 285 | type ConnectionId = String; |
| 286 | type RequestId = u32; |
| 287 | |
| 288 | #[derive(Debug)] |
| 289 | struct Span { |
| 290 | status: Option<u8>, |
| 291 | method: String, |
| 292 | uri: String, |
| 293 | pos: Option<String>, |
| 294 | timeout: Option<Duration>, |
| 295 | request_size: Option<String>, |
| 296 | response_size: Option<String>, |
| 297 | start_at: DateTime<FixedOffset>, |
| 298 | duration: TimeDelta, |
| 299 | request_log_line: usize, |
| 300 | response_log_line: Option<usize>, |
| 301 | } |