1use chrono::{DateTime, Duration, Utc};
4use relay_conventions::{attributes::*, interpolate};
5use relay_event_schema::protocol::{
6 Attributes, NetworkReportRaw, OurLog, OurLogLevel, Timestamp, TraceId,
7};
8use relay_protocol::Annotated;
9use std::borrow::Cow;
10use std::collections::HashMap;
11use std::sync::LazyLock;
12use url::Url;
13
14static NEL_CULPRITS: &[(&str, &str)] = &[
18 ("dns.unreachable", "DNS server is unreachable"),
20 (
21 "dns.name_not_resolved",
22 "DNS server responded but is unable to resolve the address",
23 ),
24 (
25 "dns.failed",
26 "Request to the DNS server failed due to reasons not covered by previous errors",
27 ),
28 (
29 "dns.address_changed",
30 "Indicates that the resolved IP address for a request's origin has changed since the corresponding NEL policy was received",
31 ),
32 ("tcp.timed_out", "TCP connection to the server timed out"),
33 ("tcp.closed", "The TCP connection was closed by the server"),
34 ("tcp.reset", "The TCP connection was reset"),
35 (
36 "tcp.refused",
37 "The TCP connection was refused by the server",
38 ),
39 ("tcp.aborted", "The TCP connection was aborted"),
40 ("tcp.address_invalid", "The IP address is invalid"),
41 ("tcp.address_unreachable", "The IP address is unreachable"),
42 (
43 "tcp.failed",
44 "The TCP connection failed due to reasons not covered by previous errors",
45 ),
46 (
47 "tls.version_or_cipher_mismatch",
48 "The TLS connection was aborted due to version or cipher mismatch",
49 ),
50 (
51 "tls.bad_client_auth_cert",
52 "The TLS connection was aborted due to invalid client certificate",
53 ),
54 (
55 "tls.cert.name_invalid",
56 "The TLS connection was aborted due to invalid name",
57 ),
58 (
59 "tls.cert.date_invalid",
60 "The TLS connection was aborted due to invalid certificate date",
61 ),
62 (
63 "tls.cert.authority_invalid",
64 "The TLS connection was aborted due to invalid issuing authority",
65 ),
66 (
67 "tls.cert.invalid",
68 "The TLS connection was aborted due to invalid certificate",
69 ),
70 (
71 "tls.cert.revoked",
72 "The TLS connection was aborted due to revoked server certificate",
73 ),
74 (
75 "tls.cert.pinned_key_not_in_cert_chain",
76 "The TLS connection was aborted due to a key pinning error",
77 ),
78 (
79 "tls.protocol.error",
80 "The TLS connection was aborted due to a TLS protocol error",
81 ),
82 (
83 "tls.failed",
84 "The TLS connection failed due to reasons not covered by previous errors",
85 ),
86 (
87 "http.error",
88 "The user agent successfully received a response, but it had a {} status code",
89 ),
90 (
91 "http.protocol.error",
92 "The connection was aborted due to an HTTP protocol error",
93 ),
94 (
95 "http.response.invalid",
96 "Response is empty, has a content-length mismatch, has improper encoding, and/or other conditions that prevent user agent from processing the response",
97 ),
98 (
99 "http.response.redirect_loop",
100 "The request was aborted due to a detected redirect loop",
101 ),
102 (
103 "http.failed",
104 "The connection failed due to errors in HTTP protocol not covered by previous errors",
105 ),
106 (
107 "abandoned",
108 "User aborted the resource fetch before it is complete",
109 ),
110 ("unknown", "error type is unknown"),
111 ("dns.protocol", "ERR_DNS_MALFORMED_RESPONSE"),
114 ("dns.server", "ERR_DNS_SERVER_FAILED"),
115 (
116 "tls.unrecognized_name_alert",
117 "ERR_SSL_UNRECOGNIZED_NAME_ALERT",
118 ),
119 ("h2.ping_failed", "ERR_HTTP2_PING_FAILED"),
120 ("h2.protocol.error", "ERR_HTTP2_PROTOCOL_ERROR"),
121 ("h3.protocol.error", "ERR_QUIC_PROTOCOL_ERROR"),
122 ("http.response.invalid.empty", "ERR_EMPTY_RESPONSE"),
123 (
124 "http.response.invalid.content_length_mismatch",
125 "ERR_CONTENT_LENGTH_MISMATCH",
126 ),
127 (
128 "http.response.invalid.incomplete_chunked_encoding",
129 "ERR_INCOMPLETE_CHUNKED_ENCODING",
130 ),
131 (
132 "http.response.invalid.invalid_chunked_encoding",
133 "ERR_INVALID_CHUNKED_ENCODING",
134 ),
135 (
136 "http.request.range_not_satisfiable",
137 "ERR_REQUEST_RANGE_NOT_SATISFIABLE",
138 ),
139 (
140 "http.response.headers.truncated",
141 "ERR_RESPONSE_HEADERS_TRUNCATED",
142 ),
143 (
144 "http.response.headers.multiple_content_disposition",
145 "ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION",
146 ),
147 (
148 "http.response.headers.multiple_content_length",
149 "ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_LENGTH",
150 ),
151];
152
153static NEL_CULPRITS_MAP: LazyLock<HashMap<&'static str, &'static str>> =
155 LazyLock::new(|| NEL_CULPRITS.iter().copied().collect());
156
157#[allow(rustdoc::bare_urls)]
164fn extract_server_address(server_address: &str) -> String {
165 if let Ok(url) = Url::parse(server_address)
167 && let Some(host) = url.host_str()
168 {
169 return host.to_owned();
170 }
171 server_address.to_owned()
173}
174
175fn get_nel_culprit(error_type: &str) -> Option<&'static str> {
177 NEL_CULPRITS_MAP.get(error_type).copied()
178}
179
180fn get_nel_culprit_formatted(
181 error_type: &str,
182 status_code: Option<u16>,
183) -> Option<Cow<'static, str>> {
184 let template = get_nel_culprit(error_type)?;
185
186 if error_type == "http.error" {
187 let code = status_code.unwrap_or(0);
188 Some(Cow::Owned(template.replace("{}", &code.to_string())))
189 } else {
190 Some(Cow::Borrowed(template))
191 }
192}
193
194fn create_message(error_type: &str, status_code: Option<u16>) -> String {
196 get_nel_culprit_formatted(error_type, status_code)
197 .map(|cow| cow.into_owned())
198 .unwrap_or_else(|| error_type.to_owned())
199}
200
201pub fn create_log(nel: Annotated<NetworkReportRaw>, received_at: DateTime<Utc>) -> Option<OurLog> {
203 create_log_with_trace_id(nel, received_at, None)
204}
205
206pub fn create_log_with_trace_id(
209 nel: Annotated<NetworkReportRaw>,
210 received_at: DateTime<Utc>,
211 trace_id: Option<TraceId>,
212) -> Option<OurLog> {
213 let raw_report = nel.into_value()?;
214 let body = raw_report.body.into_value()?;
215
216 let error_type = body.ty.as_str().unwrap_or("unknown");
218 let message = create_message(
219 error_type,
220 body.status_code.value().map(|&code| code as u16),
221 );
222
223 let timestamp = received_at
224 .checked_sub_signed(Duration::milliseconds(
225 *raw_report.age.value().unwrap_or(&0),
226 ))
227 .unwrap_or(received_at);
228
229 let mut attributes: Attributes = Default::default();
230
231 macro_rules! add_attribute {
232 ($name:ident, $value:expr) => {{
233 if let Some(value) = $value.into_value() {
234 attributes.insert($name.to_owned(), value);
235 }
236 }};
237 }
238
239 macro_rules! add_string_attribute {
240 ($name:ident, $value:expr) => {{
241 let val = $value.to_string();
242 if !val.is_empty() {
243 attributes.insert($name.to_owned(), val);
244 }
245 }};
246 }
247
248 add_string_attribute!(SENTRY__ORIGIN, "auto.http.browser_report.nel");
249 add_string_attribute!(BROWSER__REPORT__TYPE, "network-error");
250
251 if let Some(url_str) = raw_report.url.value() {
253 let url_domain = extract_server_address(url_str);
254 add_string_attribute!(URL__DOMAIN, &url_domain);
255 }
256 add_attribute!(URL__FULL, raw_report.url);
257 add_attribute!(HTTP__REQUEST__DURATION, body.elapsed_time);
258 add_attribute!(HTTP__REQUEST__METHOD, body.method);
259 let referrer = interpolate::http__request__header__key("referer");
260 add_attribute!(referrer, body.referrer.clone());
261 add_attribute!(HTTP__RESPONSE__STATUS_CODE, body.status_code);
262 if let Some(protocol) = body.protocol.value() {
264 let mut parts = protocol.split('/');
265 if let Some(protocol) = parts.next()
266 && !protocol.is_empty()
267 {
268 add_string_attribute!(NETWORK__PROTOCOL__NAME, protocol);
270 }
271 if let Some(version) = parts.next()
272 && !version.is_empty()
273 {
274 add_string_attribute!(NETWORK__PROTOCOL__VERSION, version);
276 }
277 }
278
279 if let Some(server_ip) = body.server_ip.value() {
280 add_string_attribute!(SERVER__ADDRESS, server_ip.as_ref());
282 }
283
284 add_attribute!(NEL__REFERRER, body.referrer);
286 add_attribute!(NEL__PHASE, body.phase.map_value(|s| s.to_string()));
287 add_attribute!(NEL__SAMPLING_FRACTION, body.sampling_fraction);
288 add_attribute!(NEL__TYPE, body.ty.clone());
289
290 Some(OurLog {
291 timestamp: Annotated::new(Timestamp::from(timestamp)),
292 trace_id: Annotated::new(trace_id.unwrap_or_else(TraceId::random)),
293 level: Annotated::new(if error_type == "ok" {
294 OurLogLevel::Info
295 } else {
296 OurLogLevel::Warn
297 }),
298 body: Annotated::new(message),
299 attributes: Annotated::new(attributes),
300 ..Default::default()
301 })
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use chrono::{DateTime, Utc};
308 use relay_event_schema::protocol::{BodyRaw, IpAddr, NetworkReportPhases};
309 use relay_protocol::{Annotated, SerializableAnnotated};
310
311 #[test]
312 fn test_create_message() {
313 struct CreateMessageCase {
315 error_type: &'static str,
316 status_code: Option<u16>,
317 expected: &'static str,
318 }
319
320 let message_cases = vec![
321 CreateMessageCase {
322 error_type: "dns.unreachable",
323 status_code: None,
324 expected: "DNS server is unreachable",
325 },
326 CreateMessageCase {
328 error_type: "http.error",
329 status_code: Some(404),
330 expected: "The user agent successfully received a response, but it had a 404 status code",
331 },
332 CreateMessageCase {
334 error_type: "http.some_new_error",
335 status_code: None,
336 expected: "http.some_new_error",
337 },
338 ];
339
340 for case in message_cases {
341 assert_eq!(
342 create_message(case.error_type, case.status_code),
343 case.expected,
344 "Failed for error_type: {}, status_code: {:?}",
345 case.error_type,
346 case.status_code
347 );
348 }
349 }
350
351 #[test]
352 fn test_create_log_basic() {
353 let received_at = DateTime::parse_from_rfc3339("2021-04-26T08:00:05+00:00")
355 .unwrap()
356 .with_timezone(&Utc);
357
358 let body = BodyRaw {
359 ty: Annotated::new("http.error".to_owned()),
360 status_code: Annotated::new(500),
361 elapsed_time: Annotated::new(1000),
362 method: Annotated::new("GET".to_owned()),
363 protocol: Annotated::new("http/1.1".to_owned()),
364 server_ip: Annotated::new(IpAddr("192.168.1.1".to_owned())),
365 phase: Annotated::new(NetworkReportPhases::Application),
366 sampling_fraction: Annotated::new(1.0),
367 referrer: Annotated::new("https://example.com/referer".to_owned()),
368 ..Default::default()
369 };
370
371 let report = NetworkReportRaw {
372 age: Annotated::new(5000),
373 ty: Annotated::new("network-error".to_owned()),
374 url: Annotated::new("https://example.com/api".to_owned()),
375 user_agent: Annotated::new("Mozilla/5.0".to_owned()),
376 body: Annotated::new(body),
377 ..Default::default()
378 };
379
380 let fixed_trace_id: TraceId = "de4e189601a342c6bee991645300852e".parse().unwrap();
382 let log =
383 create_log_with_trace_id(Annotated::new(report), received_at, Some(fixed_trace_id))
384 .unwrap();
385 insta::assert_json_snapshot!(SerializableAnnotated(&Annotated::new(log)));
386 }
387
388 #[test]
389 fn test_create_log_missing_body() {
390 let received_at = DateTime::parse_from_rfc3339("2021-04-26T08:00:05+00:00")
391 .unwrap()
392 .with_timezone(&Utc);
393
394 let nel_missing_body = NetworkReportRaw {
395 body: Annotated::empty(),
396 ..Default::default()
397 };
398
399 let log = create_log(Annotated::new(nel_missing_body), received_at);
400 assert!(log.is_none());
402 }
403
404 #[test]
405 fn test_create_log_empty_nel() {
406 let received_at = DateTime::parse_from_rfc3339("2021-04-26T08:00:05+00:00")
407 .unwrap()
408 .with_timezone(&Utc);
409
410 let log = create_log(Annotated::empty(), received_at);
411 assert!(log.is_none());
413 }
414
415 #[test]
416 fn test_create_log_dns_error() {
417 let received_at = DateTime::parse_from_rfc3339("2021-04-26T08:00:05+00:00")
418 .unwrap()
419 .with_timezone(&Utc);
420
421 let body = BodyRaw {
422 ty: Annotated::new("dns.unreachable".to_owned()),
423 elapsed_time: Annotated::new(2000),
424 method: Annotated::new("POST".to_owned()),
425 protocol: Annotated::new("http/2".to_owned()),
426 server_ip: Annotated::new(IpAddr("10.0.0.1".to_owned())),
427 phase: Annotated::new(NetworkReportPhases::DNS),
428 sampling_fraction: Annotated::new(0.5),
429 ..Default::default()
430 };
431
432 let report = NetworkReportRaw {
433 age: Annotated::new(1000),
434 ty: Annotated::new("network-error".to_owned()),
435 url: Annotated::new("https://api.example.com/v1/users".to_owned()),
436 user_agent: Annotated::new(
437 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)".to_owned(),
438 ),
439 body: Annotated::new(body),
440 ..Default::default()
441 };
442
443 let fixed_trace_id: TraceId = "825d2fca3bcd40d390a0b39fe7102e90".parse().unwrap();
445 let log =
446 create_log_with_trace_id(Annotated::new(report), received_at, Some(fixed_trace_id))
447 .unwrap();
448 insta::assert_json_snapshot!(SerializableAnnotated(&Annotated::new(log)));
449 }
450}