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