blob: 2f209308a931200eef69b82c71a0ae374157badc [file]
"""Module for recording metrics and uploading to Google Cloud Logging.
There are 3 methods of logging:
1) Stackdriver / Cloud Logging API
2) Local Logging
3) Fluentd Agent
Setup:
1) Stackdriver / Cloud Logging API, during application setup you should call:
project_name = 'google.com:cast-cq-staging'
logger_name = 'cast_cq.master'
cloud_logging.ConfigureLogger(project_name, logger_name)
2) Local Logging, this is used by default if you do not configure the cloud
logger or enable the fluentd logger
3) For Fluentd Agent, during application setup you should call:
namespace = "buildbot"
host = "127.0.0.0"
port = 24224
cloud_logging.EnableFluentdLogger(namespace, host, port)
There are few caveats when using the fluentd agent:
- The Google Cloud project will be inferred by the agent (either by explicitly
setting the project in the configuration or implicitly by the IAM Role)
that's how it determines which project to upload the logs to.
- Since we're using a version of the Google Cloud agent, it assumes that the
host it's running on is a GCE instance (which it may not). The consequence
of this is that the logs will be grouped into a GCE resource log like
"GCE VM Instance, <id>" and NOT "Global"
- Severity is ignored when using the agent
- Additional labels are also not passed to the agent
Usage:
Then, throughout your application, you can use:
import cloud_logging
t = cloud_logging.Timer('test_timer').Start()
# do something interesting...
t.Stop()
with cloud_logging.Timer('test_timer') as t:
# do something interesting...
t.AddAttribute('a', 'b')
ev = cloud_logging.LogEvent('test_msg', value='Something to log').Log()
cloud_logging.Info('info_message', value='Something to log').Log()
cloud_logging.Error('error_message', value='Something to log').Log()
"""
import atexit
import logging
import os
import socket
import threading
import time
import traceback
from fluent import event as fluentd_event
from fluent import sender as fluentd # TODO(b/70284159): switch to asyncsender
from google.cloud import logging as gcl # pylint: disable=ungrouped-imports
from google.gax import errors
from google.gax import grpc
from google.protobuf import json_format
import six
# Subset of supported severity levels:
# https://cloud.google.com/logging/docs/api/reference/rest/v2/LogEntry#LogSeverity
DEBUG = 'DEBUG'
INFO = 'INFO'
WARNING = 'WARNING'
ERROR = 'ERROR'
SEVERITY_LEVELS = [DEBUG, INFO, WARNING, ERROR]
# Global instances of configured cloud client/loggers
_GLOBAL_CLIENT = None
_GLOBAL_LOGGER = None
_GLOBAL_THREAD = None
_GLOBAL_LABELS = {}
class BaseError(Exception):
"""Base error."""
class ConfigurationError(BaseError):
"""Base error for invalid log configurations."""
class EventError(BaseError):
"""Base error for Metrics."""
class TimerError(BaseError):
"""Base error for Timers."""
class StrictAttributesError(BaseError):
"""Base Error for StrictAttributes decorator."""
class LoggerError(BaseError):
"""Base error for errors raised in logger."""
def __init__(self, original_error):
super(LoggerError, self).__init__(self)
self.original_error = original_error
self.original_trace = traceback.format_exc()
def __str__(self):
return self.original_trace
class LoggerGaxError(LoggerError):
"""Class for gax errors raised in logger."""
class LogEvent(object):
"""A high level LogEvent class used for logging events and data."""
def __init__(self, name, attributes=None, value=None, severity=None):
"""Initialize a LogEvent.
Args:
name: The name of the event being timed.
attributes: A dict representing attributes to be recorded with this timer.
value: A value to include in the Metric.
severity: A string representing the log severity. One of:
'DEBUG', 'INFO', 'WARNING', 'ERROR' or None.
"""
self._name = self._Sanitize(name)
self._attributes = {}
if not attributes:
attributes = {}
self.AddAttributes(attributes)
self._host = socket.getfqdn()
self._recorded_at = self._Now()
self._logged = False
self._value = self._Sanitize(value)
self._type = 'general'
self._severity = severity
@property
def type(self):
"""Returns the type of event."""
return self._type
@property
def value(self):
"""Returns the value for this event."""
return self._value
@value.setter
def value(self, value):
"""Sets the value for this event."""
self._value = self._Sanitize(value)
@property
def severity(self):
"""Returns the severity of event."""
return self._severity
@severity.setter
def severity(self, severity):
"""Sets the severity of this event.
Only valid severity values will be accepted, all others will fail silently.
Args:
severity: A string representing the log severity. One of:
'DEBUG', 'INFO', 'WARNING', 'ERROR' or None.
"""
self._severity = None
if severity in SEVERITY_LEVELS:
self._severity = severity
@property
def recorded_at(self):
"""Get the timestamp for when this event was recorded."""
return self._recorded_at
@property
def name(self):
return self._name
@property
def host(self):
return self._host
@property
def attributes(self):
return self._attributes
def _Sanitize(self, value):
"""Sanitizes arguments so they can be uploaded to GCL.
If it is a supported primitive that will be returned, otherwise its str
representation will be used.
This will also iterate through lists & dicts and sanitize their values.
Args:
value: The value to sanitize.
Returns:
The sanitized version of value.
"""
if isinstance(value, (six.integer_types, float, bool)):
return value
if isinstance(value, list):
return [self._Sanitize(v) for v in value]
if isinstance(value, dict):
return {self._Sanitize(key): self._Sanitize(v)
for key, v in six.iteritems(value)}
try:
return str(value)
except Exception as e: # pylint: disable=broad-except
return 'Could not sanitize object of type %s: %s' % (type(value), e)
def AddAttributes(self, attributes):
"""Add more attributes to the event.
Args:
attributes: A dict of attributes to add to the current attribute list.
They will override other attributes with the same keys.
Returns:
self.
"""
if not attributes:
return self
for key, value in six.iteritems(attributes):
self.AddAttribute(key, value)
return self
def AddAttribute(self, name, value):
"""Add a single attribute to the event.
Args:
name: The key to be used in the attribute dict.
value: The value to put for the given key. Note, if the key already
existed its value will be overridden.
Returns:
self.
"""
self._attributes[self._Sanitize(name)] = self._Sanitize(value)
return self
def AsDict(self):
"""Returns a dictionary representation of this event."""
result = {
'name': self.name,
'recorded_at_utc_usec': self.recorded_at,
'value': self.value,
'type': self.type,
'host': self.host,
'attributes': self.attributes
}
return result
def Valid(self):
"""Returns if this event is valid."""
return True
def Log(self):
"""Log this event.
Raises:
EventError: If the event is invalid.
"""
if not self.Valid():
raise EventError('Event not valid.')
if not self._logged:
_SendEvent(self, severity=self._severity)
self._logged = True
def _Now(self):
"""Returns the UTC usec timestamp for now."""
return time.time() * 1000 * 1000
class Timer(LogEvent):
"""A simpler timer object to record & log the duration of something."""
def __init__(self, name, attributes=None, severity=None):
"""Initialize a timer.
Remember to call Start() to begin timing.
Args:
name: The name of the event being timed.
attributes: A dict representing attributes to be recorded with this timer.
severity: A string representing the log severity. One of:
'DEBUG', 'INFO', 'WARNING', 'ERROR' or None.
"""
super(Timer, self).__init__(name, attributes=attributes, severity=severity)
self._started_at = None
self._finished_at = None
self._type = 'timer'
def Start(self):
"""Starts the timer.
Returns:
self.
"""
self._started_at = self._Now()
return self
def Stop(self):
"""Stops the timer and records the result.
Note that the timer must have been started.
Returns:
self.
Raises:
TimerError: If the timer has not yet been started.
"""
if not self.started:
raise TimerError('Timer not yet started.')
self._finished_at = self._Now()
self.Log()
return self
@property
def started(self):
"""Returns boolean if this timer has been started."""
return self._started_at is not None
@property
def finished(self):
"""Returns boolean if this timer has been completed."""
return self._finished_at is not None
def Time(self):
"""Returns the value recorded by this timer.
Returns:
The timed value.
Raises:
TimerError: If the timer has not been started or completed.
"""
if not self.started:
raise TimerError('Timer not yet started.')
if not self.finished:
raise TimerError('Timer not yet finished.')
return self._finished_at - self._started_at
@property
def value(self):
return self.Time()
@property
def recorded_at(self):
return self._finished_at
def Valid(self):
try:
self.Time()
return True
except TimerError:
return False
def __enter__(self):
self.Start()
return self
def __exit__(self, *args):
self.Stop()
class StrictAttributes(object):
"""Decorator for LogEvents that require a fixed set of attributes.
This decorator should be used when collecting metrics to be uploaded to
BigQuery. All attributes to be collected must be included in valid_attributes,
mapped to their default values. Any valid_attributes not added before Log()
will be included with the specified default value.
Sample Usage:
VALID_ATTRIBUTES = {
'status': StrictAttributes.DEFAULT_STRING,
'count': StrictAttributes.DEFAULT_INT }
@StrictAttributes(VALID_ATTRIBUTES)
class MyLogger(Timer):
...
"""
# TODO(adrexler): Migrate valid_attributes to protobuf.
# Default values for all accepted attribute datatypes.
DEFAULT_STRING = ''
DEFAULT_BOOL = False
DEFAULT_LIST = []
DEFAULT_DICT = {}
DEFAULT_INT = -1
DEFAULT_FLOAT = float(DEFAULT_INT)
try:
DEFAULT_LONG = long(DEFAULT_INT)
except NameError:
DEFAULT_LONG = int(DEFAULT_INT) # pylint: disable=redefined-variable-type
def __init__(self, valid_attributes):
"""Constructor.
Args:
valid_attributes: All acceptable attributes, mapped to default values.
"""
self._valid_attributes = valid_attributes
def __call__(self, cls, *args, **kwargs):
"""Returns MetricsCollector class, extending cls."""
if not issubclass(cls, LogEvent):
raise StrictAttributesError('%s does not extend LogEvent!', cls)
valid_attributes = self._valid_attributes
class MetricsCollector(cls):
"""Extension of LogEvent child cls, with strict attributes."""
def __init__(self, *args, **kwargs):
self._valid_attributes = valid_attributes
super(MetricsCollector, self).__init__(*args, **kwargs)
def AddAttribute(self, name, value):
"""Adds value for attribute after checking that attribute is valid."""
if name not in self._valid_attributes:
logging.error('%s is not a valid attribute!', name)
return
super(MetricsCollector, self).AddAttribute(name, value)
def Log(self):
"""Adds specified default values for missing attributes and logs."""
for attribute in self._valid_attributes:
if attribute not in self._attributes:
self.AddAttribute(attribute, self._valid_attributes[attribute])
super(MetricsCollector, self).Log()
return MetricsCollector
class BatchThread(threading.Thread):
"""A simple thread worker to call commit on the Batch Logger."""
def __init__(self, duration, err_limit=3, sleep=2.0):
super(BatchThread, self).__init__()
self._duration = duration
self._sleep = sleep
self._stop = threading.Event()
self._err_limit = err_limit
def run(self):
while not self._stop.isSet():
time.sleep(self._duration)
if not _CommitWithRetry(self._sleep, self._err_limit):
logging.error('Terminated cloud logging due to non-transient error')
self._stop.set()
def join(self, timeout=None):
self._stop.set()
super(BatchThread, self).join(timeout)
def Debug(*args, **kwargs):
return LogEvent(*args, severity=DEBUG, **kwargs)
def Info(*args, **kwargs):
return LogEvent(*args, severity=INFO, **kwargs)
def Warn(*args, **kwargs):
return LogEvent(*args, severity=WARNING, **kwargs)
def Error(*args, **kwargs):
return LogEvent(*args, severity=ERROR, **kwargs)
def _SendEvent(event, severity=None):
"""Log a metric.
Args:
event: A LogEvent to log. Must have an AsDict method implemented.
severity: A string representing the log severity. This will be ignored if
fluentd is enabled.
Raises:
LoggerGaxError: If GaxError other then DEADLINE_EXCEEDED was
raised during logger call.
LoggerError: If any error other then ParseError or GaxError was
raised during logger call.
"""
if fluentd.get_global_sender():
fluentd_event.Event(None, event.AsDict())
elif _GLOBAL_LOGGER:
try:
_GLOBAL_LOGGER.log_struct(
event.AsDict(), labels=_GLOBAL_LABELS, severity=severity)
except errors.GaxError as e:
if grpc.exc_to_code(e.cause) != grpc.StatusCode.DEADLINE_EXCEEDED:
raise LoggerGaxError(e)
logging.exception('Gax RPC Deadline exceeded while push to log event %s'
' with labels %s, not expecting event loss in cloud',
event.AsDict(), _GLOBAL_LABELS)
except json_format.ParseError as e:
logging.exception('Unable to log event %s with labels %s'
' event will not be reported to cloud',
event.AsDict(), _GLOBAL_LABELS)
except Exception as e: # pylint: disable=broad-except
raise LoggerError(e)
else:
logging.info(event.AsDict())
def _CommitWithRetry(sleep=2.0, retries=3):
"""Commiting event queue. Dispatching errors and recover if possible.
The only transient error we are aware of is a Gax RetryError.
In 99% cases we succeed from 3rd commit and will not succeed after that
without clearing commit queue.
For other then Gax Retry Error we will not recover, so returning False
Args:
sleep: Sleep period between retries in case of error.
retries: Maximum number of commits before clearing queue.
Return:
Boolean if the batch process should continue
"""
while retries > 0:
try:
_GLOBAL_LOGGER.commit()
return True
except errors.RetryError as e:
retries -= 1
queue_size = len(_GLOBAL_LOGGER.entries[:])
err = str(e)
logging.exception('Gax Retry Error while commit log buffer,'
' %d retries left,%s %d events in queue', retries,
'' if retries else ' dropping', queue_size)
if retries > 0:
Warn('RetryError', value=queue_size, attributes={
'retries_left': retries, 'error': err,
'dropping_queue': False}).Log()
time.sleep(sleep)
else:
del _GLOBAL_LOGGER.entries[:]
Warn('RetryError', value=queue_size, attributes={
'retries_left': 0, 'error': err,
'dropping_queue': True}).Log()
except errors.GaxError as e:
logging.exception('Gax Error while commit log buffer, stopping batch')
return False
except Exception as e: # pylint: disable=broad-except
logging.exception('Unexpected Error while commit log buffer,'
' stopping batch')
return False
return True
def ConfigureLogger(project_name, logger_name, labels=None, batch_time=None,
batch_err_limit=3):
"""Configure a Cloud Logger.
Does NOT enable logging to a fluentd agent. See EnableFluentdLogger()
Args:
project_name: The name of the project to log to.
logger_name: The name of the logger.
labels: An optional dict of labels to apply to each message.
batch_time: use a batch logger to send logs every n seconds.
batch_err_limit: max consecutive errors before batch logging is terminated.
Raises:
ConfigurationError: If no credentials were found.
"""
if 'GOOGLE_APPLICATION_CREDENTIALS' not in os.environ:
raise ConfigurationError(
('No credentials found. Make sure a json based key is available in the '
'GOOGLE_APPLICATION_CREDENTIALS env var.')
)
global _GLOBAL_CLIENT
global _GLOBAL_LOGGER
global _GLOBAL_THREAD
_GLOBAL_CLIENT = gcl.Client(project=project_name)
_GLOBAL_LOGGER = _GLOBAL_CLIENT.logger(logger_name)
if batch_time:
_GLOBAL_LOGGER = _GLOBAL_LOGGER.batch()
_GLOBAL_THREAD = BatchThread(batch_time, batch_err_limit)
_GLOBAL_THREAD.start()
_GLOBAL_LABELS['host'] = socket.getfqdn()
if labels:
_GLOBAL_LABELS.update(labels)
def SafeConfigureLogger(*args, **kwargs):
"""Configures a Cloud Logger, if possible. Otherwise fails silently.
Does NOT enable logging to a fluentd agent. See EnableFluentdLogger()
"""
try:
ConfigureLogger(*args, **kwargs)
except ConfigurationError:
logging.error('Unable to configure cloud logging: No GAE credentials '
'found. Logging to console only.')
def EnableFluentdLogger(namespace, host="127.0.0.1", port=24224):
"""Enable logging to a fluentd agent for logging to Google Cloud.
This disables any other form of logging, including both the Cloud Logging API
and local logging.
Args:
namespace: The namespace for logs in this application. For example
"buildbot", which becomes referenced in Google Cloud as
logName: "projects/<project>/logs/<namespace>"
host: The hostname or IP the fluentd agent lives on.
port: The port the fluentd agent is listening on.
"""
if fluentd.get_global_sender() is None:
fluentd.setup(namespace, host=host, port=port)
@atexit.register
def StopLogger():
"""
Stop the batch logger thread and/or fluentd logger if started. No more
logs will be sent.
This will be executed on application termination
"""
if _GLOBAL_THREAD:
_GLOBAL_THREAD.join()
if fluentd.get_global_sender():
fluentd.close()