diff --git a/src/sentry/lang/java/processing.py b/src/sentry/lang/java/processing.py index 841d934e202102..62421f695666f6 100644 --- a/src/sentry/lang/java/processing.py +++ b/src/sentry/lang/java/processing.py @@ -182,6 +182,11 @@ def process_jvm_stacktraces(symbolicator: Symbolicator, data: Any) -> Any: stacktrace_infos = find_stacktraces_in_data(data) stacktraces = [ { + **( + {"exception": {"type": sinfo.exception_type, "module": sinfo.exception_module}} + if sinfo.exception_type and sinfo.exception_module + else {} + ), "frames": [ _normalize_frame(frame, index) for index, frame in enumerate(sinfo.stacktrace.get("frames") or ()) diff --git a/src/sentry/stacktraces/processing.py b/src/sentry/stacktraces/processing.py index 78994412a0d236..754128dd7908b5 100644 --- a/src/sentry/stacktraces/processing.py +++ b/src/sentry/stacktraces/processing.py @@ -40,6 +40,8 @@ class StacktraceInfo(NamedTuple): container: dict[str, Any] platforms: set[str] is_exception: bool + exception_type: str | None + exception_module: str | None def __hash__(self) -> int: return id(self) @@ -50,6 +52,14 @@ def __eq__(self, other: object) -> bool: def __ne__(self, other: object) -> bool: return self is not other + def get_exception(self) -> str | None: + """Returns the fully qualified exception name (module.type) or None if not an exception.""" + if self.exception_type is None: + return None + if self.exception_module: + return f"{self.exception_module}.{self.exception_type}" + return self.exception_type + def get_frames(self) -> Sequence[dict[str, Any]]: return _safe_get_frames(self.stacktrace) @@ -225,6 +235,10 @@ def _append_stacktrace( container: Any = None, # Whether or not the container is from `exception.values` is_exception: bool = False, + # The exception type (e.g., "ValueError") if this stacktrace belongs to an exception + exception_type: str | None = None, + # The exception module (e.g., "__builtins__") if this stacktrace belongs to an exception + exception_module: str | None = None, # Prevent skipping empty/null stacktraces from `exception.values` (other empty/null # stacktraces are always skipped) include_empty_exceptions: bool = False, @@ -244,6 +258,8 @@ def _append_stacktrace( container=container, platforms=platforms, is_exception=is_exception, + exception_type=exception_type, + exception_module=exception_module, ) ) @@ -253,6 +269,8 @@ def _append_stacktrace( exc.get("stacktrace"), container=exc, is_exception=True, + exception_type=exc.get("type"), + exception_module=exc.get("module"), include_empty_exceptions=include_empty_exceptions, ) diff --git a/tests/sentry/test_stacktraces.py b/tests/sentry/test_stacktraces.py index 3df96da8d27933..2ae04e743f1d45 100644 --- a/tests/sentry/test_stacktraces.py +++ b/tests/sentry/test_stacktraces.py @@ -36,6 +36,11 @@ def test_stacktraces_basics(self) -> None: assert len(infos) == 1 assert len(infos[0].stacktrace["frames"]) == 2 assert infos[0].platforms == {"javascript", "native"} + # Top-level stacktraces are not exceptions + assert infos[0].is_exception is False + assert infos[0].exception_type is None + assert infos[0].exception_module is None + assert infos[0].get_exception() is None def test_stacktraces_exception(self) -> None: data: dict[str, Any] = { @@ -69,6 +74,42 @@ def test_stacktraces_exception(self) -> None: infos = find_stacktraces_in_data(data) assert len(infos) == 1 assert len(infos[0].stacktrace["frames"]) == 2 + # Exception stacktraces have type but no module in this case + assert infos[0].is_exception is True + assert infos[0].exception_type == "Error" + assert infos[0].exception_module is None + assert infos[0].get_exception() == "Error" + + def test_stacktraces_exception_with_module(self) -> None: + data: dict[str, Any] = { + "message": "hello", + "platform": "java", + "exception": { + "values": [ + { + "type": "RuntimeException", + "module": "java.lang", + "stacktrace": { + "frames": [ + { + "function": "main", + "module": "com.example.App", + "filename": "App.java", + "lineno": 10, + }, + ] + }, + } + ] + }, + } + + infos = find_stacktraces_in_data(data) + assert len(infos) == 1 + assert infos[0].is_exception is True + assert infos[0].exception_type == "RuntimeException" + assert infos[0].exception_module == "java.lang" + assert infos[0].get_exception() == "java.lang.RuntimeException" def test_stacktraces_threads(self) -> None: data: dict[str, Any] = { @@ -102,6 +143,11 @@ def test_stacktraces_threads(self) -> None: infos = find_stacktraces_in_data(data) assert len(infos) == 1 assert len(infos[0].stacktrace["frames"]) == 2 + # Thread stacktraces are not exceptions + assert infos[0].is_exception is False + assert infos[0].exception_type is None + assert infos[0].exception_module is None + assert infos[0].get_exception() is None def test_find_stacktraces_skip_none(self) -> None: # This tests: @@ -147,6 +193,10 @@ def test_find_stacktraces_skip_none(self) -> None: assert len(infos) == 4 assert sum(1 for x in infos if x.stacktrace) == 3 assert sum(1 for x in infos if x.is_exception) == 4 + # All exceptions have type "Error" and no module + assert all(x.exception_type == "Error" for x in infos) + assert all(x.exception_module is None for x in infos) + assert all(x.get_exception() == "Error" for x in infos) # XXX: The null frame is still part of this stack trace! assert len(infos[3].stacktrace["frames"]) == 3 @@ -154,6 +204,8 @@ def test_find_stacktraces_skip_none(self) -> None: assert len(infos) == 1 # XXX: The null frame is still part of this stack trace! assert len(infos[0].stacktrace["frames"]) == 3 + assert infos[0].exception_type == "Error" + assert infos[0].get_exception() == "Error" @pytest.mark.parametrize(