Skip to main content

relay_event_normalization/normalize/
nel.rs

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