diff --git a/error_reporting/google/cloud/error_reporting/_gax.py b/error_reporting/google/cloud/error_reporting/_gax.py new file mode 100644 index 000000000000..e480fcf7380a --- /dev/null +++ b/error_reporting/google/cloud/error_reporting/_gax.py @@ -0,0 +1,74 @@ +# 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. + +"""GAX wrapper for Error Reporting API requests.""" + +from google.cloud._helpers import make_secure_channel +from google.cloud._http import DEFAULT_USER_AGENT + +from google.cloud.gapic.errorreporting.v1beta1 import ( + report_errors_service_client) +from google.cloud.grpc.devtools.clouderrorreporting.v1beta1 import ( + report_errors_service_pb2) +from google.protobuf.json_format import ParseDict + + +def make_report_error_api(client): + """Create an instance of the GAX Logging API. + + :type client::class:`google.cloud.error_reporting.Client` + :param client: Error Reporting client. + + :rtype: :class:_ErrorReportingGaxApi + :returns: An Error Reporting API instance. + """ + channel = make_secure_channel( + client._connection.credentials, + DEFAULT_USER_AGENT, + report_errors_service_client.ReportErrorsServiceClient.SERVICE_ADDRESS) + gax_client = report_errors_service_client.ReportErrorsServiceClient( + channel=channel) + return _ErrorReportingGaxApi(gax_client, client.project) + + +class _ErrorReportingGaxApi(object): + """Helper mapping Error Reporting-related APIs + + :type gax_api: + :class:`v1beta1.report_errors_service_client.ReportErrorsServiceClient` + :param gax_api: API object used to make GAX requests. + + :type project: str + :param project: Google Cloud Project ID + """ + + def __init__(self, gax_api, project): + self._gax_api = gax_api + self._project = project + + def report_error_event(self, error_report): + """Uses the GAX client to report the error. + + :type error_report: dict + :param error_report: + payload of the error report formatted according to + https://cloud.google.com/error-reporting/docs/formatting-error-messages + This object should be built using + Use + :meth:~`google.cloud.error_reporting.client._build_error_report` + """ + project_name = self._gax_api.project_path(self._project) + error_report_payload = report_errors_service_pb2.ReportedErrorEvent() + ParseDict(error_report, error_report_payload) + self._gax_api.report_error_event(project_name, error_report_payload) diff --git a/error_reporting/google/cloud/error_reporting/_logging.py b/error_reporting/google/cloud/error_reporting/_logging.py new file mode 100644 index 000000000000..59e1154c2b17 --- /dev/null +++ b/error_reporting/google/cloud/error_reporting/_logging.py @@ -0,0 +1,60 @@ +# 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. + +"""Interact with Stackdriver Error Reporting via Logging API. + +It's possible to report Stackdriver Error Reporting errors by formatting +structured log messages in Stackdriver Logging in a given format. This +client provides a mechanism to report errors using that technique. +""" + +import google.cloud.logging.client + + +class _ErrorReportingLoggingAPI(object): + """Report to Stackdriver Error Reporting via Logging API + + :type project: str + :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. + """ + def __init__(self, project, credentials=None, http=None): + self.logging_client = google.cloud.logging.client.Client( + project, credentials, http) + + def report_error_event(self, error_report): + """Report error payload. + + :type error_report: dict + :param: error_report: + dict payload of the error report formatted according to + https://cloud.google.com/error-reporting/docs/formatting-error-messages + This object should be built using + :meth:~`google.cloud.error_reporting.client._build_error_report` + """ + logger = self.logging_client.logger('errors') + logger.log_struct(error_report) diff --git a/error_reporting/google/cloud/error_reporting/client.py b/error_reporting/google/cloud/error_reporting/client.py index dfa0b0e2845b..4d8a0c2a4a48 100644 --- a/error_reporting/google/cloud/error_reporting/client.py +++ b/error_reporting/google/cloud/error_reporting/client.py @@ -12,13 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Client for interacting with the Stackdriver Logging API""" +"""Client for interacting with the Stackdriver Error Reporting API""" +import os import traceback -import google.cloud.logging.client +try: + from google.cloud.error_reporting._gax import make_report_error_api + _HAVE_GAX = True +except ImportError: # pragma: NO COVER + _HAVE_GAX = False + +from google.cloud._helpers import _determine_default_project +from google.cloud.error_reporting._logging import _ErrorReportingLoggingAPI +from google.cloud.environment_vars import DISABLE_GRPC + import six +_DISABLE_GAX = os.getenv(DISABLE_GRPC, False) +_USE_GAX = _HAVE_GAX and not _DISABLE_GAX + class HTTPContext(object): """HTTPContext defines an object that captures the parameter for the @@ -96,6 +109,12 @@ class Client(object): SHA-1 hash, for example. If the developer did not provide a version, the value is set to default. + :type use_gax: bool + :param use_gax: (Optional) Explicitly specifies whether + to use the gRPC transport (via GAX) or HTTP. If unset, + falls back to the ``GOOGLE_CLOUD_DISABLE_GRPC`` environment + variable. + :raises: :class:`ValueError` if the project is neither passed in nor set in the environment. """ @@ -104,24 +123,56 @@ def __init__(self, project=None, credentials=None, http=None, service=None, - version=None): - self.logging_client = google.cloud.logging.client.Client( - project, credentials, http) + version=None, + use_gax=None): + if project is None: + self._project = _determine_default_project() + else: + self._project = project + self._credentials = credentials + self._http = http + + self._report_errors_api = None + self.service = service if service else self.DEFAULT_SERVICE self.version = version + if use_gax is None: + self._use_gax = _USE_GAX + else: + self._use_gax = use_gax 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. + @property + def report_errors_api(self): + """Helper for logging-related API calls. - 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. + See: + https://cloud.google.com/logging/docs/api/reference/rest/v2/entries + https://cloud.google.com/logging/docs/api/reference/rest/v2/projects.logs - Currently this method sends the Error Report by formatting a structured - log message according to + :rtype: + :class:`_gax._ErrorReportingGaxApi` + or + :class:`._logging._ErrorReportingLoggingAPI` + :returns: A class that implements the report errors API. + """ + if self._report_errors_api is None: + if self._use_gax: + self._report_errors_api = make_report_error_api(self._project) + else: + self._report_errors_api = _ErrorReportingLoggingAPI( + self._project, self._credentials, self._http) + return self._report_errors_api + + def _build_error_report(self, + message, + report_location=None, + http_context=None, + user=None): + """Builds the Error Reporting object to report. + + This builds the object according to https://cloud.google.com/error-reporting/docs/formatting-error-messages @@ -151,7 +202,10 @@ def _send_error_report(self, message, logged in. In this case the Error Reporting system will use other data, such as remote IP address, to distinguish affected users. - """ + :rtype: dict + :returns: A dict payload ready to be serialized to JSON and sent to + the API. + """ payload = { 'serviceContext': { 'service': self.service, @@ -178,9 +232,49 @@ def _send_error_report(self, message, if user: payload['context']['user'] = user + return payload + + def _send_error_report(self, + message, + report_location=None, + http_context=None, + user=None): + """Makes the call to the Error Reporting API. + + This is the lower-level interface to build and send the payload, + generally users will use either report() or report_exception() to + automatically gather the parameters for this method. - logger = self.logging_client.logger('errors') - logger.log_struct(payload) + :type message: str + :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`google.cloud.error_reporting.HTTPContext` + :param http_context: The HTTP request which was processed when the + error was triggered. + + :type user: str + :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. + """ + error_report = self._build_error_report(message, report_location, + http_context, user) + self.report_errors_api.report_error_event(error_report) def report(self, message, http_context=None, user=None): """ Reports a message to Stackdriver Error Reporting diff --git a/error_reporting/setup.py b/error_reporting/setup.py index 9d65198d9af6..5035847a2108 100644 --- a/error_reporting/setup.py +++ b/error_reporting/setup.py @@ -52,6 +52,7 @@ REQUIREMENTS = [ 'google-cloud-core >= 0.22.1, < 0.23dev', 'google-cloud-logging >= 0.22.0, < 0.23dev', + 'gapic-google-cloud-error-reporting-v1beta1 >= 0.14.0, < 0.15dev' ] setup( diff --git a/error_reporting/tox.ini b/error_reporting/tox.ini index e18578ba64ac..63c0e63bb2cd 100644 --- a/error_reporting/tox.ini +++ b/error_reporting/tox.ini @@ -14,7 +14,7 @@ deps = pytest covercmd = py.test --quiet \ - --cov=google.cloud.error-reporting \ + --cov=google.cloud.error_reporting \ --cov=unit_tests \ --cov-config {toxinidir}/.coveragerc \ unit_tests diff --git a/error_reporting/unit_tests/test__gax.py b/error_reporting/unit_tests/test__gax.py new file mode 100644 index 000000000000..af0c4247dae4 --- /dev/null +++ b/error_reporting/unit_tests/test__gax.py @@ -0,0 +1,53 @@ +# Copyright 2017 Google Inc. +# +# 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 unittest + +import mock + + +class Test_make_report_error_api(unittest.TestCase): + + def test_make_report_error_api(self): + from google.cloud.error_reporting._gax import make_report_error_api + client = mock.Mock() + client.project = mock.Mock() + report_error_client = make_report_error_api(client) + self.assertEqual(report_error_client._project, client.project) + + +class Test_ErrorReportingGaxApi(unittest.TestCase): + + PROJECT = 'PROJECT' + + def _call_fut(self, gax_api, project): + from google.cloud.error_reporting._gax import _ErrorReportingGaxApi + return _ErrorReportingGaxApi(gax_api, project) + + def test_constructor(self): + gax_api = mock.Mock() + gax_client_wrapper = self._call_fut(gax_api, self.PROJECT) + + self.assertEqual(gax_client_wrapper._project, self.PROJECT) + self.assertEqual(gax_client_wrapper._gax_api, gax_api) + + @mock.patch("google.cloud.error_reporting._gax.ParseDict") + def test_report_error_event(self, _): + gax_api = mock.Mock() + gax_client_wrapper = self._call_fut(gax_api, self.PROJECT) + + mock_error_report = mock.Mock() + gax_client_wrapper.report_error_event(mock_error_report) + self.assertTrue(gax_api.report_error_event.called_with, + mock_error_report) diff --git a/error_reporting/unit_tests/test__logging.py b/error_reporting/unit_tests/test__logging.py new file mode 100644 index 000000000000..99371906eedc --- /dev/null +++ b/error_reporting/unit_tests/test__logging.py @@ -0,0 +1,51 @@ +# Copyright 2017 Google Inc. +# +# 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 unittest + +import mock + + +def _make_credentials(): + import google.auth.credentials + return mock.Mock(spec=google.auth.credentials.Credentials) + + +class Test_ErrorReportingLoggingAPI(unittest.TestCase): + + PROJECT = 'PROJECT' + SERVICE = 'SERVICE' + VERSION = 'myversion' + + def _call_fut(self, project, credentials): + from google.cloud.error_reporting._logging import ( + _ErrorReportingLoggingAPI) + return _ErrorReportingLoggingAPI(project, credentials) + + def test_constructor(self): + credentials = _make_credentials() + logger_client = self._call_fut(self.PROJECT, credentials) + + self.assertEqual(logger_client.logging_client._connection.credentials, + credentials) + self.assertEqual(logger_client.logging_client.project, self.PROJECT) + + @mock.patch('google.cloud.logging.client') + def test_report_error_event(self, _): + credentials = _make_credentials() + logger_client = self._call_fut(self.PROJECT, credentials) + payload = mock.Mock() + logger_client.report_error_event(payload) + logger_mock = mock.Mock() + self.assertTrue(logger_mock.log_struct.called_with, payload) diff --git a/error_reporting/unit_tests/test_client.py b/error_reporting/unit_tests/test_client.py index c1b105990f0d..09b9065f59f9 100644 --- a/error_reporting/unit_tests/test_client.py +++ b/error_reporting/unit_tests/test_client.py @@ -47,10 +47,11 @@ def _makeHTTP(self, *args, **kw): SERVICE = 'SERVICE' VERSION = 'myversion' - def test_ctor_default(self): + @mock.patch( + 'google.cloud.error_reporting.client._determine_default_project') + def test_ctor_default(self, _): CREDENTIALS = _make_credentials() - target = self._make_one(project=self.PROJECT, - credentials=CREDENTIALS) + target = self._make_one(credentials=CREDENTIALS) self.assertEquals(target.service, target.DEFAULT_SERVICE) self.assertEquals(target.version, None) @@ -63,27 +64,52 @@ def test_ctor_params(self): self.assertEquals(target.service, self.SERVICE) self.assertEquals(target.version, self.VERSION) - def test_report_exception(self): + def test_report_exception_with_gax(self): CREDENTIALS = _make_credentials() target = self._make_one(project=self.PROJECT, credentials=CREDENTIALS) - logger = _Logger() - target.logging_client.logger = lambda _: logger + patch = mock.patch( + 'google.cloud.error_reporting.client.make_report_error_api') + with patch as make_api: + try: + raise NameError + except NameError: + target.report_exception() + payload = make_api.return_value.report_error_event.call_args[0][0] + self.assertEquals(payload['serviceContext'], { + 'service': target.DEFAULT_SERVICE, + }) + self.assertIn('test_report', payload['message']) + self.assertIn('test_client.py', payload['message']) - try: - raise NameError - except NameError: - target.report_exception() + def test_report_exception_wo_gax(self): + CREDENTIALS = _make_credentials() + target = self._make_one(project=self.PROJECT, + credentials=CREDENTIALS, + use_gax=False) + patch = mock.patch( + 'google.cloud.error_reporting.client._ErrorReportingLoggingAPI' + ) + with patch as _error_api: + try: + raise NameError + except NameError: + target.report_exception() + mock_report = _error_api.return_value.report_error_event + payload = mock_report.call_args[0][0] - 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']) + self.assertIsNotNone(target.report_errors_api) - def test_report_exception_with_service_version_in_constructor(self): + @mock.patch('google.cloud.error_reporting.client.make_report_error_api') + def test_report_exception_with_service_version_in_constructor( + self, + make_client): CREDENTIALS = _make_credentials() SERVICE = "notdefault" VERSION = "notdefaultversion" @@ -92,18 +118,18 @@ def test_report_exception_with_service_version_in_constructor(self): 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" + client = mock.Mock() + make_client.return_value = client + try: raise NameError except NameError: target.report_exception(http_context=http_context, user=USER) - payload = logger.log_struct_called_with + payload = client.report_error_event.call_args[0][0] self.assertEquals(payload['serviceContext'], { 'service': SERVICE, 'version': VERSION @@ -118,32 +144,23 @@ def test_report_exception_with_service_version_in_constructor(self): payload['context']['httpContext']['method'], 'GET') self.assertEquals(payload['context']['user'], USER) - def test_report(self): + @mock.patch('google.cloud.error_reporting.client.make_report_error_api') + def test_report(self, make_client): CREDENTIALS = _make_credentials() target = self._make_one(project=self.PROJECT, credentials=CREDENTIALS) - logger = _Logger() - target.logging_client.logger = lambda _: logger + client = mock.Mock() + make_client.return_value = client MESSAGE = 'this is an error' target.report(MESSAGE) - payload = logger.log_struct_called_with + payload = client.report_error_event.call_args[0][0] + 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 _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 + self.assertLess(report_location['lineNumber'], 250)