Skip to content

Commit

Permalink
Network Error Logging: backend part (#55135)
Browse files Browse the repository at this point in the history
Sentry backend part of the NEL ([Network Error
Logging](https://developer.mozilla.org/en-US/docs/Web/HTTP/Network_Error_Logging))
implementation.

* Added a new `nel` event type and interface
* Special logic for metadata and culprit generation

To be deployed after merging Relay part
(getsentry/relay#2421) and releasing
`sentry-relay` python package.

---------

Co-authored-by: mdtro <matthew.trostel@sentry.io>
Co-authored-by: Joris Bayer <joris.bayer@sentry.io>
  • Loading branch information
3 people authored Nov 9, 2023
1 parent d593b51 commit 685d98c
Showing 13 changed files with 137 additions and 2 deletions.
5 changes: 4 additions & 1 deletion api-docs/components/schemas/key.json
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@
},
"dsn": {
"type": "object",
"required": ["cdn", "csp", "minidump", "public", "secret", "security"],
"required": ["cdn", "csp", "minidump", "nel", "public", "secret", "security"],
"properties": {
"cdn": {
"type": "string"
@@ -36,6 +36,9 @@
"minidump": {
"type": "string"
},
"nel": {
"type": "string"
},
"public": {
"type": "string"
},
2 changes: 2 additions & 0 deletions src/sentry/api/serializers/models/project_key.py
Original file line number Diff line number Diff line change
@@ -23,6 +23,7 @@ class DSN(TypedDict):
csp: str
security: str
minidump: str
nel: str
unreal: str
cdn: str

@@ -85,6 +86,7 @@ def serialize(
"csp": obj.csp_endpoint,
"security": obj.security_endpoint,
"minidump": obj.minidump_endpoint,
"nel": obj.nel_endpoint,
"unreal": obj.unreal_endpoint,
"cdn": obj.js_sdk_loader_cdn_url,
},
2 changes: 2 additions & 0 deletions src/sentry/apidocs/examples/project_examples.py
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@
"csp": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/csp-report/?sentry_key=a785682ddda719b7a8a4011110d75598",
"security": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/security/?sentry_key=a785682ddda719b7a8a4011110d75598",
"minidump": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/minidump/?sentry_key=a785682ddda719b7a8a4011110d75598",
"nel": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/nel/?sentry_key=a785682ddda719b7a8a4011110d75598",
"unreal": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/unreal/a785682ddda719b7a8a4011110d75598/",
"cdn": "https://js.sentry-cdn.com/a785682ddda719b7a8a4011110d75598.min.js",
},
@@ -43,6 +44,7 @@
"csp": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/csp-report/?sentry_key=a785682ddda719b7a8a4011110d75598",
"security": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/security/?sentry_key=a785682ddda719b7a8a4011110d75598",
"minidump": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/minidump/?sentry_key=a785682ddda719b7a8a4011110d75598",
"nel": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/nel/?sentry_key=a785682ddda719b7a8a4011110d75598",
"unreal": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/unreal/a785682ddda719b7a8a4011110d75598/",
"cdn": "https://js.sentry-cdn.com/a785682ddda719b7a8a4011110d75598.min.js",
},
1 change: 1 addition & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
@@ -1974,6 +1974,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"hpkp": "sentry.interfaces.security.Hpkp",
"expectct": "sentry.interfaces.security.ExpectCT",
"expectstaple": "sentry.interfaces.security.ExpectStaple",
"nel": "sentry.interfaces.nel.Nel",
"exception": "sentry.interfaces.exception.Exception",
"logentry": "sentry.interfaces.message.Message",
"request": "sentry.interfaces.http.Http",
50 changes: 50 additions & 0 deletions src/sentry/constants.py
Original file line number Diff line number Diff line change
@@ -707,6 +707,56 @@ def from_str(cls, string: str) -> int:
"*/ping",
]


NEL_CULPRITS = {
# https://w3c.github.io/network-error-logging/#predefined-network-error-types
"dns.unreachable": "DNS server is unreachable",
"dns.name_not_resolved": "DNS server responded but is unable to resolve the address",
"dns.failed": "Request to the DNS server failed due to reasons not covered by previous errors",
"dns.address_changed": "Indicates that the resolved IP address for a request's origin has changed since the corresponding NEL policy was received",
"tcp.timed_out": "TCP connection to the server timed out",
"tcp.closed": "The TCP connection was closed by the server",
"tcp.reset": "The TCP connection was reset",
"tcp.refused": "The TCP connection was refused by the server",
"tcp.aborted": "The TCP connection was aborted",
"tcp.address_invalid": "The IP address is invalid",
"tcp.address_unreachable": "The IP address is unreachable",
"tcp.failed": "The TCP connection failed due to reasons not covered by previous errors",
"tls.version_or_cipher_mismatch": "The TLS connection was aborted due to version or cipher mismatch",
"tls.bad_client_auth_cert": "The TLS connection was aborted due to invalid client certificate",
"tls.cert.name_invalid": "The TLS connection was aborted due to invalid name",
"tls.cert.date_invalid": "The TLS connection was aborted due to invalid certificate date",
"tls.cert.authority_invalid": "The TLS connection was aborted due to invalid issuing authority",
"tls.cert.invalid": "The TLS connection was aborted due to invalid certificate",
"tls.cert.revoked": "The TLS connection was aborted due to revoked server certificate",
"tls.cert.pinned_key_not_in_cert_chain": "The TLS connection was aborted due to a key pinning error",
"tls.protocol.error": "The TLS connection was aborted due to a TLS protocol error",
"tls.failed": "The TLS connection failed due to reasons not covered by previous errors",
"http.error": "The user agent successfully received a response, but it had a {} status code",
"http.protocol.error": "The connection was aborted due to an HTTP protocol error",
"http.response.invalid": "Response is empty, has a content-length mismatch, has improper encoding, and/or other conditions that prevent user agent from processing the response",
"http.response.redirect_loop": "The request was aborted due to a detected redirect loop",
"http.failed": "The connection failed due to errors in HTTP protocol not covered by previous errors",
"abandoned": "User aborted the resource fetch before it is complete",
"unknown": "error type is unknown",
# Chromium-specific errors, not documented in the spec
# https://chromium.googlesource.com/chromium/src/+/HEAD/net/network_error_logging/network_error_logging_service.cc
"dns.protocol": "ERR_DNS_MALFORMED_RESPONSE",
"dns.server": "ERR_DNS_SERVER_FAILED",
"tls.unrecognized_name_alert": "ERR_SSL_UNRECOGNIZED_NAME_ALERT",
"h2.ping_failed": "ERR_HTTP2_PING_FAILED",
"h2.protocol.error": "ERR_HTTP2_PROTOCOL_ERROR",
"h3.protocol.error": "ERR_QUIC_PROTOCOL_ERROR",
"http.response.invalid.empty": "ERR_EMPTY_RESPONSE",
"http.response.invalid.content_length_mismatch": "ERR_CONTENT_LENGTH_MISMATCH",
"http.response.invalid.incomplete_chunked_encoding": "ERR_INCOMPLETE_CHUNKED_ENCODING",
"http.response.invalid.invalid_chunked_encoding": "ERR_INVALID_CHUNKED_ENCODING",
"http.request.range_not_satisfiable": "ERR_REQUEST_RANGE_NOT_SATISFIABLE",
"http.response.headers.truncated": "ERR_RESPONSE_HEADERS_TRUNCATED",
"http.response.headers.multiple_content_disposition": "ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION",
"http.response.headers.multiple_content_length": "ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_LENGTH",
}

# Generated from https://raw.githubusercontent.com/github-linguist/linguist/master/lib/linguist/languages.yml and our list of platforms/languages
EXTENSION_LANGUAGE_MAP = {
"c": "c",
13 changes: 12 additions & 1 deletion src/sentry/culprit.py
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
code that generates it.
"""

from sentry.constants import MAX_CULPRIT_LENGTH
from sentry.constants import MAX_CULPRIT_LENGTH, NEL_CULPRITS
from sentry.utils.safe import get_path
from sentry.utils.strings import truncatechars

@@ -34,6 +34,9 @@ def generate_culprit(data):
if not culprit and stacktraces:
culprit = get_stacktrace_culprit(get_path(stacktraces, -1), platform=platform)

if not culprit and data.get("nel"):
culprit = get_nel_culprit(data.get("nel"))

if not culprit and data.get("request"):
culprit = get_path(data, "request", "url")

@@ -69,3 +72,11 @@ def get_frame_culprit(frame, platform):
# to a unicode string if needed.
return "{}({})".format(frame.get("function") or "?", fileloc)
return "{} in {}".format(fileloc, frame.get("function") or "?")


def get_nel_culprit(data_nel):
body = data_nel.get("body")
ty = body.get("type", "<missing>")
if ty == "http.error":
return NEL_CULPRITS[ty].format(body.get("status_code"))
return NEL_CULPRITS.get(ty, ty)
3 changes: 3 additions & 0 deletions src/sentry/eventtypes/__init__.py
Original file line number Diff line number Diff line change
@@ -5,13 +5,15 @@
from sentry.eventtypes.feedback import FeedbackEvent
from sentry.eventtypes.generic import GenericEvent
from sentry.eventtypes.manager import EventTypeManager
from sentry.eventtypes.nel import NelEvent
from sentry.eventtypes.security import CspEvent, ExpectCTEvent, ExpectStapleEvent, HpkpEvent
from sentry.eventtypes.transaction import TransactionEvent

default_manager = EventTypeManager()
default_manager.register(DefaultEvent)
default_manager.register(ErrorEvent)
default_manager.register(CspEvent)
default_manager.register(NelEvent)
default_manager.register(HpkpEvent)
default_manager.register(ExpectCTEvent)
default_manager.register(ExpectStapleEvent)
@@ -27,6 +29,7 @@
DefaultEvent,
ErrorEvent,
CspEvent,
NelEvent,
HpkpEvent,
ExpectCTEvent,
ExpectStapleEvent,
10 changes: 10 additions & 0 deletions src/sentry/eventtypes/nel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from .base import DefaultEvent


class NelEvent(DefaultEvent):
key = "nel"

def extract_metadata(self, data):
metadata = super().extract_metadata(data)
metadata["uri"] = data.get("request").get("url")
return metadata
11 changes: 11 additions & 0 deletions src/sentry/interfaces/nel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
__all__ = "Nel"

from sentry.interfaces.base import Interface


class Nel(Interface):
"""
A browser NEL report.
"""

title = "NEL report"
6 changes: 6 additions & 0 deletions src/sentry/models/projectkey.py
Original file line number Diff line number Diff line change
@@ -209,6 +209,12 @@ def security_endpoint(self):

return f"{endpoint}/api/{self.project_id}/security/?sentry_key={self.public_key}"

@property
def nel_endpoint(self):
endpoint = self.get_endpoint()

return f"{endpoint}/api/{self.project_id}/nel/?sentry_key={self.public_key}"

@property
def minidump_endpoint(self):
endpoint = self.get_endpoint()
2 changes: 2 additions & 0 deletions src/sentry/utils/canonical.py
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@
"request": ("sentry.interfaces.Http",),
"user": ("sentry.interfaces.User",),
"csp": ("sentry.interfaces.Csp",),
"nel": ("sentry.interfaces.Nel",),
"breadcrumbs": ("sentry.interfaces.Breadcrumbs",),
"contexts": ("sentry.interfaces.Contexts",),
"threads": ("sentry.interfaces.Threads",),
@@ -35,6 +36,7 @@
"sentry.interfaces.Http": ("request",),
"sentry.interfaces.User": ("user",),
"sentry.interfaces.Csp": ("csp",),
"sentry.interfaces.Nel": ("nel",),
"sentry.interfaces.Breadcrumbs": ("breadcrumbs",),
"sentry.interfaces.Contexts": ("contexts",),
"sentry.interfaces.Threads": ("threads",),
17 changes: 17 additions & 0 deletions tests/sentry/event_manager/test_generate_culprit.py
Original file line number Diff line number Diff line change
@@ -105,3 +105,20 @@ def test_truncation():
def test_hash_from_values():
result = hash_from_values(["foo", "bar", "foô"])
assert result == "6d81588029ed4190110b2779ba952a00"


def test_nel_culprit():
data = {"nel": {"body": {"phase": "application", "type": "http.error", "status_code": 418}}}
assert (
generate_culprit(data)
== "The user agent successfully received a response, but it had a 418 status code"
)

data = {"nel": {"body": {"phase": "connection", "type": "tcp.reset"}}}
assert generate_culprit(data) == "The TCP connection was reset"

data = {"nel": {"body": {"phase": "dns", "type": "dns.weird"}}}
assert generate_culprit(data) == "dns.weird"

data = {"nel": {"body": {"phase": "dns"}}}
assert generate_culprit(data) == "<missing>"
17 changes: 17 additions & 0 deletions tests/sentry/eventtypes/test_nel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from sentry.eventtypes.nel import NelEvent
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import region_silo_test


@region_silo_test(stable=True)
class NelEventTest(TestCase):
def test_get_metadata(self):
inst = NelEvent()
data = {
"logentry": {"formatted": "connection / tcp.refused"},
"request": {"url": "https://example.com/"},
}
assert inst.get_metadata(data) == {
"title": "connection / tcp.refused",
"uri": "https://example.com/",
}

0 comments on commit 685d98c

Please sign in to comment.