Repository: SneaksAndData/adapta (language: python)

Issue:
[FEATURE] Logging formatter for python clients
**Is your feature request related to a problem? Please describe.**
Everybody logs like they want. In the end, it is hard to parse logs and do analysis on them in grafana.

**Describe the solution you'd like**
This is solved in .netcore projects by using serilog json formatter. We should adopt the same practice for python, using `logging` library.



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 7cca8fcc..0678d1ea 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -26,7 +26,7 @@ jobs:
         run: |
           set -euxo pipefail
           
-          poetry install --extras "azure"
+          poetry install --extras "azure datadog"
       - name: Lint
         run: |
           set -euxo pipefail
diff --git a/poetry.lock b/poetry.lock
index 8cc197e6..cb4530dd 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -204,6 +204,35 @@ sdist = ["setuptools_rust (>=0.11.4)"]
 ssh = ["bcrypt (>=3.1.5)"]
 test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
 
+[[package]]
+name = "datadog"
+version = "0.44.0"
+description = "The Datadog Python library"
+category = "main"
+optional = true
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+
+[package.dependencies]
+requests = ">=2.6.0"
+
+[[package]]
+name = "datadog-api-client"
+version = "1.12"
+description = "Collection of all Datadog Public endpoints"
+category = "main"
+optional = true
+python-versions = ">=3.7"
+
+[package.dependencies]
+certifi = "*"
+python-dateutil = "*"
+urllib3 = ">=1.15"
+
+[package.extras]
+apm = ["ddtrace (>=1.0.0rc1)"]
+async = ["aiosonic"]
+tests = ["aiosonic", "glom", "jinja2", "pytest", "pytest-bdd (>=5.0.0)", "pytest-asyncio", "pytest-randomly", "pytest-recording", "python-dateutil", "mypy", "types-python-dateutil"]
+
 [[package]]
 name = "deltalake"
 version = "0.5.6"
@@ -271,6 +300,14 @@ requirements_deprecated_finder = ["pipreqs", "pip-api"]
 colors = ["colorama (>=0.4.3,<0.5.0)"]
 plugins = ["setuptools"]
 
+[[package]]
+name = "json-log-formatter"
+version = "0.5.1"
+description = "JSON log formatter"
+category = "main"
+optional = false
+python-versions = "*"
+
 [[package]]
 name = "lazy-object-proxy"
 version = "1.7.1"
@@ -692,11 +729,12 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
 
 [extras]
 azure = ["azure-identity", "azure-storage-blob", "azure-mgmt-storage", "azure-keyvault-secrets", "azure-servicebus"]
+datadog = ["datadog", "datadog-api-client"]
 
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.8"
-content-hash = "737e5eb23ba5bfd156e34bd8b0c96e735c9bc3cc1f154c62bd4eff6d47e9c024"
+content-hash = "c6a091dc7b9b862babd5ad817e921efde9d590a4f6494419681c977b31ff7fdc"
 
 [metadata.files]
 astroid = [
@@ -872,6 +910,14 @@ cryptography = [
     {file = "cryptography-36.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e167b6b710c7f7bc54e67ef593f8731e1f45aa35f8a8a7b72d6e42ec76afd4b3"},
     {file = "cryptography-36.0.2.tar.gz", hash = "sha256:70f8f4f7bb2ac9f340655cbac89d68c527af5bb4387522a8413e841e3e6628c9"},
 ]
+datadog = [
+    {file = "datadog-0.44.0-py2.py3-none-any.whl", hash = "sha256:57c4878d3a8351f652792cdba78050274789dcc44313adec096e87f9d3ca5992"},
+    {file = "datadog-0.44.0.tar.gz", hash = "sha256:071170f0c7ef22511dbf7f9bd76c4be500ee2d3d52072900a5c87b5495d2c733"},
+]
+datadog-api-client = [
+    {file = "datadog-api-client-1.12.tar.gz", hash = "sha256:2f3a347f5b6768be2f31650f5db947bccd7c79e82cc8d2424ce82723d63781b7"},
+    {file = "datadog_api_client-1.12-py3-none-any.whl", hash = "sha256:de9e68f441c1c6fe5be1d5d970408ad70f8b9e3273a8893a48ad563a43732e83"},
+]
 deltalake = [
     {file = "deltalake-0.5.6-cp36-abi3-macosx_10_7_x86_64.whl", hash = "sha256:eb419eac9382fd01390f091215e31c5c3bb4d29e421ffc94a55cd306805b1dfa"},
     {file = "deltalake-0.5.6-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:3128d6df02ea2a82b2227195743f305f3bac66f308848bb58ba10a79b8565d7a"},
@@ -899,6 +945,9 @@ isort = [
     {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
     {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
 ]
+json-log-formatter = [
+    {file = "JSON-log-formatter-0.5.1.tar.gz", hash = "sha256:53246bcc5be5452bd46020326c50556ac6d35be20f00733d7235e0ca90d0c5f1"},
+]
 lazy-object-proxy = [
     {file = "lazy-object-proxy-1.7.1.tar.gz", hash = "sha256:d609c75b986def706743cdebe5e47553f4a5a1da9c5ff66d76013ef396b5a8a4"},
     {file = "lazy_object_proxy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb8c5fd1684d60a9902c60ebe276da1f2281a318ca16c1d0a96db28f62e9166b"},
diff --git a/proteus/logs/README.md b/proteus/logs/README.md
new file mode 100644
index 00000000..2b907641
--- /dev/null
+++ b/proteus/logs/README.md
@@ -0,0 +1,61 @@
+# Logging Integrations
+
+This module provides a generic interface to plug json-formatted logging into your python application.
+
+## Generic Usage
+
+First, create a logger object and add some log sources. Then log on a desired level directly using default handler:
+
+```python
+from proteus.logs import ProteusLogger
+from proteus.logs.models import LogLevel
+
+proteus_logger = ProteusLogger() \
+    .add_log_source(log_source_name='proteus_test_logger_1', min_log_level=LogLevel.INFO, is_default=True) \
+    .add_log_source(log_source_name='proteus_test_logger_2', min_log_level=LogLevel.ERROR)
+
+# INFO level, default log source
+
+proteus_logger.info(template='Test message: {message}', message='important')
+
+# ERROR level with exception
+
+try:
+    raise ValueError('Big boom')
+except ValueError as ex:
+    proteus_logger.error(log_source_name='proteus_test_logger_2',
+                         template='Test error message: {message}', message='failure',
+                         exception=ex)
+```
+
+You can also use `Logger` instances directly:
+
+```python
+from proteus.logs import ProteusLogger
+from proteus.logs.models import LogLevel
+
+proteus_logger = ProteusLogger() \
+    .add_log_source(log_source_name='proteus_test_logger_1', min_log_level=LogLevel.INFO, is_default=True)
+
+logger = proteus_logger.proteus_test_logger_1
+```
+
+### DataDog handler
+
+In order to send logs to DataDog, use `DataDogApiHandler` when adding a log source. If you still want messages
+in `stdout` or `stderr`, add `StreamHandler` on top:
+
+```python
+from logging import StreamHandler
+
+from proteus.logs import ProteusLogger
+from proteus.logs.models import LogLevel
+from proteus.logs.handlers.datadog_api_handler import DataDogApiHandler
+
+proteus_logger = ProteusLogger() \
+    .add_log_source(log_source_name='proteus_test_logger_1', min_log_level=LogLevel.INFO,
+                    log_handlers=[DataDogApiHandler(), StreamHandler()], is_default=True)
+```
+
+Remember to set `DD_API_KEY`, `DD_APP_KEY` and `DD_SITE` environment variables before creating an instance
+of `DataDogApiHandler()`.
diff --git a/proteus/logs/__init__.py b/proteus/logs/__init__.py
new file mode 100644
index 00000000..cb52649c
--- /dev/null
+++ b/proteus/logs/__init__.py
@@ -0,0 +1,4 @@
+"""
+ Module index.
+"""
+from proteus.logs._base import ProteusLogger
diff --git a/proteus/logs/_base.py b/proteus/logs/_base.py
new file mode 100644
index 00000000..a295e38d
--- /dev/null
+++ b/proteus/logs/_base.py
@@ -0,0 +1,157 @@
+"""
+ Proteus Logging Interface.
+"""
+import json
+import logging
+
+from logging import Handler, StreamHandler
+from typing import List, Optional
+
+import json_log_formatter
+
+from proteus.logs.models import LogLevel
+
+
+class ProteusLogger:
+    """
+     Proteus Proxy for Python logging library.
+    """
+
+    def __init__(self):
+        """
+          Creates a new instance of a ProteusLogger
+        """
+        self._loggers = {}
+        self._default_log_source = None
+
+    def add_log_source(self, *, log_source_name: str, min_log_level: LogLevel,
+                       log_handlers: Optional[List[Handler]] = None,
+                       is_default=False) -> 'ProteusLogger':
+        """
+          Adds a new log source.
+
+        :param log_source_name: Name of a source.
+        :param min_log_level: Minimal log level for this source.
+        :param log_handlers: Attached log handlers. StreamHandler is used if not provided.
+        :param is_default: Whether this log source should be used in a `log` method when no log source is explicitly provided.
+        :return:
+        """
+        new_logger = logging.getLogger(log_source_name)
+        new_logger.setLevel(min_log_level.value)
+
+        if not log_handlers or len(log_handlers) == 0:
+            log_handlers = [StreamHandler()]
+
+        for log_handler in log_handlers:
+            log_handler.setFormatter(json_log_formatter.JSONFormatter())
+            new_logger.addHandler(log_handler)
+
+        self._loggers.setdefault(log_source_name, new_logger)
+
+        if is_default:
+            self._default_log_source = log_source_name
+
+        return self
+
+    def __getattr__(self, log_source) -> Optional[logging.Logger]:
+        if log_source in self._loggers:
+            return self._loggers[log_source]
+
+        return None
+
+    @staticmethod
+    def _prepare_message(template: str, tags: Optional[str] = None, diagnostics: Optional[str] = None,
+                         **kwargs) -> str:
+        """
+         Returns message dictionary to be used by handler formatter.
+         :param exclude_fields: Fields to exclude from export
+
+        :return:
+        """
+        base_object = {
+            'template': template,
+            'text': template.format(**kwargs)
+        }
+        if tags:
+            base_object.setdefault('tags', tags)
+        if diagnostics:
+            base_object.setdefault('diagnostics', diagnostics)
+
+        return json.dumps(base_object)
+
+    def _get_logger(self, log_source_name: Optional[str] = None) -> logging.Logger:
+        """
+          Retrieves a logger by log source name, or a default logger is log source name is not provided.
+
+        :param log_source_name: Optional name of a log source.
+        :return:
+        """
+        assert log_source_name or self._default_log_source, 'Argument `log_source` must be provided when no default log source is added. You can add a log source as default by calling `add_log_source(.., is_default=True)`'
+
+        if log_source_name:
+            assert log_source_name in self._loggers, f"{log_source_name} does not have an associated logger. Use add_log_source() to associate a logger with this log source."
+
+        return self._loggers[log_source_name or self._default_log_source]
+
+    def info(self, template: str, tags: Optional[str] = None, log_source_name: Optional[str] = None, **kwargs) -> None:
+        """
+          Sends an INFO level message to configured log sources.
+
+        :param template: Message template.
+        :param tags: Optional message tags.
+        :param log_source_name: Optional name of a log source, if not using a default.
+        :param kwargs: Templated arguments (key=value).
+        :return:
+        """
+        logger = self._get_logger(log_source_name)
+        logger.info(msg=self._prepare_message(template=template, tags=tags, diagnostics=None, **kwargs))
+
+    def warning(self, template: str, exception: BaseException, tags: Optional[str] = None,
+                log_source_name: Optional[str] = None, **kwargs) -> None:
+        """
+          Sends a WARNING level message to configured log sources.
+
+        :param template: Message template.
+        :param exception: Exception associated with this warning.
+        :param tags: Optional message tags.
+        :param log_source_name: Optional name of a log source, if not using a default.
+        :param kwargs: Templated arguments (key=value).
+        :return:
+        """
+        logger = self._get_logger(log_source_name)
+        logger.warning(msg=self._prepare_message(template=template, tags=tags, diagnostics=None, **kwargs),
+                       exc_info=exception, stack_info=True, stacklevel=3)
+
+    def error(self, template: str, exception: BaseException, tags: Optional[str] = None,
+              log_source_name: Optional[str] = None, **kwargs) -> None:
+        """
+          Sends an ERROR level message to configured log sources.
+
+        :param template: Message template.
+        :param exception: Exception associated with this error.
+        :param tags: Optional message tags.
+        :param log_source_name: Optional name of a log source, if not using a default.
+        :param kwargs: Templated arguments (key=value).
+        :return:
+        """
+        logger = self._get_logger(log_source_name)
+        logger.error(msg=self._prepare_message(template=template, tags=tags, diagnostics=None, **kwargs),
+                     exc_info=exception, stack_info=True, stacklevel=3)
+
+    def debug(self, template: str, exception: BaseException, diagnostics: Optional[str] = None,  # pylint: disable=R0913
+              tags: Optional[str] = None,
+              log_source_name: Optional[str] = None, **kwargs) -> None:
+        """
+          Sends a DEBUG level message to configured log sources.
+
+        :param template: Message template.
+        :param exception: Exception associated with this error.
+        :param diagnostics: Optional additional diagnostics info.
+        :param tags: Optional message tags.
+        :param log_source_name: Optional name of a log source, if not using a default.
+        :param kwargs: Templated arguments (key=value).
+        :return:
+        """
+        logger = self._get_logger(log_source_name)
+        logger.error(msg=self._prepare_message(template=template, tags=tags, diagnostics=diagnostics, **kwargs),
+                     exc_info=exception, stack_info=True, stacklevel=3)
diff --git a/proteus/logs/handlers/__init__.py b/proteus/logs/handlers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/proteus/logs/handlers/datadog_api_handler.py b/proteus/logs/handlers/datadog_api_handler.py
new file mode 100644
index 00000000..4c1a26e1
--- /dev/null
+++ b/proteus/logs/handlers/datadog_api_handler.py
@@ -0,0 +1,108 @@
+"""
+  Logging handler for DataDog.
+"""
+import json
+import os
+import signal
+import socket
+import platform
+import traceback
+from logging import LogRecord, Handler
+from typing import List
+
+from datadog_api_client import Configuration, ApiClient
+from datadog_api_client.v2.api.logs_api import LogsApi
+from datadog_api_client.v2.model.http_log import HTTPLog
+from datadog_api_client.v2.model.http_log_item import HTTPLogItem
+
+
+class DataDogApiHandler(Handler):
+    """
+      Logging handler for DataDog.
+    """
+    def __init__(self, *, buffer_size=10, async_handler=False, debug=False):
+        """
+          Creates a handler than can upload log records to DataDog index.
+
+          Additional docs: https://docs.datadoghq.com/logs/log_collection/?tab=host#attributes-and-tags
+
+        :param buffer_size: Optional number of records to buffer up in memory before sending to DataDog.
+        :param async_handler: Whether to send requests in an async manner. Only use this for production.
+        :param debug: Whether to print messages from this handler to the console. Use this to debug handler behaviour.
+        """
+        super().__init__()
+        assert os.getenv(
+            'DD_API_KEY'), 'DD_API_KEY environment variable must be set in order to use DataDogApiHandler'
+        assert os.getenv('DD_APP_KEY', 'DD_APP_KEY environment variable must be set in order to use DataDogApiHandler')
+        assert os.getenv('DD_SITE', 'DD_SITE environment variable must be set in order to use DataDogApiHandler')
+
+        configuration = Configuration()
+        configuration.server_variables["site"] = os.getenv('DD_SITE')
+
+        if debug:
+            configuration.debug = True
+
+        self._logs_api = LogsApi(api_client=ApiClient(configuration))
+        self._buffer: List[HTTPLogItem] = []
+        self._buffer_size = buffer_size
+        self._async_handler = async_handler
+        self._debug = debug
+
+        # send records even if an application is interrupted
+        if platform.system() != "Windows":
+            signal.signal(signal.SIGINT, self._flush)
+            signal.signal(signal.SIGTERM, self._flush)
+
+    def _flush(self) -> None:
+        """
+         Flushes a log buffer to the consumer
+
+        :return:
+        """
+        result = self._logs_api.submit_log(
+            body=HTTPLog(value=self._buffer),
+            content_encoding='gzip',
+            async_req=self._async_handler
+        )
+        if self._async_handler:
+            result = result.get()
+
+        if self._debug:
+            print(f"DataDog response: {result}")
+
+        self._buffer = []
+
+    def emit(self, record: LogRecord) -> None:
+        def convert_record(rec: LogRecord) -> HTTPLogItem:
+
+            record_json = json.loads(self.format(rec))
+            record_message = json.loads(record_json['message'])
+
+            tags = record_json.get('tags', None)
+            if tags:
+                record_json.pop('tags')
+
+            if rec.exc_info:
+                ex_type, _, ex_tb = rec.exc_info
+                record_message.setdefault('error', {
+                    'stack': '\n'.join(traceback.format_tb(ex_tb)),
+                    'message': rec.exc_text,
+                    'kind': ex_type.__name__
+                })
+
+            return HTTPLogItem(
+                ddsource=rec.name,
+                ddtags=tags,
+                hostname=socket.gethostname(),
+                message=json.dumps(record_message),
+                status=rec.levelname
+            )
+
+        if len(self._buffer) < self._buffer_size:
+            self._buffer.append(convert_record(record))
+        else:
+            self._buffer.append(convert_record(record))
+            self._flush()
+
+    def flush(self) -> None:
+        self._flush()
diff --git a/proteus/logs/models/__init__.py b/proteus/logs/models/__init__.py
new file mode 100644
index 00000000..b59ca930
--- /dev/null
+++ b/proteus/logs/models/__init__.py
@@ -0,0 +1,5 @@
+"""
+ Module index.
+"""
+
+from proteus.logs.models._log_level import LogLevel
diff --git a/proteus/logs/models/_log_level.py b/proteus/logs/models/_log_level.py
new file mode 100644
index 00000000..1b377962
--- /dev/null
+++ b/proteus/logs/models/_log_level.py
@@ -0,0 +1,14 @@
+"""
+ Standard log levels.
+"""
+from enum import Enum
+
+
+class LogLevel(Enum):
+    """
+     Supported log levels.
+    """
+    INFO = 'INFO'
+    WARN = 'WARN'
+    ERROR = 'ERROR'
+    DEBUG = 'DEBUG'
diff --git a/proteus/metrics/__init__.py b/proteus/metrics/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/pyproject.toml b/pyproject.toml
index cb2ac311..80042dee 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,11 @@ azure-mgmt-storage = { version = "~19.1.0", optional = true }
 azure-keyvault-secrets = { version = "~4.3", optional = true }
 azure-servicebus = { version = "~7.3", optional = true }
 
+datadog = { version = "~0.44.0", optional = true }
+datadog-api-client = { version = "~1.12.0", optional = true }
+
+JSON-log-formatter = "~0.5.1"
+
 [tool.poetry.extras]
 azure = [
     'azure-identity',
@@ -30,6 +35,10 @@ azure = [
     'azure-keyvault-secrets',
     'azure-servicebus'
 ]
+datadog = [
+    'datadog',
+    'datadog-api-client'
+]
 
 [tool.poetry.dev-dependencies]
 pytest = "~6.2"


Test patch:
diff --git a/tests/test_logs.py b/tests/test_logs.py
new file mode 100644
index 00000000..f196b37d
--- /dev/null
+++ b/tests/test_logs.py
@@ -0,0 +1,100 @@
+import json
+import os
+import socket
+from logging import StreamHandler
+
+import tempfile
+from typing import Dict
+
+import pytest
+import uuid
+
+from datadog_api_client.v2.model.http_log_item import HTTPLogItem
+from pytest_mock import MockerFixture
+
+from proteus.logs.handlers.datadog_api_handler import DataDogApiHandler
+from proteus.logs import ProteusLogger
+from proteus.logs.models import LogLevel
+
+
+@pytest.mark.parametrize('level,template,args,exception,diagnostics,expected_message', [
+    (
+            LogLevel.INFO,
+            'This a unit test logger {index}',
+            {'index': 1},
+            None,
+            None,
+            '{"template": "This a unit test logger {index}", "text": "This a unit test logger 1"}'
+    ),
+    (
+            LogLevel.WARN,
+            'This a unit test logger {index}',
+            {'index': 1},
+            ValueError("test warning"),
+            None,
+            '{"template": "This a unit test logger {index}", "text": "This a unit test logger 1"}'
+    ),
+    (
+            LogLevel.ERROR,
+            'This a unit test logger {index}',
+            {'index': 1},
+            ValueError("test error"),
+            None,
+            '{"template": "This a unit test logger {index}", "text": "This a unit test logger 1"}'
+    ),
+    (
+            LogLevel.DEBUG,
+            'This a unit test logger {index}',
+            {'index': 1},
+            ValueError("test error"),
+            'additional debug info',
+            '{"template": "This a unit test logger {index}", "text": "This a unit test logger 1", "diagnostics": "additional debug info"}'
+    )
+])
+def test_log_format(level: LogLevel, template: str, args: Dict, exception: BaseException, diagnostics: str,
+                    expected_message: str):
+    test_file_path = os.path.join(tempfile.gettempdir(), str(uuid.uuid4()))
+    with open(test_file_path, 'w') as log_stream:
+        stream_logger = ProteusLogger() \
+            .add_log_source(log_source_name=str(uuid.uuid4()), min_log_level=LogLevel.DEBUG, is_default=True,
+                            log_handlers=[StreamHandler(stream=log_stream)])
+
+        if level == LogLevel.INFO:
+            stream_logger.info(template=template, **args)
+        if level == LogLevel.WARN:
+            stream_logger.warning(template=template, exception=exception, **args)
+        if level == LogLevel.ERROR:
+            stream_logger.error(template=template, exception=exception, **args)
+        if level == LogLevel.DEBUG:
+            stream_logger.debug(template=template, exception=exception, diagnostics=diagnostics, **args)
+
+    logged_lines = open(test_file_path, 'r').readlines()
+    try:
+        entry = json.loads(''.join(logged_lines))
+        assert entry['message'] == expected_message
+    except Exception as ex:
+        raise ex
+
+
+def test_datadog_api_handler(mocker: MockerFixture):
+    os.environ.setdefault('DD_API_KEY', 'some-key')
+    os.environ.setdefault('DD_APP_KEY', 'some-app-key')
+    os.environ.setdefault('DD_SITE', 'some-site.dog')
+
+    mocker.patch('proteus.logs.handlers.datadog_api_handler.DataDogApiHandler._flush', return_value=None)
+    mock_handler = DataDogApiHandler(buffer_size=1)
+    mock_source = str(uuid.uuid4())
+
+    dd_logger = ProteusLogger() \
+        .add_log_source(log_source_name=mock_source, min_log_level=LogLevel.INFO,
+                        log_handlers=[mock_handler], is_default=True)
+
+    dd_logger.warning(template='This a unit test logger {index}', exception=ValueError("test warning"), index=1)
+
+    assert mock_handler._buffer[0] == HTTPLogItem(
+        ddsource=mock_source,
+        ddtags=None,
+        hostname=socket.gethostname(),
+        message='{"template": "This a unit test logger {index}", "text": "This a ''unit test logger 1", "error": {"stack": "", "message": null, ''"kind": "ValueError"}}',
+        status='WARNING'
+    )