Integrate pylog with xapp-frame-py

Integrate pylog (https://gerrit.o-ran-sc.org/r/admin/repos/com/pylog)
with xapp-frame-py.

Issue-ID: RIC-330
Signed-off-by: subhash kumar singh <subh.singh@samsung.com>
Change-Id: I305a7a9090d83a9b4e266760f8fd76a045aa5cc4
diff --git a/docs/index.rst b/docs/index.rst
index 74bc561..3d2d9e6 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -16,6 +16,7 @@
    rmr_api.rst
    alarm_api.rst
    rnib.rst
+   mdclogger.rst
    developer-guide.rst
    release-notes.rst
 
diff --git a/docs/mdclogger.rst b/docs/mdclogger.rst
new file mode 100644
index 0000000..762c57e
--- /dev/null
+++ b/docs/mdclogger.rst
@@ -0,0 +1,128 @@
+..
+.. Copyright (c) 2019 AT&T Intellectual Property.
+..
+.. Copyright (c) 2019 Nokia.
+..
+.. Copyright (c) 2021 Samsung
+..
+.. Licensed under the Creative Commons Attribution 4.0 International
+..
+.. Public License (the "License"); you may not use this file except
+..
+.. in compliance with the License. You may obtain a copy of the License at
+..
+..
+..     https://creativecommons.org/licenses/by/4.0/
+..
+..
+.. Unless required by applicable law or agreed to in writing, documentation
+..
+.. 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.
+..
+.. This source code is part of the near-RT RIC (RAN Intelligent Controller)
+..
+.. platform project (RICP).
+..
+
+MDCLogger
+=========
+
+
+Usage
+-----
+
+The library can be used in as shown below.
+
+
+.. code:: bash
+
+ ```python
+   from ricappframe.logger.mdclogger import MDCLogger
+   my_logger = MDCLogger()
+   my_logger.mdclog_format_init(configmap_monitor=True)
+   my_logger.error("This is an error log")
+ ```
+
+A program can create several logger instances.
+
+mdclog_format_init() Adds the MDC log format with HostName, PodName, ContainerName, ServiceName,PID,CallbackNotifyforLogFieldChange
+
+Pass configmap_monitor = False in mdclog_format_init() function to stop dynamic log level change based on configmap.
+
+Logging Levels
+--------------
+.. code:: bash
+
+ """Severity levels of the log messages."""
+     DEBUG = 10
+     INFO = 20
+     WARNING = 30
+     ERROR = 40
+
+mdcLogger API's
+---------------
+
+1. Set current logging level
+
+.. code:: bash
+
+ def set_level(self, level: Level):
+
+        Keyword arguments:
+        level -- logging level. Log messages with lower severity will be filtered.
+
+2. Return the current logging level
+
+.. code:: bash
+
+ def get_level(self) -> Level:
+
+3. Add a logger specific MDC
+
+.. code:: bash
+
+ def add_mdc(self, key: str, value: Value):
+
+        Keyword arguments:
+        key -- MDC key
+        value -- MDC value
+
+4. Return logger's MDC value with the given key or None
+
+.. code:: bash
+
+ def get_mdc(self, key: str) -> Value:
+
+5. Remove logger's MDC with the given key
+
+.. code:: bash
+
+ def remove_mdc(self, key: str):
+
+6. Remove all MDCs of the logger instance.
+
+.. code:: bash
+
+ def clean_mdc(self):
+
+
+7. Initialise Logging format: 
+
+This api Initialzes mdclog print format using MDC Dictionary by extracting the environment variables in the calling process for “SYSTEM_NAME”, “HOST_NAME”, “SERVICE_NAME”, “CONTAINER_NAME”, “POD_NAME” & “CONFIG_MAP_NAME” mapped to HostName, ServiceName, ContainerName, Podname and Configuration-file-name of the services respectively.
+
+
+.. code:: bash
+
+ def mdclog_format_init(configmap_monitor=False):
+
+        Keyword arguments:
+        configmap_monitor -- Enables/Disables Dynamic log level change based on configmap
+                          -- Boolean values True/False can be passed as per requirement.
+
+
diff --git a/ricxappframe/logger/__init__.py b/ricxappframe/logger/__init__.py
new file mode 100644
index 0000000..0ec8933
--- /dev/null
+++ b/ricxappframe/logger/__init__.py
@@ -0,0 +1,18 @@
+# Copyright (c) 2019 AT&T Intellectual Property.
+# Copyright (c) 2018-2019 Nokia.
+#
+# 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.
+#
+# This source code is part of the near-RT RIC (RAN Intelligent Controller)
+# platform project (RICP).
+#
diff --git a/ricxappframe/logger/mdclogger.py b/ricxappframe/logger/mdclogger.py
new file mode 100644
index 0000000..526b065
--- /dev/null
+++ b/ricxappframe/logger/mdclogger.py
@@ -0,0 +1,252 @@
+# Copyright (c) 2019 AT&T Intellectual Property.
+# Copyright (c) 2018-2019 Nokia.
+#
+# 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.
+#
+# This source code is part of the near-RT RIC (RAN Intelligent Controller)
+# platform project (RICP).
+#
+
+"""Structured logging library with Mapped Diagnostic Context
+
+Outputs the log entries to standard out in structured format, json currently.
+Severity based filtering.
+Supports Mapped Diagnostic Context (MDC).
+
+Set MDC pairs are automatically added to log entries by the library.
+"""
+from typing import TypeVar
+from enum import IntEnum
+import sys
+import json
+import time
+import os
+import inotify.adapters
+import threading
+
+
+class Level(IntEnum):
+    """Severity levels of the log messages."""
+    DEBUG = 10
+    INFO = 20
+    WARNING = 30
+    ERROR = 40
+
+
+LEVEL_STRINGS = {Level.DEBUG: "DEBUG",
+                 Level.INFO: "INFO",
+                 Level.WARNING: "WARNING",
+                 Level.ERROR: "ERROR"}
+
+
+Value = TypeVar('Value', str, int)
+
+
+class MDCLogger():
+    """Initialize the mdclogging module.
+    Calling of the function is optional. If not called, the process name
+    (sys.argv[0]) is used by default.
+
+    Keyword arguments:
+    name -- name of the component. The name will appear as part of the log
+            entries.
+    """
+    def __init__(self, name: str = sys.argv[0], level: Level = Level.ERROR):
+        """Initialize a Logger instance.
+
+            Keyword arguments:
+            name -- name of the component. The name will appear as part of the
+                    log entries.
+        """
+        self.procname = name
+        self.current_level = level
+        self.mdc = {}
+
+    # Pass configmap_monitor = True to monitor configmap to change logs dynamically using configmap
+
+    def mdclog_format_init(self, configmap_monitor=False):
+
+        self.mdc = {"PID": "", "SYSTEM_NAME": "", "HOST_NAME": "", "SERVICE_NAME": "", "CONTAINER_NAME": "", "POD_NAME": ""}
+        self.get_env_params_values()
+        try:
+            self.filename = os.environ['CONFIG_MAP_NAME']
+            self.dirname = str(self.filename[:self.filename.rindex('/')])
+            self.parse_file()
+
+            if configmap_monitor:
+                self.register_log_change_notify()
+
+        except Exception as e:
+            print("Unable to Add Watch on ConfigMap File", e)
+
+    def _output_log(self, log: str):
+
+        """Output the log, currently to stdout."""
+        print(log)
+
+    def log(self, level: Level, message: str):
+        """Log a message.
+
+        Logs the message with the given severity if it is equal or higher than
+        the current logging level.
+
+        Keyword arguments:
+        level -- severity of the log message
+        message -- log message
+        """
+        if level >= self.current_level:
+            log_entry = {}
+            log_entry["ts"] = int(round(time.time() * 1000))
+            log_entry["crit"] = LEVEL_STRINGS[level]
+            log_entry["id"] = self.procname
+            log_entry["mdc"] = self.mdc
+            log_entry["msg"] = message
+            self._output_log(json.dumps(log_entry))
+
+    def error(self, message: str):
+        """Log an error message. Equals to log(ERROR, msg)."""
+        self.log(Level.ERROR, message)
+
+    def warning(self, message: str):
+        """Log a warning message. Equals to log(WARNING, msg)."""
+        self.log(Level.WARNING, message)
+
+    def info(self, message: str):
+        """Log an info message. Equals to log(INFO, msg)."""
+        self.log(Level.INFO, message)
+
+    def debug(self, message: str):
+        """Log a debug message. Equals to log(DEBUG, msg)."""
+        self.log(Level.DEBUG, message)
+
+    def set_level(self, level: Level):
+        """Set current logging level.
+
+        Keyword arguments:
+        level -- logging level. Log messages with lower severity will be
+                 filtered.
+        """
+        try:
+            self.current_level = Level(level)
+        except ValueError:
+            pass
+
+    def get_level(self) -> Level:
+        """Return the current logging level."""
+        return self.current_level
+
+    def add_mdc(self, key: str, value: Value):
+        """Add a logger specific MDC.
+
+        If an MDC with the given key exists, it is replaced with the new one.
+        An MDC can be removed with remove_mdc() or clean_mdc().
+
+        Keyword arguments:
+        key -- MDC key
+        value -- MDC value
+        """
+        self.mdc[key] = value
+
+    def get_env_params_values(self):
+
+        try:
+            self.mdc['SYSTEM_NAME'] = os.environ['SYSTEM_NAME']
+        except Exception:
+            self.mdc['SYSTEM_NAME'] = ""
+
+        try:
+            self.mdc['HOST_NAME'] = os.environ['HOST_NAME']
+        except Exception:
+            self.mdc['HOST_NAME'] = ""
+
+        try:
+            self.mdc['SERVICE_NAME'] = os.environ['SERVICE_NAME']
+        except Exception:
+            self.mdc['SERVICE_NAME'] = ""
+
+        try:
+            self.mdc['CONTAINER_NAME'] = os.environ['CONTAINER_NAME']
+        except Exception:
+            self.mdc['CONTAINER_NAME'] = ""
+
+        try:
+            self.mdc['POD_NAME'] = os.environ['POD_NAME']
+        except Exception:
+            self.mdc['POD_NAME'] = ""
+        try:
+            self.mdc['PID'] = os.getpid()
+        except Exception:
+            self.mdc['PID'] = ""
+
+    def update_mdc_log_level_severity(self, level):
+
+        severity_level = Level.ERROR
+
+        if(level == ""):
+            print("Invalid Log Level defined in ConfigMap")
+        elif((level.upper() == "ERROR") or (level.upper() == "ERR")):
+            severity_level = Level.ERROR
+        elif((level.upper() == "WARNING") or (level.upper() == "WARN")):
+            severity_level = Level.WARNING
+        elif(level.upper() == "INFO"):
+            severity_level = Level.INFO
+        elif(level.upper() == "DEBUG"):
+            severity_level = Level.DEBUG
+
+        self.set_level(severity_level)
+
+    def parse_file(self):
+        src = open(self.filename, 'r')
+        level = ""
+        for line in src:
+            if 'log-level:' in line:
+                level_tmp = str(line.split(':')[-1]).strip()
+                level = level_tmp
+                break
+        src.close()
+        self.update_mdc_log_level_severity(level)
+
+    def monitor_loglevel_change_handler(self):
+        i = inotify.adapters.Inotify()
+        i.add_watch(self.dirname)
+        for event in i.event_gen():
+            if (event is not None) and ('IN_MODIFY' in str(event[1]) or 'IN_DELETE' in str(event[1])):
+                self.parse_file()
+
+    def register_log_change_notify(self):
+        t1 = threading.Thread(target=self.monitor_loglevel_change_handler)
+        t1.daemon = True
+        try:
+            t1.start()
+        except (KeyboardInterrupt, SystemExit):
+            # TODO: add cleanup handler
+            # cleanup_stop_thread()
+            sys.exit()
+
+    def get_mdc(self, key: str) -> Value:
+        """Return logger's MDC value with the given key or None."""
+        try:
+            return self.mdc[key]
+        except KeyError:
+            return None
+
+    def remove_mdc(self, key: str):
+        """Remove logger's MDC with the given key."""
+        try:
+            del self.mdc[key]
+        except KeyError:
+            pass
+
+    def clean_mdc(self):
+        """Remove all MDCs of the logger instance."""
+        self.mdc = {}
diff --git a/setup.py b/setup.py
index 82899fc..1675c4e 100644
--- a/setup.py
+++ b/setup.py
@@ -37,7 +37,7 @@
     author="O-RAN Software Community",
     description="Xapp and RMR framework for Python",
     url="https://gerrit.o-ran-sc.org/r/admin/repos/ric-plt/xapp-frame-py",
-    install_requires=["inotify_simple", "msgpack", "mdclogpy", "ricsdl>=3.0.0,<4.0.0", "requests", "protobuf"],
+    install_requires=["inotify_simple", "msgpack", "mdclogpy", "ricsdl>=3.0.0,<4.0.0", "requests", "protobuf", "inotify"],
     classifiers=[
         "Development Status :: 4 - Beta",
         "Intended Audience :: Telecommunications Industry",
diff --git a/tests/mdclogtestutils.py b/tests/mdclogtestutils.py
new file mode 100644
index 0000000..c4283e8
--- /dev/null
+++ b/tests/mdclogtestutils.py
@@ -0,0 +1,35 @@
+# Copyright (c) 2019 AT&T Intellectual Property.
+# Copyright (c) 2018-2019 Nokia.
+#
+# 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.
+#
+# This source code is part of the near-RT RIC (RAN Intelligent Controller)
+# platform project (RICP).
+#
+"""Helper functions for mdclogpy unit tests."""
+
+import json
+
+
+class TestMdcLogUtils():
+    """Helper functions for unit tests."""
+
+    @staticmethod
+    def get_logs(call_args_list):
+        """Return the logs as a list of strings from the call_args_list."""
+        return [x[0][0] for x in call_args_list]
+
+    @staticmethod
+    def get_logs_as_json(call_args_list):
+        """Return the logs as a list of json objects from the call_args_list."""
+        return list(map(json.loads, TestMdcLogUtils.get_logs(call_args_list)))
diff --git a/tests/test_Logger.py b/tests/test_Logger.py
new file mode 100644
index 0000000..8a1a278
--- /dev/null
+++ b/tests/test_Logger.py
@@ -0,0 +1,223 @@
+# Copyright (c) 2019 AT&T Intellectual Property.
+# Copyright (c) 2018-2019 Nokia.
+#
+# 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.
+#
+# This source code is part of the near-RT RIC (RAN Intelligent Controller)
+# platform project (RICP).
+#
+"""Unit tests for Logger.py"""
+import unittest
+from unittest.mock import patch
+
+from ricxappframe.logger.mdclogger import Level, MDCLogger
+from .mdclogtestutils import TestMdcLogUtils
+
+
+class TestMdcLog(unittest.TestCase):
+    """Unit tests for mdclog.py"""
+
+    def setUp(self):
+        self.logger = MDCLogger()
+
+    def tearDown(self):
+        pass
+
+    def test_that_get_level_returns_the_current_log_level(self):
+
+        # default level is ERROR
+        self.assertEqual(self.logger.get_level(), Level.ERROR)
+        self.logger.set_level(Level.INFO)
+        self.assertEqual(self.logger.get_level(), Level.INFO)
+        self.logger.set_level(Level.WARNING)
+        self.assertEqual(self.logger.get_level(), Level.WARNING)
+        self.logger.set_level(Level.ERROR)
+        self.assertEqual(self.logger.get_level(), Level.ERROR)
+        self.logger.set_level(Level.DEBUG)
+        self.assertEqual(self.logger.get_level(), Level.DEBUG)
+
+    def test_that_set_level_does_not_accept_incorrect_level(self):
+
+        self.logger.set_level(Level.INFO)
+        self.logger.set_level(55)
+        self.assertEqual(self.logger.get_level(), Level.INFO)
+
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_that_logs_with_lower_than_current_level_(self, output_mock):
+
+        self.logger.set_level(Level.WARNING)
+        self.logger.log(Level.DEBUG, "DEBUG")
+        self.logger.log(Level.INFO, "INFO")
+        self.logger.log(Level.WARNING, "WARNING")
+        self.logger.log(Level.ERROR, "ERROR")
+
+        self.assertEqual(2, output_mock.call_count)
+        logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list)
+        self.assertEqual(logs[0]["msg"], "WARNING")
+        self.assertEqual(logs[1]["msg"], "ERROR")
+
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_that_logs_with_lower_than_current_level_are_not_logged(self, output_mock):
+
+        self.logger.set_level(Level.WARNING)
+        self.logger.log(Level.DEBUG, "DEBUG")
+        self.logger.log(Level.INFO, "INFO")
+        self.logger.log(Level.WARNING, "WARNING")
+        self.logger.log(Level.ERROR, "ERROR")
+
+        self.assertEqual(2, output_mock.call_count)
+        logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list)
+        self.assertEqual(logs[0]["msg"], "WARNING")
+        self.assertEqual(logs[1]["msg"], "ERROR")
+
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_that_log_contains_correct_criticality(self, output_mock):
+
+        self.logger.set_level(Level.DEBUG)
+
+        self.logger.log(Level.DEBUG, "debug test log")
+        self.logger.log(Level.INFO, "info test log")
+        self.logger.log(Level.WARNING, "warning test log")
+        self.logger.log(Level.ERROR, "error test log")
+
+        self.logger.debug("another debug test log")
+        self.logger.info("another info test log")
+        self.logger.warning("another warning test log")
+        self.logger.error("another error test log")
+
+        self.assertEqual(8, output_mock.call_count)
+        logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list)
+        self.assertEqual(logs[0]["crit"], "DEBUG")
+        self.assertEqual(logs[1]["crit"], "INFO")
+        self.assertEqual(logs[2]["crit"], "WARNING")
+        self.assertEqual(logs[3]["crit"], "ERROR")
+        self.assertEqual(logs[4]["crit"], "DEBUG")
+        self.assertEqual(logs[5]["crit"], "INFO")
+        self.assertEqual(logs[6]["crit"], "WARNING")
+        self.assertEqual(logs[7]["crit"], "ERROR")
+
+    @patch('time.time')
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_that_log_contains_correct_timestamp(self, output_mock, mock_time):
+
+        mock_time.return_value = 1554806251.4388545
+        self.logger.error("timestamp test")
+
+        logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list)
+        self.assertEqual(logs[0]["ts"], 1554806251439)
+
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_that_log_contains_correct_message(self, output_mock):
+
+        self.logger.error("message test")
+        logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list)
+        print(logs)
+        self.assertEqual(logs[0]["msg"], "message test")
+
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_that_log_message_is_escaped_to_valid_json_string(self, output_mock):
+
+        self.logger.set_level(Level.DEBUG)
+
+        self.logger.info(r'\ and "')
+
+        logs = TestMdcLogUtils.get_logs(output_mock.call_args_list)
+        self.assertTrue(r'\\ and \"' in logs[0])
+        logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list)
+        self.assertEqual(logs[0]["msg"], r'\ and "')
+
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_that_empty_mdc_is_logged_correctly(self, output_mock):
+        self.logger.mdclog_format_init(configmap_monitor=True)
+        self.logger.error("empty mdc test")
+        self.logger.error(output_mock.call_args_list)
+        logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list)
+        self.assertEqual(logs[0]["msg"], 'empty mdc test')
+
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_that_config_map_is_monitored_correctly(self, output_mock):
+        src = open("//tmp//log", "w")
+        src.write("log-level: debug\n")
+        src.close()
+        self.logger.filename = "/tmp/log"
+        self.logger.dirname = "/tmp/"
+        self.logger.mdc = {"PID": "", "SYSTEM_NAME": "", "HOST_NAME": "", "SERVICE_NAME": "", "CONTAINER_NAME": "", "POD_NAME": ""}
+        self.logger.get_env_params_values()
+        self.logger.parse_file()
+        self.logger.error("Hello")
+        self.assertEqual(self.logger.get_level(), Level.DEBUG)
+
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_that_mdc_values_are_logged_correctly(self, output_mock):
+
+        self.logger.add_mdc("key1", "value1")
+        self.logger.add_mdc("key2", "value2")
+        self.logger.error("mdc test")
+
+        logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list)
+        self.assertEqual(logs[0]["mdc"]["key1"], "value1")
+        self.assertEqual(logs[0]["mdc"]["key2"], "value2")
+
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_that_mdc_pid_logged_correctly(self, output_mock):
+        self.logger.mdclog_format_init(configmap_monitor=True)
+        self.logger.error("mdc test")
+        logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list)
+        self.assertTrue(logs[0]["mdc"]["PID"])
+
+    def test_that_mdc_values_can_be_added_and_removed(self):
+
+        self.logger.add_mdc("key1", "value1")
+        self.logger.add_mdc("key2", "value2")
+        self.assertEqual(self.logger.get_mdc("key2"), "value2")
+        self.assertEqual(self.logger.get_mdc("key1"), "value1")
+        self.assertEqual(self.logger.get_mdc("non_existent"), None)
+        self.logger.remove_mdc("key1")
+        self.assertEqual(self.logger.get_mdc("key1"), None)
+        self.logger.remove_mdc("non_existent")
+        self.logger.clean_mdc()
+        self.assertEqual(self.logger.get_mdc("key2"), None)
+
+    @patch('ricxappframe.logger.mdclogger.MDCLogger._output_log')
+    def test_multiple_logger_instances(self, output_mock):
+
+        logger1 = MDCLogger("logger1")
+        logger2 = MDCLogger("logger2")
+        logger1.add_mdc("logger1_key1", "logger1_value1")
+        logger1.add_mdc("logger1_key2", "logger1_value2")
+        logger2.add_mdc("logger2_key1", "logger2_value1")
+        logger2.add_mdc("logger2_key2", "logger2_value2")
+
+        logger1.error("error msg")
+        logger2.error("warning msg")
+
+        logs = TestMdcLogUtils.get_logs_as_json(output_mock.call_args_list)
+        self.assertEqual(2, output_mock.call_count)
+
+        self.assertEqual(logs[0]["id"], "logger1")
+        self.assertEqual(logs[0]["crit"], "ERROR")
+        self.assertEqual(logs[0]["msg"], "error msg")
+        self.assertEqual(logs[0]["mdc"]["logger1_key1"], "logger1_value1")
+        self.assertEqual(logs[0]["mdc"]["logger1_key2"], "logger1_value2")
+        self.assertEqual(len(logs[0]["mdc"]), 2)
+
+        self.assertEqual(logs[1]["id"], "logger2")
+        self.assertEqual(logs[1]["crit"], "ERROR")
+        self.assertEqual(logs[1]["msg"], "warning msg")
+        self.assertEqual(logs[1]["mdc"]["logger2_key1"], "logger2_value1")
+        self.assertEqual(logs[1]["mdc"]["logger2_key2"], "logger2_value2")
+        self.assertEqual(len(logs[1]["mdc"]), 2)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tox.ini b/tox.ini
index 0cdf5e6..0180e50 100644
--- a/tox.ini
+++ b/tox.ini
@@ -67,6 +67,7 @@
        msgpack
        ricsdl
        protobuf
+       inotify
 commands =
     sphinx-build -W -b html -n -d {envtmpdir}/doctrees ./docs/ {toxinidir}/docs/_build/html
     echo "Generated docs available in {toxinidir}/docs/_build/html"