1# Copyright 2017 Christoph Reiter
2#
3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation; either version 2 of the License, or
6# (at your option) any later version.
7
8"""
9A wrapper API for sentry-raven to make it work in a GUI environment.
10
11We need to split the capturing phase from the submit phase so we can show
12the report to the user and provide feedback about the report submit process.
13This hacks it together while trying to not touch too many raven internals.
14
15It also only imports raven when needed since it takes quite a lot of time
16to import.
17"""
18
19import os
20import pprint
21from urllib.parse import urlencode
22
23from quodlibet.util.urllib import Request, urlopen
24
25
26class SentryError(Exception):
27    """Exception type for all the API below"""
28
29    pass
30
31
32def send_feedback(dsn, event_id, name, email, comment, timeout):
33    """Send feedback, blocking.
34
35    Args:
36        dsn (str): The DSN
37        event_id (str): The event ID this feedback should be attached to
38        name (str): The user name
39        email (str): The user email
40        comment (str): The feedback text
41        timeout (float): The timeout for this request
42    Raises:
43        SentryError: In case of timeout or other errors
44    """
45
46    name = str(name).encode("utf-8")
47    email = str(email).encode("utf-8")
48    comment = str(comment).encode("utf-8")
49
50    data = urlencode(
51        [('name', name), ('email', email), ('comments', comment)])
52    if not isinstance(data, bytes):
53        # py3
54        data = data.encode("utf-8")
55
56    headers = {"Referer": "https://quodlibet.github.io"}
57    params = urlencode([("dsn", dsn), ("eventId", event_id)])
58
59    try:
60        req = Request(
61            "https://sentry.io/api/embed/error-page/?" + params,
62            data=data, headers=headers)
63
64        urlopen(req, timeout=timeout).close()
65    except EnvironmentError as e:
66        raise SentryError(e)
67
68
69def urlopen_hack(**kwargs):
70    # There is no way to make raven use the system cert store. This makes
71    # it use the standard urlopen instead.
72    url = kwargs["url"]
73    data = kwargs["data"]
74    timeout = kwargs["timeout"]
75    return urlopen(url, data, timeout)
76
77
78class CapturedException(object):
79    """Contains the data to be send to sentry."""
80
81    def __init__(self, dsn, data):
82        """
83        Args:
84            dsn (str): the sentry.io DSN
85            data (object): some sentry internals
86        """
87
88        self._dsn = dsn
89        self._args, self._kwargs = data
90        self._comment = None
91
92    def get_report(self):
93        """Gives a textual representation of the collected data.
94
95        The goal is to give the user a way to see what is being send to the
96        sentry servers.
97
98        Returns:
99            str
100        """
101
102        lines = []
103        if self._args:
104            lines += pprint.pformat(self._args, width=40).splitlines()
105        if self._kwargs:
106            lines += pprint.pformat(self._kwargs, width=40).splitlines()
107
108        def compact(l):
109            level = len(l) - len(l.lstrip())
110            return u" " * (level // 4) + l.lstrip()
111
112        return u"\n".join(map(compact, lines))
113
114    def set_comment(self, comment):
115        """Attach a user provided comment to the error.
116        Something like "I clicked button X and then this happened"
117
118        Args:
119            comment (str)
120        """
121
122        self._comment = comment
123
124    def send(self, timeout):
125        """Submit the error including the user feedback. Blocking.
126
127        Args:
128            timeout (float): timeout for each request made
129        Returns:
130            str: The sentry event id
131        Raises:
132            SentryError
133        """
134
135        from raven import Client
136        from raven.transport import http
137        from raven.transport.http import HTTPTransport
138
139        http.urlopen = urlopen_hack
140
141        try:
142            raise Exception
143        except Exception:
144            client = Client(
145                self._dsn + "?timeout=%d" % timeout, install_sys_hook=False,
146                install_logging_hook=False, capture_locals=False,
147                transport=HTTPTransport)
148
149            # replace the captured data with the one we already have
150            old_send = client.send
151
152            def inject_data(*args, **kwargs):
153                kw = dict(self._kwargs)
154                kw["event_id"] = kwargs.get("event_id", "")
155                return old_send(*self._args, **kw)
156
157            client.send = inject_data
158
159            event_id = client.captureException()
160            if client.state.did_fail():
161                raise SentryError("captureException failed")
162
163            # fix leak
164            client.context.deactivate()
165
166            if self._comment:
167                send_feedback(self._dsn, event_id,
168                              "default", "email@example.com", self._comment,
169                              timeout)
170
171            return event_id
172
173
174class Sentry(object):
175    """The main object of our sentry API wrapper"""
176
177    def __init__(self, dsn):
178        """
179        Args:
180            dsn (str)
181        """
182
183        self._dsn = dsn
184        self._tags = {}
185
186    def add_tag(self, key, value):
187        """Attach tags to the error report.
188
189        Args:
190            key (str)
191            value (str)
192
193        The keys are arbitrary, but some have a special meaning:
194
195        * "release" will show up as a separate page in sentry
196        * "environment" will add a dropdown for grouping
197        """
198
199        self._tags[key] = value
200
201    def capture(self, exc_info=None, fingerprint=None):
202        """Captures the current exception and returns a CapturedException
203
204        The returned object contains everything needed to submit the error
205        at a later point in time (e.g. after pushing it to the main thread
206        and displaying it in the UI)
207
208        Args:
209            exc_info (tuple): a sys.exc_info() return value
210            fingerprint (List[str] or None):
211                fingerprint for custom grouping
212        Returns:
213            CapturedException
214        Raises:
215            SentryError: Raised if raven isn't installed or capturing failed
216                for some unknown reason.
217        """
218
219        try:
220            from raven import Client
221            from raven.transport import Transport
222            from raven.processors import Processor
223        except ImportError as e:
224            raise SentryError(e)
225
226        class DummyTransport(Transport):
227            """A sync raven transport which does nothing"""
228
229            def send(self, *args, **kwargs):
230                pass
231
232        # Some tags have a special meaning and conflict with info given to the
233        # client, so pass them to the client instead
234        tags = dict(self._tags)
235        kwargs = {}
236        if "release" in tags:
237            kwargs["release"] = tags.pop("release")
238        if "environment" in tags:
239            kwargs["environment"] = tags.pop("environment")
240        if "server_name" in tags:
241            kwargs["name"] = tags.pop("server_name")
242
243        # It would default to the hostname otherwise
244        kwargs.setdefault("name", "default")
245
246        # We use a dummy transport and intercept the captured data
247        client = Client(
248            self._dsn, install_sys_hook=False, install_logging_hook=False,
249            capture_locals=True, transport=DummyTransport, tags=tags, **kwargs)
250
251        data = [None]
252
253        old_send = client.send
254
255        def save_state(*args, **kwargs):
256            data[0] = (args, kwargs)
257            return old_send(*args, **kwargs)
258
259        client.send = save_state
260        client.captureException(exc_info, fingerprint=fingerprint)
261        if data[0] is None:
262            raise SentryError("Failed to capture")
263
264        class SanitizePaths(Processor):
265            """Makes filename on Windows match the Linux one.
266            Also adjust abs_path, so it still contains filename.
267            """
268
269            def filter_stacktrace(self, data, **kwargs):
270                for frame in data.get('frames', []):
271                    if frame.get("abs_path"):
272                        frame["abs_path"] = \
273                            frame["abs_path"].replace(os.sep, "/")
274                    if frame.get("filename"):
275                        frame["filename"] = \
276                            frame["filename"].replace(os.sep, "/")
277
278        SanitizePaths(client).process(data[0][1])
279
280        # fix leak
281        client.context.deactivate()
282
283        return CapturedException(self._dsn, data[0])
284