relay_event_normalization/normalize/
nel.rs

1//! Contains helper function for NEL reports.
2
3use 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
13/// Mapping of NEL error types to their human-readable descriptions.
14///
15/// Based on W3C Network Error Logging specification and Chromium-specific extensions.
16static NEL_CULPRITS: &[(&str, &str)] = &[
17    // https://w3c.github.io/network-error-logging/#predefined-network-error-types
18    ("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    // Chromium-specific errors, not documented in the spec
111    // https://chromium.googlesource.com/chromium/src/+/HEAD/net/network_error_logging/network_error_logging_service.cc
112    ("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
152/// Lazy-initialized HashMap for fast NEL error type lookups.
153static NEL_CULPRITS_MAP: LazyLock<HashMap<&'static str, &'static str>> =
154    LazyLock::new(|| NEL_CULPRITS.iter().copied().collect());
155
156/// Extracts the domain or IP address from a server address string.
157///
158/// e.g. 123.123.123.123 -> 123.123.123.123
159/// e.g. https://example.com/foo?bar=1 -> example.com
160/// e.g. http://localhost:8080/foo?bar=1 -> localhost
161/// e.g. http://\[::1\]:8080/foo -> \[::1\]
162#[allow(rustdoc::bare_urls)]
163fn extract_server_address(server_address: &str) -> String {
164    // Try to parse as URL and extract host
165    if let Ok(url) = Url::parse(server_address) {
166        if let Some(host) = url.host_str() {
167            return host.to_owned();
168        }
169    }
170    // Fallback: URL parsing failed or no host found, return original
171    server_address.to_owned()
172}
173
174/// Gets the human-readable description for a NEL error type.
175fn 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
193/// Creates a human-readable message for a NEL report
194fn 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
200/// Creates a [`OurLog`] from the provided [`NetworkReportRaw`].
201pub fn create_log(nel: Annotated<NetworkReportRaw>, received_at: DateTime<Utc>) -> Option<OurLog> {
202    create_log_with_trace_id(nel, received_at, None)
203}
204
205/// Creates a [`OurLog`] from the provided [`NetworkReportRaw`] with an optional trace ID.
206/// If trace_id is None, a random one will be generated.
207pub 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    // Extract the error type string to avoid borrowing issues later
216    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    // Handle URL and extract server address if available
251    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    // Split protocol into name and version components
261    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                // e.g. "http"
266                add_string_attribute!("network.protocol.name", protocol);
267            }
268        }
269        if let Some(version) = parts.next() {
270            if !version.is_empty() {
271                // e.g. "1.1"
272                add_string_attribute!("network.protocol.version", version);
273            }
274        }
275    }
276
277    if let Some(server_ip) = body.server_ip.value() {
278        // server_ip contains the IP address of the server according to NEL spec
279        add_string_attribute!("server.address", server_ip.as_ref());
280    }
281
282    // NEL-specific attributes
283    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        // Test cases for create_message
312        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            // This tests the status code replacement in the message
325            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            // Unknown errors do not get a human-friendly message, but the error type is preserved
331            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        // Use a fixed timestamp for deterministic testing
352        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        // Use a fixed trace ID for deterministic testing
379        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        // No log is created because the body is missing
399        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        // No log is created because the NEL is empty
410        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        // Use a fixed trace ID for deterministic testing
442        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}