Skip to content

Commit 133ca48

Browse files
Bill Prindaspecster
Bill Prin
authored andcommitted
Add Logging Handler
1 parent 1942f2d commit 133ca48

File tree

7 files changed

+323
-3
lines changed

7 files changed

+323
-3
lines changed

CONTRIBUTING.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ Running System Tests
160160
can be downloaded directly from the developer's console by clicking
161161
"Generate new JSON key". See private key
162162
`docs <https://cloud.google.com/storage/docs/authentication#generating-a-private-key>`__
163-
for more details.
163+
for more details. In order for Logging system tests to work, the Service Account
164+
will also have to be made a project Owner. This can be changed under "IAM & Admin".
164165

165166
- Examples of these can be found in ``system_tests/local_test_setup.sample``. We
166167
recommend copying this to ``system_tests/local_test_setup``, editing the

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
logging-entries
111111
logging-metric
112112
logging-sink
113+
logging-handlers
113114

114115
.. toctree::
115116
:maxdepth: 0

docs/logging-handlers.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Python Logging Module Handler
2+
==============================
3+
4+
.. automodule:: gcloud.logging.handlers
5+
:members:
6+
:show-inheritance:
7+

docs/logging-usage.rst

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,45 @@ Delete a sink:
377377
>>> sink.delete() # API call
378378
>>> sink.exists() # API call
379379
False
380+
381+
Integration with Python logging module
382+
---------------------------------------------
383+
384+
385+
It's possible to tie the Python :mod:`logging` module directly into Google Cloud Logging. To use it,
386+
create a :class:`CloudLoggingHandler <gcloud.logging.CloudLoggingHandler>` instance from your
387+
Logging client.
388+
389+
.. doctest::
390+
391+
>>> import logging
392+
>>> import gcloud.logging # Don't conflict with standard logging
393+
>>> from gcloud.logging.handlers import CloudLoggingHandler
394+
>>> client = gcloud.logging.Client()
395+
>>> handler = CloudLoggingHandler(client)
396+
>>> cloud_logger = logging.getLogger('cloudLogger')
397+
>>> cloud_logger.setLevel(logging.INFO) # defaults to WARN
398+
>>> cloud_logger.addHandler(handler)
399+
>>> cloud_logger.error('bad news') # API call
400+
401+
.. note::
402+
403+
This handler currently only supports a synchronous API call, which means each logging statement
404+
that uses this handler will require an API call.
405+
406+
It is also possible to attach the handler to the root Python logger, so that for example a plain
407+
`logging.warn` call would be sent to Cloud Logging, as well as any other loggers created. However,
408+
you must avoid infinite recursion from the logging calls the client itself makes. A helper
409+
method :meth:`setup_logging <gcloud.logging.handlers.setup_logging>` is provided to configure
410+
this automatically:
411+
412+
.. doctest::
413+
414+
>>> import logging
415+
>>> import gcloud.logging # Don't conflict with standard logging
416+
>>> from gcloud.logging.handlers import CloudLoggingHandler, setup_logging
417+
>>> client = gcloud.logging.Client()
418+
>>> handler = CloudLoggingHandler(client)
419+
>>> logging.getLogger().setLevel(logging.INFO) # defaults to WARN
420+
>>> setup_logging(handler)
421+
>>> logging.error('bad news') # API call

gcloud/logging/handlers.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright 2016 Google Inc. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Python :mod:`logging` handlers for Google Cloud Logging."""
16+
17+
import logging
18+
19+
EXCLUDE_LOGGER_DEFAULTS = (
20+
'gcloud',
21+
'oauth2client.client'
22+
)
23+
24+
25+
class CloudLoggingHandler(logging.StreamHandler, object):
26+
"""Python standard logging handler to log messages to the Google Cloud
27+
Logging API.
28+
29+
This handler can be used to route Python standard logging messages to
30+
Google Cloud logging.
31+
32+
Note that this handler currently only supports a synchronous API call,
33+
which means each logging statement that uses this handler will require
34+
an API call.
35+
36+
:type client: :class:`gcloud.logging.client`
37+
:param client: the authenticated gcloud logging client for this handler
38+
to use
39+
40+
Example:
41+
42+
.. doctest::
43+
44+
import gcloud.logging
45+
from gcloud.logging.handlers import CloudLoggingHandler
46+
47+
client = gcloud.logging.Client()
48+
handler = CloudLoggingHandler(client)
49+
50+
cloud_logger = logging.getLogger('cloudLogger')
51+
cloud_logger.setLevel(logging.INFO)
52+
cloud_logger.addHandler(handler)
53+
54+
cloud.logger.error("bad news") # API call
55+
56+
"""
57+
58+
def __init__(self, client):
59+
super(CloudLoggingHandler, self).__init__()
60+
self.client = client
61+
62+
def emit(self, record):
63+
"""
64+
Overrides the default emit behavior of StreamHandler.
65+
66+
See: https://docs.python.org/2/library/logging.html#handler-objects
67+
"""
68+
message = super(CloudLoggingHandler, self).format(record)
69+
logger = self.client.logger(record.name)
70+
logger.log_struct({"message": message},
71+
severity=record.levelname)
72+
73+
74+
def setup_logging(handler, excluded_loggers=EXCLUDE_LOGGER_DEFAULTS):
75+
"""Helper function to attach the CloudLoggingAPI handler to the Python
76+
root logger, while excluding loggers this library itself uses to avoid
77+
infinite recursion
78+
79+
:type handler: :class:`logging.handler`
80+
:param handler: the handler to attach to the global handler
81+
82+
:type excluded_loggers: tuple
83+
:param excluded_loggers: The loggers to not attach the handler to. This
84+
will always include the loggers in the path of
85+
the logging client itself.
86+
87+
Example:
88+
89+
.. doctest::
90+
91+
import logging
92+
import gcloud.logging
93+
from gcloud.logging.handlers import CloudLoggingAPIHandler
94+
95+
client = gcloud.logging.Client()
96+
handler = CloudLoggingHandler(client)
97+
setup_logging(handler)
98+
logging.getLogger().setLevel(logging.DEBUG)
99+
100+
logging.error("bad news") # API call
101+
102+
"""
103+
all_excluded_loggers = set(excluded_loggers + EXCLUDE_LOGGER_DEFAULTS)
104+
logger = logging.getLogger()
105+
logger.addHandler(handler)
106+
logger.addHandler(logging.StreamHandler())
107+
for logger_name in all_excluded_loggers:
108+
logger = logging.getLogger(logger_name)
109+
logger.propagate = False
110+
logger.addHandler(logging.StreamHandler())

gcloud/logging/test_handlers.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
#!/usr/bin/env python
2+
# Copyright 2016 Google Inc. All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import logging
17+
18+
import unittest2
19+
20+
21+
class TestCloudLoggingHandler(unittest2.TestCase):
22+
23+
PROJECT = 'PROJECT'
24+
25+
def _getTargetClass(self):
26+
from gcloud.logging.handlers import CloudLoggingHandler
27+
return CloudLoggingHandler
28+
29+
def _makeOne(self, *args, **kw):
30+
return self._getTargetClass()(*args, **kw)
31+
32+
def test_ctor(self):
33+
client = _Client(self.PROJECT)
34+
handler = self._makeOne(client)
35+
self.assertEqual(handler.client, client)
36+
37+
def test_emit(self):
38+
client = _Client(self.PROJECT)
39+
handler = self._makeOne(client)
40+
LOGNAME = 'loggername'
41+
MESSAGE = 'hello world'
42+
record = _Record(LOGNAME, logging.INFO, MESSAGE)
43+
handler.emit(record)
44+
self.assertEqual(client.logger(LOGNAME).log_struct_called_with,
45+
({'message': MESSAGE}, logging.INFO))
46+
47+
48+
class TestSetupLogging(unittest2.TestCase):
49+
50+
def _callFUT(self, handler, excludes=None):
51+
from gcloud.logging.handlers import setup_logging
52+
if excludes:
53+
return setup_logging(handler, excluded_loggers=excludes)
54+
else:
55+
return setup_logging(handler)
56+
57+
def test_setup_logging(self):
58+
handler = _Handler(logging.INFO)
59+
self._callFUT(handler)
60+
61+
root_handlers = logging.getLogger().handlers
62+
self.assertIn(handler, root_handlers)
63+
64+
def test_setup_logging_excludes(self):
65+
INCLUDED_LOGGER_NAME = 'includeme'
66+
EXCLUDED_LOGGER_NAME = 'excludeme'
67+
68+
handler = _Handler(logging.INFO)
69+
self._callFUT(handler, (EXCLUDED_LOGGER_NAME,))
70+
71+
included_logger = logging.getLogger(INCLUDED_LOGGER_NAME)
72+
self.assertTrue(included_logger.propagate)
73+
74+
excluded_logger = logging.getLogger(EXCLUDED_LOGGER_NAME)
75+
self.assertNotIn(handler, excluded_logger.handlers)
76+
self.assertFalse(excluded_logger.propagate)
77+
78+
def setUp(self):
79+
self._handlers_cache = logging.getLogger().handlers[:]
80+
81+
def tearDown(self):
82+
# cleanup handlers
83+
logging.getLogger().handlers = self._handlers_cache[:]
84+
85+
86+
class _Handler(object):
87+
88+
def __init__(self, level):
89+
self.level = level
90+
91+
def acquire(self):
92+
pass # pragma: NO COVER
93+
94+
def release(self):
95+
pass # pragma: NO COVER
96+
97+
98+
class _Logger(object):
99+
100+
def log_struct(self, message, severity=None):
101+
self.log_struct_called_with = (message, severity)
102+
103+
104+
class _Client(object):
105+
106+
def __init__(self, project):
107+
self.project = project
108+
self.logger_ = _Logger()
109+
110+
def logger(self, _): # pylint: disable=unused-argument
111+
return self.logger_
112+
113+
114+
class _Record(object):
115+
116+
def __init__(self, name, level, message):
117+
self.name = name
118+
self.levelname = level
119+
self.message = message
120+
self.exc_info = None
121+
self.exc_text = None
122+
self.stack_info = None
123+
124+
def getMessage(self):
125+
return self.message

system_tests/logging_.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import logging
1516
import time
1617

1718
import unittest2
1819

1920
from gcloud import _helpers
2021
from gcloud.environment_vars import TESTS_PROJECT
21-
from gcloud import logging
22+
import gcloud.logging
23+
import gcloud.logging.handlers
2224

2325
from system_test_utils import unique_resource_id
2426

@@ -71,13 +73,14 @@ class Config(object):
7173

7274
def setUpModule():
7375
_helpers.PROJECT = TESTS_PROJECT
74-
Config.CLIENT = logging.Client()
76+
Config.CLIENT = gcloud.logging.Client()
7577

7678

7779
class TestLogging(unittest2.TestCase):
7880

7981
def setUp(self):
8082
self.to_delete = []
83+
self._handlers_cache = logging.getLogger().handlers[:]
8184

8285
def tearDown(self):
8386
from gcloud.exceptions import NotFound
@@ -92,6 +95,7 @@ def tearDown(self):
9295
time.sleep(backoff_intervals.pop(0))
9396
else:
9497
raise
98+
logging.getLogger().handlers = self._handlers_cache[:]
9599

96100
@staticmethod
97101
def _logger_name():
@@ -103,6 +107,7 @@ def test_log_text(self):
103107
self.to_delete.append(logger)
104108
logger.log_text(TEXT_PAYLOAD)
105109
entries, _ = _retry_backoff(_has_entries, logger.list_entries)
110+
106111
self.assertEqual(len(entries), 1)
107112
self.assertEqual(entries[0].payload, TEXT_PAYLOAD)
108113

@@ -151,6 +156,35 @@ def test_log_struct(self):
151156
self.assertEqual(len(entries), 1)
152157
self.assertEqual(entries[0].payload, JSON_PAYLOAD)
153158

159+
def test_log_handler(self):
160+
LOG_MESSAGE = 'It was the best of times.'
161+
# only create the logger to delete, hidden otherwise
162+
logger = Config.CLIENT.logger(self._logger_name())
163+
self.to_delete.append(logger)
164+
165+
handler = gcloud.logging.handlers.CloudLoggingHandler(Config.CLIENT)
166+
cloud_logger = logging.getLogger(self._logger_name())
167+
cloud_logger.addHandler(handler)
168+
cloud_logger.warn(LOG_MESSAGE)
169+
time.sleep(5)
170+
entries, _ = logger.list_entries()
171+
self.assertEqual(len(entries), 1)
172+
self.assertEqual(entries[0].payload, {'message': LOG_MESSAGE})
173+
174+
def test_log_root_handler(self):
175+
LOG_MESSAGE = 'It was the best of times.'
176+
# only create the logger to delete, hidden otherwise
177+
logger = Config.CLIENT.logger("root")
178+
self.to_delete.append(logger)
179+
180+
handler = gcloud.logging.handlers.CloudLoggingHandler(Config.CLIENT)
181+
gcloud.logging.handlers.setup_logging(handler)
182+
logging.warn(LOG_MESSAGE)
183+
time.sleep(5)
184+
entries, _ = logger.list_entries()
185+
self.assertEqual(len(entries), 1)
186+
self.assertEqual(entries[0].payload, {'message': LOG_MESSAGE})
187+
154188
def test_log_struct_w_metadata(self):
155189
JSON_PAYLOAD = {
156190
'message': 'System test: test_log_struct',

0 commit comments

Comments
 (0)