From 7b52cc11df646184d8d6aa242d2c32cdc0c57336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Capypara=20K=C3=B6pcke?= Date: Fri, 13 Oct 2023 16:35:33 +0200 Subject: [PATCH] feat(api): Support capturing user feedback This adds an API to the Sentry Python SDK that captures user feedback via envelope. This is implemented very similiarly to how it is done for the JavaScript SDK, see https://github.com/getsentry/sentry-javascript/pull/7729. Fixes GH-1064 --- docs/api.rst | 1 + sentry_sdk/__init__.py | 1 + sentry_sdk/_types.py | 5 ++++- sentry_sdk/api.py | 10 ++++++++++ sentry_sdk/client.py | 19 ++++++++++++++++++- sentry_sdk/envelope.py | 11 ++++++++++- sentry_sdk/hub.py | 16 ++++++++++++++++ tests/test_client.py | 30 ++++++++++++++++++++++++++++++ 8 files changed, 90 insertions(+), 3 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index f504bbb642..58ff7fcc0f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -12,6 +12,7 @@ Capturing Data .. autofunction:: sentry_sdk.api.capture_event .. autofunction:: sentry_sdk.api.capture_exception .. autofunction:: sentry_sdk.api.capture_message +.. autofunction:: sentry_sdk.api.capture_user_feedback Enriching Events diff --git a/sentry_sdk/__init__.py b/sentry_sdk/__init__.py index 562da90739..49e6be55da 100644 --- a/sentry_sdk/__init__.py +++ b/sentry_sdk/__init__.py @@ -22,6 +22,7 @@ "capture_event", "capture_message", "capture_exception", + "capture_user_feedback", "add_breadcrumb", "configure_scope", "push_scope", diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index e88d07b420..65c7acc198 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -19,7 +19,7 @@ from typing import Tuple from typing import Type from typing import Union - from typing_extensions import Literal + from typing_extensions import Literal, TypedDict ExcInfo = Tuple[ Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType] @@ -54,6 +54,7 @@ "internal", "profile", "statsd", + "user_report", ] SessionStatus = Literal["ok", "exited", "crashed", "abnormal"] EndpointType = Literal["store", "envelope"] @@ -116,3 +117,5 @@ FlushedMetricValue = Union[int, float] BucketKey = Tuple[MetricType, str, MeasurementUnit, MetricTagsInternal] + + UserFeedback = TypedDict('UserFeedback', {"event_id": str, "email": str, "name": str, "comments": str}) diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index f0c6a87432..6c98b32759 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -22,6 +22,7 @@ BreadcrumbHint, ExcInfo, MeasurementUnit, + UserFeedback, ) from sentry_sdk.tracing import Span @@ -39,6 +40,7 @@ def overload(x): "capture_event", "capture_message", "capture_exception", + "capture_user_feedback", "add_breadcrumb", "configure_scope", "push_scope", @@ -109,6 +111,14 @@ def capture_exception( return Hub.current.capture_exception(error, scope=scope, **scope_args) +@hubmethod +def capture_user_feedback( + feedback # type: UserFeedback +): + # type: (...) -> None + return Hub.current.capture_user_feedback(feedback) + + @hubmethod def add_breadcrumb( crumb=None, # type: Optional[Breadcrumb] diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 749ab23cfe..d6556f7e63 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -44,7 +44,7 @@ from typing import Sequence from sentry_sdk.scope import Scope - from sentry_sdk._types import Event, Hint + from sentry_sdk._types import Event, Hint, UserFeedback from sentry_sdk.session import Session @@ -633,6 +633,23 @@ def capture_session( else: self.session_flusher.add_session(session) + def capture_user_feedback( + self, + feedback, # type: UserFeedback + ): + # type: (...) -> None + """Captures user feedback. + + :param feedback: The user feedback to send to Sentry. + """ + headers = { + "event_id": feedback["event_id"], + "sent_at": format_timestamp(datetime_utcnow()), + } + envelope = Envelope(headers=headers) + envelope.add_user_feedback(feedback) + self.transport.capture_envelope(envelope) + def close( self, timeout=None, # type: Optional[float] diff --git a/sentry_sdk/envelope.py b/sentry_sdk/envelope.py index a3e4b5a940..e9dd3be5b5 100644 --- a/sentry_sdk/envelope.py +++ b/sentry_sdk/envelope.py @@ -15,7 +15,7 @@ from typing import List from typing import Iterator - from sentry_sdk._types import Event, EventDataCategory + from sentry_sdk._types import Event, EventDataCategory, UserFeedback def parse_json(data): @@ -94,6 +94,13 @@ def add_item( # type: (...) -> None self.items.append(item) + def add_user_feedback( + self, + feedback, # type: UserFeedback + ): + # type: (...) -> None + self.add_item(Item(payload=PayloadRef(json=feedback), type="user_report")) + def get_event(self): # type: (...) -> Optional[Event] for items in self.items: @@ -258,6 +265,8 @@ def data_category(self): return "error" elif ty == "client_report": return "internal" + elif ty == "user_report": + return "user_report" elif ty == "profile": return "profile" elif ty == "statsd": diff --git a/sentry_sdk/hub.py b/sentry_sdk/hub.py index 2525dc56f1..bf4c4c9240 100644 --- a/sentry_sdk/hub.py +++ b/sentry_sdk/hub.py @@ -51,6 +51,7 @@ Breadcrumb, BreadcrumbHint, ExcInfo, + UserFeedback, ) from sentry_sdk.consts import ClientConstructor @@ -403,6 +404,21 @@ def capture_exception(self, error=None, scope=None, **scope_args): return None + def capture_user_feedback(self, feedback): + # type: (UserFeedback) -> None + """ + Captures user feedback. + + :param feedback: The user feedback to send to Sentry. + + Alias of :py:meth:`sentry_sdk.Client.capture_user_feedback`. + """ + client, _ = self._stack[-1] + if client is not None: + client.capture_user_feedback(feedback) + + return None + def _capture_internal_exception( self, exc_info # type: Any ): diff --git a/tests/test_client.py b/tests/test_client.py index 5a7a5cff16..f2ac88e9a2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -15,6 +15,7 @@ capture_message, capture_exception, capture_event, + capture_user_feedback, start_transaction, set_tag, ) @@ -591,6 +592,35 @@ def test_capture_event_works(sentry_init): pytest.raises(EventCapturedError, lambda: capture_event({})) +def test_capture_user_feedback_works(sentry_init, capture_envelopes): + expected_event_id = "test_event_id" + expected_name = "test_name" + expected_email = "test_email" + expected_comments = "test_comments" + + sentry_init(attach_stacktrace=False) + envelopes = capture_envelopes() + + capture_user_feedback({ + "event_id": expected_event_id, + "email": expected_email, + "comments": expected_comments, + "name": expected_name, + }) + + assert len(envelopes) == 1 + user_feedback_envelope = envelopes[0] + assert user_feedback_envelope.headers["event_id"] == expected_event_id + assert len(user_feedback_envelope.items) == 1 + user_feedback_item = user_feedback_envelope.items[0] + assert user_feedback_item.data_category == "user_report" + assert user_feedback_item.headers["type"] == "user_report" + assert user_feedback_item.payload.json["event_id"] == expected_event_id + assert user_feedback_item.payload.json["email"] == expected_email + assert user_feedback_item.payload.json["name"] == expected_name + assert user_feedback_item.payload.json["comments"] == expected_comments + + @pytest.mark.parametrize("num_messages", [10, 20]) def test_atexit(tmpdir, monkeypatch, num_messages): app = tmpdir.join("app.py")