diff --git a/logging/config.yml b/logging/config.yml new file mode 100644 index 0000000..97f23c1 --- /dev/null +++ b/logging/config.yml @@ -0,0 +1,18 @@ +version: 1 +formatters: + jsonFormatter: + (): jsonLog.JsonFormatter +handlers: + stdout: + class: logging.StreamHandler + level: DEBUG + formatter: jsonFormatter + stream: ext://sys.stdout +loggers: + jsonLogger: + level: DEBUG + handlers: [stdout] + propagate: no +root: + level: DEBUG + handlers: [stdout] \ No newline at end of file diff --git a/logging/jsonLog.py b/logging/jsonLog.py new file mode 100644 index 0000000..86f9f7c --- /dev/null +++ b/logging/jsonLog.py @@ -0,0 +1,26 @@ +import json, logging +import socket +import uuid +from datetime import datetime + +hostname = socket.gethostname() + +class JsonFormatter(logging.Formatter): + + def format(self, record): + jsonLog = { + "timestamp": datetime.fromtimestamp(record.created).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', # Only 3 Milliseconds + # "time": datetime.fromtimestamp(record.created).isoformat(), + "level": record.levelname, + "logId": str(uuid.uuid4()), + "service": "postdock", + "hostname": hostname, + "pid": record.process, + "file": record.filename, + "function": record.funcName, + "lineNumber": record.lineno, + "message": record.msg, + } + + return json.dumps(jsonLog) + \ No newline at end of file diff --git a/socketdock/__main__.py b/socketdock/__main__.py index 2292670..901f581 100644 --- a/socketdock/__main__.py +++ b/socketdock/__main__.py @@ -5,6 +5,20 @@ from sanic import Sanic from .api import api, backend_var +from .loadlogger import LoggingConfigurator + + +def configure_logging(args): + """Perform common app configuration.""" + # Set up logging + log_config = args.log_config + log_level = args.log_level + log_file = args.log_file + LoggingConfigurator.configure( + log_config_path=log_config, + log_level=log_level, + log_file=log_file, + ) def config() -> argparse.Namespace: @@ -21,9 +35,27 @@ def config() -> argparse.Namespace: parser.add_argument("--connect-uri") parser.add_argument( "--log-level", + dest="log_level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], ) + parser.add_argument( + "--log-file", + dest="log_file", + default=None, + help=( + "--log-file enables writing of logs to file, if a value is " + "provided then it uses that as log file location, otherwise " + "the default location in log config file is used." + ), + ) + parser.add_argument( + "--log-config", + dest="log_config", + default=None, + help="Specifies a custom logging configuration file", + ) + return parser.parse_args() @@ -46,7 +78,7 @@ def main(): backend_var.set(backend) - logging.basicConfig(level=args.log_level) + configure_logging(args) app = Sanic("SocketDock") app.config.WEBSOCKET_MAX_SIZE = 2**22 diff --git a/socketdock/loadlogger.py b/socketdock/loadlogger.py new file mode 100644 index 0000000..115271d --- /dev/null +++ b/socketdock/loadlogger.py @@ -0,0 +1,115 @@ +"""Logging Configurator for aca-py agent.""" + +import io +import logging +from importlib import resources +from logging.config import ( + dictConfigClass, +) +from typing import Optional + +import yaml + +LOGGER = logging.getLogger(__name__) + +def load_resource(path: str, encoding: Optional[str] = None): + """Open a resource file located in a python package or the local filesystem. + + Args: + path (str): The resource path in the form of `dir/file` or `package:dir/file` + encoding (str, optional): The encoding to use when reading the resource file. + Defaults to None. + + Returns: + file-like object: A file-like object representing the resource + """ + components = path.rsplit(":", 1) + try: + if len(components) == 1: + # Local filesystem resource + return open(components[0], encoding=encoding) + else: + # Package resource + package, resource = components + bstream = resources.files(package).joinpath(resource).open("rb") + if encoding: + return io.TextIOWrapper(bstream, encoding=encoding) + return bstream + except IOError: + LOGGER.warning("Resource not found: %s", path) + return None + + +def dictConfig(config, new_file_path=None): + """Custom dictConfig, https://github.com/python/cpython/blob/main/Lib/logging/config.py.""" + if new_file_path: + config["handlers"]["rotating_file"]["filename"] = f"{new_file_path}" + dictConfigClass(config).configure() + + +class LoggingConfigurator: + """Utility class used to configure logging and print an informative start banner.""" + + @classmethod + def configure( + cls, + log_config_path: Optional[str] = None, + log_level: Optional[str] = None, + log_file: Optional[str] = None, + ): + """Configure logger. + + :param logging_config_path: str: (Default value = None) Optional path to + custom logging config + + :param log_level: str: (Default value = None) + + :param log_file: str: (Default value = None) Optional file name to write logs to + """ + + write_to_log_file = log_file is not None or log_file == "" + + # This is a check that requires a log file path to be provided if + # --log-file is specified on startup and a config file is not. + if not log_config_path and write_to_log_file and not log_file: + raise ValueError( + "log_file (--log-file) must be provided in single-tenant mode " + "using the default config since a log file path is not set." + ) + + cls._configure_logging( + log_config_path=log_config_path, + log_level=log_level, + log_file=log_file, + ) + + @classmethod + def _configure_logging(cls, log_config_path, log_level, log_file): + # Setup log config and log file if provided + cls._setup_log_config_file(log_config_path, log_file) + + # Set custom file handler + if log_file: + logging.root.handlers.append(logging.FileHandler(log_file, encoding="utf-8")) + + # Set custom log level + if log_level: + logging.root.setLevel(log_level.upper()) + + @classmethod + def _setup_log_config_file(cls, log_config_path, log_file): + log_config, is_dict_config = cls._load_log_config(log_config_path) + + # Setup config + if not log_config: + logging.basicConfig(level=logging.WARNING) + logging.root.warning(f"Logging config file not found: {log_config_path}") + elif is_dict_config: + dictConfig(log_config, new_file_path=log_file or None) + + @classmethod + def _load_log_config(cls, log_config_path): + if ".yml" in log_config_path or ".yaml" in log_config_path: + with open(log_config_path, "r") as stream: + return yaml.safe_load(stream), True + return load_resource(log_config_path, "utf-8"), False