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