diff --git a/docs/api/cli.rst b/docs/api/cli.rst index 4352866a..6a3dad2a 100644 --- a/docs/api/cli.rst +++ b/docs/api/cli.rst @@ -17,7 +17,7 @@ Decorators ---------- .. automodule:: metricq.cli.decorator - :members: metricq_command, metricq_metric_option, metricq_server_option, metricq_token_option, + :members: metricq_command, metricq_metric_option, metricq_server_option, metricq_syslog_option, metricq_token_option, Parameter diff --git a/metricq/cli/__init__.py b/metricq/cli/__init__.py index d8a799c8..c23a59dc 100644 --- a/metricq/cli/__init__.py +++ b/metricq/cli/__init__.py @@ -2,6 +2,7 @@ metricq_command, metricq_metric_option, metricq_server_option, + metricq_syslog_option, metricq_token_option, ) from .params import ( @@ -23,5 +24,6 @@ "metricq_command", "metricq_metric_option", "metricq_server_option", + "metricq_syslog_option", "metricq_token_option", ] diff --git a/metricq/cli/decorator.py b/metricq/cli/decorator.py index 6e15557b..6401977f 100644 --- a/metricq/cli/decorator.py +++ b/metricq/cli/decorator.py @@ -1,12 +1,15 @@ import logging +from typing import Any, Callable, Optional, cast import click import click_log # type: ignore -from click import option +from click import Context, option from dotenv import find_dotenv, load_dotenv from .. import get_logger from .params import MetricParam, TemplateStringParam +from .syslog import SyslogFormatter, get_syslog_handler +from .types import FC # We do not interpolate (i.e. replace ${VAR} with corresponding environment variables). # That is because we want to be able to interpolate ourselves for metrics and tokens @@ -21,6 +24,41 @@ FC = TypeVar("FC", bound=Union[Callable[..., Any], click.Command]) +def metricq_syslog_option() -> Callable[[FC], FC]: + """ + Exposes the -\\-syslog option as a click param. + + The program will try read the 'token' from the click params. + if the token is not set, the default value of 'metricq.program' will be used. + That's why the @metricq_syslog_option should be the 2nd decorator in the chain. + + It is recommended to use the :py:func:`~metricq.cli.decorator.metricq_command` decorator instead of using this + function directly. + """ + + def enable_syslog(ctx: Context, param: Any | None, value: Optional[str]) -> None: + if value is not None: + logger = get_logger() + if value == "": + value = None + + program_name = ctx.params.get("token", "metricq.program") + + handler = get_syslog_handler(value) + handler.setFormatter(SyslogFormatter(name=program_name)) + logger.addHandler(handler) + + return option( + "--syslog", + help="Enable syslog logging by specifying the a Unix socket or host:port for the logger. If --syslog is set " + "but no value is specified, the default of localhost:514 will be used.", + callback=enable_syslog, + expose_value=False, + is_flag=False, + flag_value="", + ) + + def metricq_server_option() -> Callable[[FC], FC]: """ Allows the User to provide a -\\-server option. This option has no input validation and therefore can be any string. @@ -143,10 +181,20 @@ def metricq_command( - -\\-token: The Token is used to identify each program on the metricq network. for example: sink-py-dummy - The token param can be set using the environment variable METRICQ_TOKEN or adding the --token {value} option to the cli command + The token param can be set using the environment variable METRICQ_TOKEN or adding the --token {value} option + to the cli command + + - -\\-syslog: + The Syslog param is used to enable syslog. It can be used with or without parameter. + + If used without parameter (for example: ``metricq-check --syslog`` ) the Syslog will default to localhost:514. + + You can also specify a Unix socket (for example: /dev/log) or a custom host (for example: example.com:5114) + by adding the value to the syslog flag (for example: ``metricq-check --syslog example.com:5114``) + Full example: - ``metricq-check --server amqp://localhost/ --token sink-py-dummy`` + ``metricq-check --server amqp://localhost/ --token sink-py-dummy --syslog`` **Example**:: @@ -184,8 +232,10 @@ def decorator(func: FC) -> click.Command: log_decorator( metricq_token_option(default_token)( metricq_server_option()( - click.command(context_settings=context_settings, epilog=epilog)( - func + metricq_syslog_option()( + click.command( + context_settings=context_settings, epilog=epilog + )(func) ) ) ) diff --git a/metricq/cli/syslog.py b/metricq/cli/syslog.py new file mode 100644 index 00000000..83fa2b57 --- /dev/null +++ b/metricq/cli/syslog.py @@ -0,0 +1,32 @@ +import logging +import socket +import time +from logging.handlers import SysLogHandler + + +class SyslogFormatter(logging.Formatter): + def __init__(self, *args, name: str = "metricq", **kwargs): # type: ignore + super().__init__(*args, **kwargs) + self.program = name + + def format(self, record: logging.LogRecord) -> str: + timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.created)) + hostname = socket.gethostname() + pid = record.process + program = self.program + # Custom Formatter based on rfc3164 + # Format the header as " TIMESTAMP HOSTNAME PROGRAM[PID]: MESSAGE" + # is already being set by the SysLogHandler, we only need to add the rest + syslog_header = f"{timestamp} {hostname} {program}[{pid}]: " + message = super().format(record) + return syslog_header + message + + +def get_syslog_handler(address: str | None) -> SysLogHandler: + if address is None: + return SysLogHandler() + elif ":" in address: + ip, port = address.split(":") + return SysLogHandler(address=(ip, int(port))) + else: + return SysLogHandler(address=address)