diff --git a/Pipfile.lock b/Pipfile.lock index 499f4ad..7553740 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -150,6 +150,7 @@ "sha256:a5781d6934a3341a1f9acb4ea5acdc7ea0a0855e689dbe755d070ca51e995435", "sha256:b10a7ddd03657c761fc503495bc36471c8158e3fc948573fb9fe82a7029d8efd" ], + "markers": "python_version >= '3.3'", "version": "==7.1.1" }, "ipython-genutils": { @@ -219,11 +220,11 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:646b3401b3b0bb7752100bc9b7aeecb36cb09cdfc63652b5856708b5ba8db7da", - "sha256:82766ffd7397e6661465e20bd1390db0781ca4fbbab4cf6c2578cacdd8b09754", - "sha256:ccad8461b5d912782726af17122113e196085e7e11d57cf0c9b982bf1ab2c7be" + "sha256:c1d6aff5252ab2ef391c2fe498ed8c088066f66bc64a8d5c095bbf795d9fec34", + "sha256:d4c47f79b635a0e70b84fdb97ebd9a274203706b1ee5ed44c10da62755cf3ec9", + "sha256:fd17048d8335c1e6d5ee403c3569953ba3eb8555d710bfc548faf0712666ea39" ], - "version": "==2.0.6" + "version": "==2.0.7" }, "ptyprocess": { "hashes": [ @@ -262,10 +263,10 @@ }, "pyparsing": { "hashes": [ - "sha256:bc6c7146b91af3f567cf6daeaec360bc07d45ffec4cf5353f4d7a208ce7ca30a", - "sha256:d29593d8ebe7b57d6967b62494f8c72b03ac0262b1eed63826c6f788b3606401" + "sha256:40856e74d4987de5d01761a22d1621ae1c7f8774585acae358aa5c5936c6c90b", + "sha256:f353aab21fd474459d97b709e527b5571314ee5f067441dc9f88e33eecd96592" ], - "version": "==2.2.2" + "version": "==2.3.0" }, "pytest": { "hashes": [ @@ -338,10 +339,10 @@ }, "urllib3": { "hashes": [ - "sha256:41c3db2fc01e5b907288010dec72f9d0a74e37d6994e6eb56849f59fea2265ae", - "sha256:8819bba37a02d143296a4d032373c4dd4aca11f6d4c9973335ca75f9c8475f59" + "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", + "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" ], - "version": "==1.24" + "version": "==1.24.1" }, "wcwidth": { "hashes": [ diff --git a/README.md b/README.md index 7b29900..e60848a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,72 @@ # python_google_cloud_logger [![CircleCI](https://circleci.com/gh/rai200890/python_google_cloud_logger.svg?style=svg&circle-token=cdb4c95268aa18f240f607082833c94a700f96e9)](https://circleci.com/gh/rai200890/python_google_cloud_logger) +[![PyPI version](https://badge.fury.io/py/google-cloud-logger.svg)](https://badge.fury.io/py/google-cloud-logger) [![Maintainability](https://api.codeclimate.com/v1/badges/e988f26e1590a6591d96/maintainability)](https://codeclimate.com/github/rai200890/python_google_cloud_logger/maintainability) -Python log formatter for Google Cloud +Python log formatter for Google Cloud according to [v2 specification](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry) using [python-json-logger](https://github.com/madzak/python-json-logger) formatter + +Inspired by Elixir's [logger_json](https://github.com/Nebo15/logger_json) + +## Instalation + +### Pipenv + +``` + pipenv install google_cloud_logger +``` + +### Pip + +``` + pip install google_cloud_logger +``` + +## Usage + +```python +LOG_CONFIG = { + "version": 1, + "formatters": { + "json": { + "()": "google_cloud_logger.GoogleCloudFormatter", + "application_info": { + "type": "python-application", + "name": "Example Application" + }, + "format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s" + } + }, + "handlers": { + "json": { + "class": "logging.StreamHandler", + "formatter": "json" + } + }, + "loggers": { + "root": { + "level": "INFO", + "handlers": ["json"] + } + } +} +import logging + +from logging import config + +config.dictConfig(LOG_CONFIG) # load log config from dict + +logger = logging.getLogger("root") # get root logger instance + + +logger.info("farofa", extra={"extra": "extra"}) # log message with extra arguments +``` + +Example output: + +```json +{"timestamp": "2018-11-03T22:05:03.818000Z", "severity": "INFO", "message": "farofa", "labels": {"type": "python-application", "name": "Example Application"}, "metadata": {"userLabels": {"extra": "extra"}}, "sourceLocation": {"file": "", "line": 1, "function": ""}} +``` ## Credits diff --git a/google_cloud_logger/__init__.py b/google_cloud_logger/__init__.py index 9922a79..b302fd1 100644 --- a/google_cloud_logger/__init__.py +++ b/google_cloud_logger/__init__.py @@ -1,4 +1,5 @@ from datetime import datetime +import inspect from pythonjsonlogger.jsonlogger import JsonFormatter @@ -13,24 +14,30 @@ def __init__(self, *args, **kwargs): self.application_info = kwargs.pop("application_info", {}) super(GoogleCloudFormatter, self).__init__(*args, **kwargs) + def _get_extra_fields(self, record): + fields = set(field for field in record.__dict__.keys() + if not inspect.ismethod(field)).difference( + set(self.reserved_attrs.keys())) + return {key: getattr(record, key) for key in fields if key} + def add_fields(self, log_record, record, _message_dict): entry = self.make_entry(record) for key, value in entry.items(): log_record[key] = value - def make_labels(self, record): - fields = set(record.__dict__.keys()).difference( - set(self.reserved_attrs.keys())) - extra = {key: getattr(record, key) for key in fields} - return {**self.application_info, **extra} + def make_labels(self): + return self.application_info + + def make_user_labels(self, record): + return self._get_extra_fields(record) def make_entry(self, record): return { "timestamp": self.format_timestamp(record.asctime), "severity": self.format_severity(record.levelname), "message": record.getMessage(), + "labels": self.make_labels(), "metadata": self.make_metadata(record), - "labels": self.make_labels(record), "sourceLocation": self.make_source_location(record) } @@ -49,8 +56,8 @@ def format_severity(self, level_name): } return levels[level_name.upper()] - def make_metadata(self, _record): - return {"userLabels": None} + def make_metadata(self, record): + return {"userLabels": self.make_user_labels(record)} def make_source_location(self, record): return { diff --git a/setup.py b/setup.py index 83c96ea..b98f987 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,10 @@ from setuptools import setup +__VERSION__ = "0.1.0" + setup( name="google_cloud_logger", - version="0.0.2", + version=__VERSION__, description="Google Cloud Logger Formatter", url="http://github.com/rai200890/python_google_cloud_logger", author="Raissa Ferreira", @@ -14,11 +16,9 @@ "python-json-logger>=v0.1.5", ], classifiers=[ - "Environment :: Web Environment", - "Intended Audience :: Developers", + "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", - "Natural Language :: English", - "Operating System :: OS Independent", + "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Topic :: System :: Logging" ], diff --git a/test/google_cloud_logger/conftest.py b/test/google_cloud_logger/conftest.py new file mode 100644 index 0000000..149fd96 --- /dev/null +++ b/test/google_cloud_logger/conftest.py @@ -0,0 +1,16 @@ +import pytest + + +class MockLogRecord(object): + def __init__(self, args={}, **kwargs): + merged = {**args, **kwargs} + for field, value in merged.items(): + setattr(self, field, value) + + +@pytest.fixture +def log_record_factory(): + def build_log_record(**args): + return MockLogRecord(**args) + + return build_log_record diff --git a/test/google_cloud_logger/test_google_cloud_formatter.py b/test/google_cloud_logger/test_google_cloud_formatter.py index b50dad5..8cb2a1a 100644 --- a/test/google_cloud_logger/test_google_cloud_formatter.py +++ b/test/google_cloud_logger/test_google_cloud_formatter.py @@ -13,14 +13,19 @@ def formatter(): @pytest.fixture -def record(mocker): - return mocker.Mock( - asctime="2018-08-30 20:40:57,245", - filename="_internal.py", - funcName="_log", - lineno="88", - levelname="WARNING", - getMessage=lambda: "farofa") +def record(log_record_factory, mocker): + data = { + "asctime": "2018-08-30 20:40:57,245", + "filename": "_internal.py", + "funcName": "_log", + "lineno": "88", + "levelname": "WARNING", + "message": "farofa", + "extra_field": "extra" + } + record = log_record_factory(**data) + record.getMessage = mocker.Mock(return_value=data["message"]) + return record def test_add_fields(formatter, record, mocker): @@ -31,9 +36,10 @@ def test_add_fields(formatter, record, mocker): return_value=OrderedDict([("timestamp", "2018-08-30 20:40:57Z"), ("severity", "WARNING"), ("message", "farofa"), - ("metadata", None), ("labels", { - "extra": "extra_args" + "type": "python-application" + }), ("metadata", { + "userLabels": {} }), ("sourceLocation", { "file": "_internal.py", @@ -51,8 +57,8 @@ def test_make_entry(formatter, record): assert entry["timestamp"] == "2018-08-30T20:40:57.245000Z" assert entry["severity"] == "WARNING" assert entry["message"] == "farofa" - assert entry["metadata"] == {"userLabels": None} - assert entry["labels"] is not None + assert entry["metadata"]["userLabels"]["extra_field"] == "extra" + assert entry["labels"] == {"type": "python-application"} assert entry["sourceLocation"] == { "file": "_internal.py", "function": "_log", @@ -60,14 +66,16 @@ def test_make_entry(formatter, record): } -def test_make_labels(formatter, record): - labels = formatter.make_labels(record) +def test_make_labels(formatter): + labels = formatter.make_labels() - assert labels["type"] == "python-application" + assert labels == {"type": "python-application"} def test_make_metadata(formatter, record): - assert formatter.make_metadata(record) == {"userLabels": None} + metadata = formatter.make_metadata(record) + + assert metadata["userLabels"]["extra_field"] == "extra" def test_make_source_location(formatter, record):