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")