From b771f4f99ddebdea8541fdcaa4b9f3b8e8c70090 Mon Sep 17 00:00:00 2001 From: Bill Prin Date: Wed, 29 Jun 2016 17:42:24 -0700 Subject: [PATCH] Add Error Reporting Client --- docs/error-reporting-client.rst | 7 + docs/error-reporting-usage.rst | 102 +++++++++++ docs/index.rst | 8 + gcloud/error_reporting/__init__.py | 17 ++ gcloud/error_reporting/client.py | 252 ++++++++++++++++++++++++++ gcloud/error_reporting/test_client.py | 151 +++++++++++++++ scripts/verify_included_modules.py | 1 + 7 files changed, 538 insertions(+) create mode 100644 docs/error-reporting-client.rst create mode 100644 docs/error-reporting-usage.rst create mode 100644 gcloud/error_reporting/__init__.py create mode 100644 gcloud/error_reporting/client.py create mode 100644 gcloud/error_reporting/test_client.py diff --git a/docs/error-reporting-client.rst b/docs/error-reporting-client.rst new file mode 100644 index 000000000000..55092baa0880 --- /dev/null +++ b/docs/error-reporting-client.rst @@ -0,0 +1,7 @@ +Error Reporting Client +======================= + +.. automodule:: gcloud.error_reporting.client + :members: + :show-inheritance: + diff --git a/docs/error-reporting-usage.rst b/docs/error-reporting-usage.rst new file mode 100644 index 000000000000..c5dd5f451046 --- /dev/null +++ b/docs/error-reporting-usage.rst @@ -0,0 +1,102 @@ +Using the API +============= + + +Authentication and Configuration +-------------------------------- + +- For an overview of authentication in ``gcloud-python``, + see :doc:`gcloud-auth`. + +- In addition to any authentication configuration, you should also set the + :envvar:`GCLOUD_PROJECT` environment variable for the project you'd like + to interact with. If you are Google App Engine or Google Compute Engine + this will be detected automatically. + +- After configuring your environment, create a + :class:`Client ` + + .. doctest:: + + >>> from gcloud import error_reporting + >>> client = error_reporting.Client() + + or pass in ``credentials`` and ``project`` explicitly + + .. doctest:: + + >>> from gcloud import error_reporting + >>> client = error_reporting.Client(project='my-project', credentials=creds) + + Error Reporting associates errors with a service, which is an identifier for an executable, + App Engine service, or job. The default service is "python", but a default can be specified + for the client on construction time. You can also optionally specify a version for that service, + which defaults to "default." + + + .. doctest:: + + >>> from gcloud import error_reporting + >>> client = error_reporting.Client(project='my-project', + ... service="login_service", + ... version="0.1.0") + +Reporting an exception +----------------------- + +Report a stacktrace to Stackdriver Error Reporting after an exception + +.. doctest:: + + >>> from gcloud import error_reporting + >>> client = error_reporting.Client() + >>> try: + >>> raise NameError + >>> except Exception: + >>> client.report_exception() + + +By default, the client will report the error using the service specified in the client's +constructor, or the default service of "python". + +The user and HTTP context can also be included in the exception. The HTTP context +can be constructed using :class:`gcloud.error_reporting.HTTPContext`. This will +be used by Stackdriver Error Reporting to help group exceptions. + +.. doctest:: + + >>> from gcloud import error_reporting + >>> client = error_reporting.Client() + >>> user = 'example@gmail.com' + >>> http_context = HTTPContext(method='GET', url='/', userAgent='test agent', + ... referrer='example.com', responseStatusCode=500, + ... remote_ip='1.2.3.4') + >>> try: + >>> raise NameError + >>> except Exception: + >>> client.report_exception(http_context=http_context, user=user)) + +Reporting an error without an exception +----------------------------------------- + +Errors can also be reported to Stackdriver Error Reporting outside the context of an exception. +The library will include the file path, function name, and line number of the location where the +error was reported. + +.. doctest:: + + >>> from gcloud import error_reporting + >>> client = error_reporting.Client() + >>> error_reporting.report("Found an error!") + +Similarly to reporting an exception, the user and HTTP context can be provided: + +.. doctest:: + + >>> from gcloud import error_reporting + >>> client = error_reporting.Client() + >>> user = 'example@gmail.com' + >>> http_context = HTTPContext(method='GET', url='/', userAgent='test agent', + ... referrer='example.com', responseStatusCode=500, + ... remote_ip='1.2.3.4') + >>> error_reporting.report("Found an error!", http_context=http_context, user=user)) diff --git a/docs/index.rst b/docs/index.rst index 0c2b6ffbedb9..892094048ce4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -111,6 +111,14 @@ logging-metric logging-sink +.. toctree:: + :maxdepth: 0 + :hidden: + :caption: Stackdriver Error Reporting + + error-reporting-usage + Client + .. toctree:: :maxdepth: 0 :hidden: diff --git a/gcloud/error_reporting/__init__.py b/gcloud/error_reporting/__init__.py new file mode 100644 index 000000000000..01c84d109963 --- /dev/null +++ b/gcloud/error_reporting/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Client library for Stackdriver Error Reporting""" + +from gcloud.error_reporting.client import Client diff --git a/gcloud/error_reporting/client.py b/gcloud/error_reporting/client.py new file mode 100644 index 000000000000..11ffd0b0fec5 --- /dev/null +++ b/gcloud/error_reporting/client.py @@ -0,0 +1,252 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Client for interacting with the Stackdriver Logging API""" + +import traceback + +import gcloud.logging.client +import six + + +class HTTPContext(object): + """HTTPContext defines an object that captures the parameter for the + httpRequest part of Error Reporting API + + :type method: string + :param method: The type of HTTP request, such as GET, POST, etc. + + :type url: string + :param url: The URL of the request + + :type user_agent: string + :param user_agent: The user agent information that is provided with the + request. + + :type referrer: string + :param referrer: The referrer information that is provided with the + request. + + :type response_status_code: int + :param response_status_code: The HTTP response status code for the request. + + :type remote_ip: string + :param remote_ip: The IP address from which the request originated. This + can be IPv4, IPv6, or a token which is derived from + the IP address, depending on the data that has been + provided in the error report. + """ + + def __init__(self, method=None, url=None, + user_agent=None, referrer=None, + response_status_code=None, remote_ip=None): + self.method = method + self.url = url + # intentionally camel case for mapping to JSON API expects + # pylint: disable=invalid-name + self.userAgent = user_agent + self.referrer = referrer + self.responseStatusCode = response_status_code + self.remoteIp = remote_ip + + +class Client(object): + """Error Reporting client. Currently Error Reporting is done by creating + a Logging client. + + :type project: string + :param project: the project which the client acts on behalf of. If not + passed falls back to the default inferred from the + environment. + + :type credentials: :class:`oauth2client.client.OAuth2Credentials` or + :class:`NoneType` + :param credentials: The OAuth2 Credentials to use for the connection + owned by this client. If not passed (and if no ``http`` + object is passed), falls back to the default inferred + from the environment. + + :type http: :class:`httplib2.Http` or class that defines ``request()``. + :param http: An optional HTTP object to make requests. If not passed, an + ``http`` object is created that is bound to the + ``credentials`` for the current object. + + :type service: str + :param service: An identifier of the service, such as the name of the + executable, job, or Google App Engine service name. This + field is expected to have a low number of values that are + relatively stable over time, as opposed to version, + which can be changed whenever new code is deployed. + + + :type version: str + :param version: Represents the source code version that the developer + provided, which could represent a version label or a Git + SHA-1 hash, for example. If the developer did not provide + a version, the value is set to default. + + :raises: :class:`ValueError` if the project is neither passed in nor + set in the environment. + """ + + def __init__(self, project=None, + credentials=None, + http=None, + service=None, + version=None): + self.logging_client = gcloud.logging.client.Client( + project, credentials, http) + self.service = service if service else self.DEFAULT_SERVICE + self.version = version + + DEFAULT_SERVICE = 'python' + + def _send_error_report(self, message, + report_location=None, http_context=None, user=None): + """Makes the call to the Error Reporting API via the log stream. + + This is the lower-level interface to build the payload, generally + users will use either report() or report_exception() to automatically + gather the parameters for this method. + + Currently this method sends the Error Report by formatting a structured + log message according to + + https://cloud.google.com/error-reporting/docs/formatting-error-messages + + :type message: string + :param message: The stack trace that was reported or logged by the + service. + + :type report_location: dict + :param report_location: The location in the source code where the + decision was made to report the error, usually the place + where it was logged. For a logged exception this would be the + source line where the exception is logged, usually close to + the place where it was caught. + + This should be a Python dict that contains the keys 'filePath', + 'lineNumber', and 'functionName' + + :type http_context: :class`gcloud.error_reporting.HTTPContext` + :param http_context: The HTTP request which was processed when the + error was triggered. + + :type user: string + :param user: The user who caused or was affected by the crash. This can + be a user ID, an email address, or an arbitrary token that + uniquely identifies the user. When sending an error + report, leave this field empty if the user was not + logged in. In this case the Error Reporting system will + use other data, such as remote IP address, + to distinguish affected users. + """ + payload = { + 'serviceContext': { + 'service': self.service, + }, + 'message': '{0}'.format(message) + } + + if self.version: + payload['serviceContext']['version'] = self.version + + if report_location or http_context or user: + payload['context'] = {} + + if report_location: + payload['context']['reportLocation'] = report_location + + if http_context: + http_context_dict = http_context.__dict__ + # strip out None values + # once py26 support is dropped this can use dict comprehension + payload['context']['httpContext'] = dict( + (k, v) for (k, v) in six.iteritems(http_context_dict) + if v is not None + ) + + if user: + payload['context']['user'] = user + + logger = self.logging_client.logger('errors') + logger.log_struct(payload) + + def report(self, message, http_context=None, user=None): + """ Reports a message to Stackdriver Error Reporting + https://cloud.google.com/error-reporting/docs/formatting-error-messages + + :type message: str + :param message: A user-supplied message to report + + + :type http_context: :class`gcloud.error_reporting.HTTPContext` + :param http_context: The HTTP request which was processed when the + error was triggered. + + :type user: string + :param user: The user who caused or was affected by the crash. This + can be a user ID, an email address, or an arbitrary + token that uniquely identifies the user. When sending + an error report, leave this field empty if the user + was not logged in. In this case the Error Reporting + system will use other data, such as remote IP address, + to distinguish affected users. + + Example:: + >>> client.report("Something went wrong!") + """ + stack = traceback.extract_stack() + last_call = stack[-2] + file_path = last_call[0] + line_number = last_call[1] + function_name = last_call[2] + report_location = { + 'filePath': file_path, + 'lineNumber': line_number, + 'functionName': function_name + } + + self._send_error_report(message, + http_context=http_context, + user=user, + report_location=report_location) + + def report_exception(self, http_context=None, user=None): + """ Reports the details of the latest exceptions to Stackdriver Error + Reporting. + + :type http_context: :class`gcloud.error_reporting.HTTPContext` + :param http_context: The HTTP request which was processed when the + error was triggered. + + :type user: string + :param user: The user who caused or was affected by the crash. This + can be a user ID, an email address, or an arbitrary + token that uniquely identifies the user. When sending an + error report, leave this field empty if the user was + not logged in. In this case the Error Reporting system + will use other data, such as remote IP address, + to distinguish affected users. + + Example:: + + >>> try: + >>> raise NameError + >>> except Exception: + >>> client.report_exception() + """ + self._send_error_report(traceback.format_exc(), + http_context=http_context, + user=user) diff --git a/gcloud/error_reporting/test_client.py b/gcloud/error_reporting/test_client.py new file mode 100644 index 000000000000..8718e59dd901 --- /dev/null +++ b/gcloud/error_reporting/test_client.py @@ -0,0 +1,151 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import unittest2 + + +class TestClient(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.error_reporting.client import Client + return Client + + def _getHttpContext(self): + from gcloud.error_reporting.client import HTTPContext + return HTTPContext + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def _makeHTTP(self, *args, **kw): + return self._getHttpContext()(*args, **kw) + + PROJECT = 'PROJECT' + SERVICE = 'SERVICE' + VERSION = 'myversion' + + def test_ctor_default(self): + CREDENTIALS = _Credentials() + target = self._makeOne(project=self.PROJECT, + credentials=CREDENTIALS) + self.assertEquals(target.service, target.DEFAULT_SERVICE) + self.assertEquals(target.version, None) + + def test_ctor_params(self): + CREDENTIALS = _Credentials() + target = self._makeOne(project=self.PROJECT, + credentials=CREDENTIALS, + service=self.SERVICE, + version=self.VERSION) + self.assertEquals(target.service, self.SERVICE) + self.assertEquals(target.version, self.VERSION) + + def test_report_exception(self): + CREDENTIALS = _Credentials() + target = self._makeOne(project=self.PROJECT, + credentials=CREDENTIALS) + + logger = _Logger() + target.logging_client.logger = lambda _: logger + + try: + raise NameError + except NameError: + target.report_exception() + + payload = logger.log_struct_called_with + self.assertEquals(payload['serviceContext'], { + 'service': target.DEFAULT_SERVICE, + }) + self.assertIn('test_report', payload['message']) + self.assertIn('test_client.py', payload['message']) + + def test_report_exception_with_service_version_in_constructor(self): + CREDENTIALS = _Credentials() + SERVICE = "notdefault" + VERSION = "notdefaultversion" + target = self._makeOne(project=self.PROJECT, + credentials=CREDENTIALS, + service=SERVICE, + version=VERSION) + + logger = _Logger() + target.logging_client.logger = lambda _: logger + + http_context = self._makeHTTP(method="GET", response_status_code=500) + USER = "user@gmail.com" + + try: + raise NameError + except NameError: + target.report_exception(http_context=http_context, user=USER) + + payload = logger.log_struct_called_with + self.assertEquals(payload['serviceContext'], { + 'service': SERVICE, + 'version': VERSION + }) + self.assertIn( + 'test_report_exception_with_service_version_in_constructor', + payload['message']) + self.assertIn('test_client.py', payload['message']) + self.assertEquals( + payload['context']['httpContext']['responseStatusCode'], 500) + self.assertEquals( + payload['context']['httpContext']['method'], 'GET') + self.assertEquals(payload['context']['user'], USER) + + def test_report(self): + CREDENTIALS = _Credentials() + target = self._makeOne(project=self.PROJECT, + credentials=CREDENTIALS) + + logger = _Logger() + target.logging_client.logger = lambda _: logger + + MESSAGE = 'this is an error' + target.report(MESSAGE) + + payload = logger.log_struct_called_with + self.assertEquals(payload['message'], MESSAGE) + report_location = payload['context']['reportLocation'] + self.assertIn('test_client.py', report_location['filePath']) + self.assertEqual(report_location['functionName'], 'test_report') + self.assertGreater(report_location['lineNumber'], 100) + self.assertLess(report_location['lineNumber'], 150) + + +class _Credentials(object): + + _scopes = None + + @staticmethod + def create_scoped_required(): + return True + + def create_scoped(self, scope): + self._scopes = scope + return self + + +class _Logger(object): + + def log_struct(self, payload, # pylint: disable=unused-argument + client=None, # pylint: disable=unused-argument + labels=None, # pylint: disable=unused-argument + insert_id=None, # pylint: disable=unused-argument + severity=None, # pylint: disable=unused-argument + http_request=None): # pylint: disable=unused-argument + self.log_struct_called_with = payload diff --git a/scripts/verify_included_modules.py b/scripts/verify_included_modules.py index 15d387506484..1172c0eb303d 100644 --- a/scripts/verify_included_modules.py +++ b/scripts/verify_included_modules.py @@ -35,6 +35,7 @@ 'gcloud.bigtable.__init__', 'gcloud.datastore.__init__', 'gcloud.dns.__init__', + 'gcloud.error_reporting.__init__', 'gcloud.iterator', 'gcloud.logging.__init__', 'gcloud.monitoring.__init__',