|
1 #!/usr/bin/env python |
|
2 # |
|
3 # Copyright 2007 Google Inc. |
|
4 # |
|
5 # Licensed under the Apache License, Version 2.0 (the "License"); |
|
6 # you may not use this file except in compliance with the License. |
|
7 # You may obtain a copy of the License at |
|
8 # |
|
9 # http://www.apache.org/licenses/LICENSE-2.0 |
|
10 # |
|
11 # Unless required by applicable law or agreed to in writing, software |
|
12 # distributed under the License is distributed on an "AS IS" BASIS, |
|
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
14 # See the License for the specific language governing permissions and |
|
15 # limitations under the License. |
|
16 # |
|
17 |
|
18 """A logging handler that records information about unique exceptions. |
|
19 |
|
20 'Unique' in this case is defined as a given (exception class, location) tuple. |
|
21 Unique exceptions are logged to the datastore with an example stacktrace and an |
|
22 approximate count of occurrences, grouped by day and application version. |
|
23 |
|
24 A cron handler, in google.appengine.ext.ereporter.report_generator, constructs |
|
25 and emails a report based on the previous day's exceptions. |
|
26 |
|
27 Example usage: |
|
28 |
|
29 In your handler script(s), add: |
|
30 |
|
31 import logging |
|
32 from google.appengine.ext import ereporter |
|
33 |
|
34 ereporter.register_logger() |
|
35 |
|
36 In your app.yaml, add: |
|
37 |
|
38 handlers: |
|
39 - url: /_ereporter/.* |
|
40 script: $PYTHON_LIB/google/appengine/ext/ereporter/report_generator.py |
|
41 login: admin |
|
42 |
|
43 In your cron.yaml, add: |
|
44 |
|
45 cron: |
|
46 - description: Daily exception report |
|
47 url: /_ereporter?sender=you@yourdomain.com |
|
48 schedule: every day 00:00 |
|
49 |
|
50 This will cause a daily exception report to be generated and emailed to all |
|
51 admins, with exception traces grouped by minor version. If you only want to |
|
52 get exception information for the most recent minor version, add the |
|
53 'versions=latest' argument to the query string. For other valid query string |
|
54 arguments, see report_generator.py. |
|
55 |
|
56 If you anticipate a lot of exception traces (for example, if you're deploying |
|
57 many minor versions, each of which may have its own set of exceptions), you |
|
58 can ensure that the traces from the newest minor versions get included by adding |
|
59 this to your index.yaml: |
|
60 |
|
61 indexes: |
|
62 - kind: __google_ExceptionRecord |
|
63 properties: |
|
64 - name: date |
|
65 - name: major_version |
|
66 - name: minor_version |
|
67 direction: desc |
|
68 """ |
|
69 |
|
70 |
|
71 |
|
72 |
|
73 |
|
74 import datetime |
|
75 import logging |
|
76 import os |
|
77 import sha |
|
78 import traceback |
|
79 import urllib |
|
80 |
|
81 from google.appengine.api import memcache |
|
82 from google.appengine.ext import db |
|
83 from google.appengine.ext import webapp |
|
84 |
|
85 |
|
86 MAX_SIGNATURE_LENGTH = 256 |
|
87 |
|
88 |
|
89 class ExceptionRecord(db.Model): |
|
90 """Datastore model for a record of a unique exception.""" |
|
91 |
|
92 signature = db.StringProperty(required=True) |
|
93 major_version = db.StringProperty(required=True) |
|
94 minor_version = db.IntegerProperty(required=True) |
|
95 date = db.DateProperty(required=True) |
|
96 count = db.IntegerProperty(required=True, default=0) |
|
97 |
|
98 stacktrace = db.TextProperty(required=True) |
|
99 http_method = db.TextProperty(required=True) |
|
100 url = db.TextProperty(required=True) |
|
101 handler = db.TextProperty(required=True) |
|
102 |
|
103 @classmethod |
|
104 def get_key_name(cls, signature, version, date=None): |
|
105 """Generates a key name for an exception record. |
|
106 |
|
107 Args: |
|
108 signature: A signature representing the exception and its site. |
|
109 version: The major/minor version of the app the exception occurred in. |
|
110 date: The date the exception occurred. |
|
111 |
|
112 Returns: |
|
113 The unique key name for this exception record. |
|
114 """ |
|
115 if not date: |
|
116 date = datetime.date.today() |
|
117 return '%s@%s:%s' % (signature, date, version) |
|
118 |
|
119 |
|
120 class ExceptionRecordingHandler(logging.Handler): |
|
121 """A handler that records exception data to the App Engine datastore.""" |
|
122 |
|
123 def __init__(self, log_interval=10): |
|
124 """Constructs a new ExceptionRecordingHandler. |
|
125 |
|
126 Args: |
|
127 log_interval: The minimum interval at which we will log an individual |
|
128 exception. This is a per-exception timeout, so doesn't affect the |
|
129 aggregate rate of exception logging, only the rate at which we record |
|
130 ocurrences of a single exception, to prevent datastore contention. |
|
131 """ |
|
132 self.log_interval = log_interval |
|
133 logging.Handler.__init__(self) |
|
134 |
|
135 @classmethod |
|
136 def __RelativePath(cls, path): |
|
137 """Rewrites a path to be relative to the app's root directory. |
|
138 |
|
139 Args: |
|
140 path: The path to rewrite. |
|
141 |
|
142 Returns: |
|
143 The path with the prefix removed, if that prefix matches the app's |
|
144 root directory. |
|
145 """ |
|
146 cwd = os.getcwd() |
|
147 if path.startswith(cwd): |
|
148 path = path[len(cwd)+1:] |
|
149 return path |
|
150 |
|
151 @classmethod |
|
152 def __GetSignature(cls, exc_info): |
|
153 """Returns a unique signature string for an exception. |
|
154 |
|
155 Args: |
|
156 exc_info: The exc_info object for an exception. |
|
157 |
|
158 Returns: |
|
159 A unique signature string for the exception, consisting of fully |
|
160 qualified exception name and call site. |
|
161 """ |
|
162 ex_type, unused_value, trace = exc_info |
|
163 frames = traceback.extract_tb(trace) |
|
164 |
|
165 fulltype = '%s.%s' % (ex_type.__module__, ex_type.__name__) |
|
166 path, line_no = frames[-1][:2] |
|
167 path = cls.__RelativePath(path) |
|
168 site = '%s:%d' % (path, line_no) |
|
169 signature = '%s@%s' % (fulltype, site) |
|
170 if len(signature) > MAX_SIGNATURE_LENGTH: |
|
171 signature = 'hash:%s' % sha.new(signature).hexdigest() |
|
172 |
|
173 return signature |
|
174 |
|
175 @classmethod |
|
176 def __GetURL(cls): |
|
177 """Returns the URL of the page currently being served. |
|
178 |
|
179 Returns: |
|
180 The full URL of the page currently being served. |
|
181 """ |
|
182 if os.environ['SERVER_PORT'] == '80': |
|
183 scheme = 'http://' |
|
184 else: |
|
185 scheme = 'https://' |
|
186 host = os.environ['SERVER_NAME'] |
|
187 script_name = urllib.quote(os.environ['SCRIPT_NAME']) |
|
188 path_info = urllib.quote(os.environ['PATH_INFO']) |
|
189 qs = os.environ.get('QUERY_STRING', '') |
|
190 if qs: |
|
191 qs = '?' + qs |
|
192 return scheme + host + script_name + path_info + qs |
|
193 |
|
194 def __GetFormatter(self): |
|
195 """Returns the log formatter for this handler. |
|
196 |
|
197 Returns: |
|
198 The log formatter to use. |
|
199 """ |
|
200 if self.formatter: |
|
201 return self.formatter |
|
202 else: |
|
203 return logging._defaultFormatter |
|
204 |
|
205 def emit(self, record): |
|
206 """Log an error to the datastore, if applicable. |
|
207 |
|
208 Args: |
|
209 The logging.LogRecord object. |
|
210 See http://docs.python.org/library/logging.html#logging.LogRecord |
|
211 """ |
|
212 try: |
|
213 if not record.exc_info: |
|
214 return |
|
215 |
|
216 signature = self.__GetSignature(record.exc_info) |
|
217 |
|
218 if not memcache.add(signature, None, self.log_interval): |
|
219 return |
|
220 |
|
221 db.run_in_transaction_custom_retries(1, self.__EmitTx, signature, |
|
222 record.exc_info) |
|
223 except Exception: |
|
224 self.handleError(record) |
|
225 |
|
226 def __EmitTx(self, signature, exc_info): |
|
227 """Run in a transaction to insert or update the record for this transaction. |
|
228 |
|
229 Args: |
|
230 signature: The signature for this exception. |
|
231 exc_info: The exception info record. |
|
232 """ |
|
233 today = datetime.date.today() |
|
234 version = os.environ['CURRENT_VERSION_ID'] |
|
235 major_ver, minor_ver = version.rsplit('.', 1) |
|
236 minor_ver = int(minor_ver) |
|
237 key_name = ExceptionRecord.get_key_name(signature, version) |
|
238 |
|
239 exrecord = ExceptionRecord.get_by_key_name(key_name) |
|
240 if not exrecord: |
|
241 exrecord = ExceptionRecord( |
|
242 key_name=key_name, |
|
243 signature=signature, |
|
244 major_version=major_ver, |
|
245 minor_version=minor_ver, |
|
246 date=today, |
|
247 stacktrace=self.__GetFormatter().formatException(exc_info), |
|
248 http_method=os.environ['REQUEST_METHOD'], |
|
249 url=self.__GetURL(), |
|
250 handler=self.__RelativePath(os.environ['PATH_TRANSLATED'])) |
|
251 |
|
252 exrecord.count += 1 |
|
253 exrecord.put() |
|
254 |
|
255 |
|
256 def register_logger(logger=None): |
|
257 if not logger: |
|
258 logger = logging.getLogger() |
|
259 handler = ExceptionRecordingHandler() |
|
260 logger.addHandler(handler) |
|
261 return handler |