-
-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathflask_error.py
More file actions
executable file
·360 lines (319 loc) · 14.7 KB
/
flask_error.py
File metadata and controls
executable file
·360 lines (319 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# -*- coding: utf-8 -*-
#
# Error tracker's flask plugin, this class initialize it's internal state
#
# :copyright: 2020 Sonu Kumar
# :license: BSD-3-Clause
#
from warnings import warn
try:
from flask import _app_ctx_stack as stack
except ImportError:
from flask import _request_ctx_stack as stack
from flask import request
from error_tracker.libs.utils import Masking, ConfigError, get_exception_name, \
get_context_detail, get_notification_subject
from error_tracker.libs.mixins import ModelMixin
from error_tracker.flask.utils import DefaultFlaskContextBuilder, DefaultFlaskViewPermission
from .view import Views
from . import defaults
import datetime
from sqlalchemy import Column, String, Text, Integer, DateTime, desc
from sqlalchemy.orm.exc import NoResultFound
page_size = None
class AppErrorTracker(object):
def __init__(self, app=None, db=None, model=None, notifier=None, recipients=None,
db_table_name="app_error", notification_subject_prefix=None,
url_prefix=None, masking=None, ticketing=None,
context_builder=DefaultFlaskContextBuilder(),
view_permission=DefaultFlaskViewPermission()):
"""
An error tracker class, which manage the exception and store them to database
:param app: a Flask app instance where exception has to be tracked
:param db: Database connection object for storing exception
:param model: Database model object for tracking exception
:param notifier: a notifier object that would notify notification to the subscribers
:param recipients: list of recipients emails
:param db_table_name: database table name in case if it's required overriding
:param notification_subject_prefix: notification subject prefix
:param url_prefix: URL prefix to be exposed by the web apps
:param masking: a masking object or lambda function that can provide custom masking rules
:param context_builder: a builder function that provides the request details,
it can be used to provide custom data, apart from the default one
:param ticketing: a ticking mixing object used to create or update ticket in ticketing system
"""
# Database related fields
self.app = app
self.db = db
self.db_table = db_table_name
self.model = model
# masking related fields
self.masking = masking
# context builder object
self.context_builder = context_builder
self.views = None
# notification related fields
self.notifier = notifier
self.notification_subject_prefix = notification_subject_prefix
self.send_notification = False
self.recipients = recipients
self.notification_sender = None
self.ticketing = ticketing
self.view_permission = view_permission
self.active = False
if self.app:
self.init_app(app, db, model=model, notifier=notifier, url_prefix=url_prefix,
masking=masking, context_builder=context_builder,
ticketing=ticketing, view_permission=view_permission)
def init_app(self, app, db, model=None, notifier=None, url_prefix=None, masking=None,
context_builder=DefaultFlaskContextBuilder(),
ticketing=None, view_permission=DefaultFlaskViewPermission()):
"""
Initialize this app with different attributes
:param view_permission: view permission checker
:param app: a Flask app instance
:param db: Database connection object
:param model: Database model class
:param notifier: notifier app which will be used to notify notification
:param url_prefix: Url prefix for WEB UI
:param masking: a masking interface object or lambda function
:param context_builder: a builder function that provides the request details,
it can be used to provide custom data, apart from the default one
:param ticketing : a ticking mixing object used to create or update ticket in ticketing system
:return: None
"""
if self.active:
raise ConfigError("App is already configured")
self.view_permission = view_permission
self.app = app
self.db = db
if app is None:
raise ConfigError("app is None")
if model is None and self.db is None:
raise ConfigError("Either db or model must be provide")
send_notification = self.app.config.setdefault('APP_ERROR_SEND_NOTIFICATION',
defaults.APP_ERROR_SEND_NOTIFICATION)
recipients = self.app.config.setdefault('APP_ERROR_RECIPIENT_EMAIL',
defaults.APP_ERROR_RECIPIENT_EMAIL)
subject_prefix = self.app.config.setdefault('APP_ERROR_SUBJECT_PREFIX',
defaults.APP_ERROR_SUBJECT_PREFIX)
mask_with = self.app.config.setdefault('APP_ERROR_MASK_WITH', defaults.APP_ERROR_MASK_WITH)
mask_key_has = self.app.config.setdefault('APP_ERROR_MASKED_KEY_HAS',
defaults.APP_ERROR_MASKED_KEY_HAS)
url_prefix = url_prefix or self.app.config.setdefault('APP_ERROR_URL_PREFIX',
defaults.APP_ERROR_URL_PREFIX)
self.notification_sender = self.app.config.setdefault('APP_ERROR_EMAIL_SENDER',
defaults.APP_ERROR_EMAIL_SENDER)
global page_size
self.model = model or self._get_model()
self.views = Views(self.app, self.model, url_prefix, self.view_permission)
page_size = self.app.config.setdefault("APP_DEFAULT_LIST_SIZE", defaults.APP_DEFAULT_LIST_SIZE)
# masking object setting
if type(mask_key_has) == str:
mask_key_has = (mask_key_has,)
if mask_key_has and mask_with:
self.masking = masking or Masking(mask_with, mask_key_has)
self.notifier = notifier or getattr(app, "notifier", None) or getattr(app, "mailer", None)
self._set_notification_fields(recipients, send_notification, subject_prefix)
# Use the new style teardown_appcontext if it's available,
# otherwise fall back to the request context
if hasattr(app, 'teardown_appcontext'):
app.teardown_appcontext(self.teardown)
else:
app.teardown_request(self.teardown)
self.context_builder = context_builder
self.ticketing = ticketing
self.active = True
def _set_notification_fields(self, recipients, send_notification, subject_prefix):
if self.notifier is not None and send_notification:
if recipients not in [None, ""]:
self.send_notification = True
self.recipients = recipients
if type(self.recipients) == str:
self.recipients = [self.recipients]
if self.notification_sender is None:
raise ConfigError(
"Email recipients is set but notification sender is"
" not configured. set APP_ERROR_EMAIL_SENDER in app config")
self.recipients = list(self.recipients)
else:
warn("APP_ERROR_RECIPIENT_EMAIL is not set in the app config")
if subject_prefix in [None, ""]:
warn("APP_ERROR_SUBJECT_PREFIX is not set in the app config")
else:
self.notification_subject_prefix = subject_prefix
def teardown(self, exception):
pass
def _get_model(self):
"""
:return: Default model for storing exception
"""
class AppDbModel(ModelMixin, self.db.Model):
db = self.db
hash = Column(String(64), primary_key=True)
host = Column(String(2048))
path = Column(String(512))
method = Column(String(32))
request_data = Column(Text)
exception_name = Column(String(256))
traceback = Column(Text)
count = Column(Integer, default=1)
created_on = Column(DateTime)
last_seen = Column(DateTime)
def __repr__(self):
return "AppDbModel(%s)" % self.__str__()
@classmethod
def delete_entity(cls, rhash):
"""
:param rhash: lookup key
:return: None
"""
cls.query.filter_by(hash=rhash).delete()
cls.db.session.commit()
@classmethod
def create_or_update_entity(cls, rhash, host, path, method, request_data,
exception_name, traceback):
try:
error = cls.query.filter_by(hash=rhash).one()
error.count += 1
error.last_seen = datetime.datetime.now()
# error.exception = exception
cls.db.session.commit()
except NoResultFound:
now = datetime.datetime.now()
error = cls(hash=rhash, host=host, path=path, method=method,
request_data=str(request_data),
exception_name=exception_name, traceback=traceback,
created_on=now,
last_seen=now)
cls.db.session.add(error)
cls.db.session.commit()
return error
@classmethod
def get_exceptions_per_page(cls, page_number=1):
return cls.query.order_by(desc(cls.last_seen)).paginate(
page_number, page_size, False)
@classmethod
def get_entity(cls, rhash):
"""
:param rhash: key for lookup
:return: Single entry of this class
"""
error = cls.query.filter_by(hash=rhash).first()
return error
class Meta:
table_name = self.db_table
return AppDbModel
def _send_notification(self, url, method, message, exception, error):
subject = get_notification_subject(self.notification_subject_prefix,
method, url, exception)
self.notifier.notify(request, error, email_subject=subject,
email_body=message, from_email=self.notification_sender,
recipient_list=self.recipients)
def _post_process(self, rq, frame_str, frames, error):
if self.send_notification:
if rq:
message = ('URL: %s' % rq.path) + '\n\n'
method = rq.method
url = rq.url
else:
message = ""
method = ""
url = ""
message += frame_str
self._send_notification(url, method, message,
frames[-1][:-1], error)
if self.ticketing is not None:
self.ticketing.raise_ticket(error)
def capture_message(self, message):
"""
Capture an error for the current error
:param message: message to be recorded
:return: None
"""
self.capture_exception(additional_context={'message': message})
def capture_exception(self, additional_context=None):
"""
Record occurred exception, check whether the exception recording is
enabled or not. If it's enabled then record all the details and store
them in the database, it will notify email as well. The id of exception
is SHA256 of frame string
:return: None
"""
rq = request
if not self.active:
return
try:
path = request.path
host = request.host_url
method = request.method
except RuntimeError:
rq = None
path = ""
host = ""
method = ""
ty, frames, frame_str, traceback_str, rhash, request_data = \
get_context_detail(rq, self.masking, self.context_builder, additional_context)
error = self.model.create_or_update_entity(rhash, host, path, method,
str(request_data),
get_exception_name(ty),
traceback_str)
self._post_process(rq, frame_str, frames, error)
def auto_track_exception(self, func, additional_context=None, silent=False):
"""
Decorator to be used for automatic exception capture, where exception can occur
:param func:
:param additional_context: any additional context
:param silent: exception should be re-raise or ignored
:return: None
"""
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
self.capture_exception(additional_context)
if not silent:
raise e
return wrapper
def track_exception(self, func):
"""
Decorator to be used for automatic exception capture, on HTTP 500 etc,
where exception has already occurred
"""
def wrapper(e):
self.capture_exception()
return func(e)
return wrapper
def get_exceptions(self, page_number=1):
"""
Get list of exception objects from persistence store
:param page_number: documents of a specific page
:return: list of exception objects
"""
if self.model:
return self.model.get_exceptions_per_page(page_number=page_number).items
raise ConfigError
def get_exception(self, rhash):
"""
Get a specific exception, can be used for some customization etc
:param rhash: hash of the exception
:return: exception object
"""
if self.model:
return self.model.get_entity(rhash)
raise ConfigError
def delete_exception(self, rhash):
"""
Delete a specific exception from database
:param rhash: hash of the exception
:return: whatever model returns
"""
if self.model:
return self.model.delete_entity(rhash)
raise ConfigError
def create_or_update_exception(self, rhash, host, path, method, request_data,
exception_name, traceback):
if self.model:
return self.model.create_or_update_entity(rhash, host, path, method, request_data,
exception_name, traceback)
raise ConfigError