From 1f928c4a2fa6ec4d39cc09c8bcefa1d4e7f469e9 Mon Sep 17 00:00:00 2001 From: acidvegas Date: Mon, 21 Oct 2024 06:34:40 -0400 Subject: [PATCH] Initial commit --- .gitignore | 2 + .screens/preview.png | Bin 0 -> 25277 bytes README.md | 262 ++++++++++++++++++++++++++++ apv.py | 396 +++++++++++++++++++++++++++++++++++++++++++ setup.py | 24 +++ unittest.py | 95 +++++++++++ 6 files changed, 779 insertions(+) create mode 100644 .gitignore create mode 100644 .screens/preview.png create mode 100644 README.md create mode 100644 apv.py create mode 100644 setup.py create mode 100644 unittest.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f250cab --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +logs/ diff --git a/.screens/preview.png b/.screens/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..bc30b78a2f0827bcbcf091db1232fadc19b42172 GIT binary patch literal 25277 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%0AKxivdk01>d_En))5 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 Flexible & powerful logging solution for Python applications + +![](./.screens/preview.png) + +## Table of Contents +- [Introduction](#introduction) +- [Requirements](#requirements) +- [Features](#features) +- [Installation](#installation) +- [Configuration Options](#configuration-options) +- [Usage](#usage) + - [Basic Console Logging](#basic-console-logging) + - [Console Logging with Details](#console-logging-with-details) + - [File Logging with Rotation](#file-logging-with-rotation) + - [File Logging with Compression and JSON Format](#file-logging-with-compression-and-json-format) + - [Graylog Integration](#graylog-integration) + - [AWS CloudWatch Integration](#aws-cloudwatch-integration) + - [Mixing it all together](#mixing-it-all-together) +- [Testing](#testing) + +## Introduction +APV emerged from a simple observation: despite the abundance of logging solutions, there's a glaring lack of standardization in application logging. As a developer deeply entrenched in Elasticsearch, AWS, and Graylog ecosystems, I found myself repeatedly grappling with inconsistent log formats and cumbersome integrations. APV is my response to this challenge – a logging library that doesn't aim to revolutionize the field, but rather to streamline it. + +This project is rooted in pragmatism. It offers a clean, efficient approach to generating logs that are both human-readable and machine-parseable. APV isn't about reinventing logging; it's about refining it. It provides a unified interface that plays well with various logging backends, from local files to cloud services, without sacrificing flexibility or performance. + +While there's no shortage of logging libraries out there, APV represents a distillation of best practices I've encountered and challenges I've overcome. It's designed for developers who appreciate clean, consistent logs and seamless integration with modern logging infrastructures. If you're tired of wrestling with logging inconsistencies in production environments, APV might just be the solution you didn't know you were looking for. + +## Requirements +- [Python 3.10+](https://www.python.org/) + - [boto3](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/quickstart.html) *(Optional: for CloudWatch logging)* + - [ecs-logging](https://github.com/aws-observability/aws-ecs-logging) *(Optional: for ECS logging)* + +## Features +- **Console Logging with Colors**: Enhanced readability with colored log messages in the console. +- **File Logging**: Write logs to files with support for log rotation based on size and number of backups. +- **Log Compression**: Automatically compress old log files using gzip to save disk space. +- **JSON Logging**: Output logs in JSON format for better structure and integration with log management systems. +- **ECS Logging**: Output logs in ECS format for better integration with [Elasticsearch](https://www.elastic.co/elasticsearch/) +- **Detailed Log Messages**: Option to include module name, function name, and line number in log messages. +- **Graylog Integration**: Send logs to a [Graylog](https://www.graylog.org/) server using GELF over UDP. +- **AWS CloudWatch Integration**: Send logs to [AWS CloudWatch Logs](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/WhatIsCloudWatchLogs.html). +- **Customizable Logging Levels**: Set the logging level to control verbosity. + +## Installation +To install APV, you can use the provided `setup.py` script: + +1. **Clone the repository or download the source code.** + + ```bash + git clone https://github.com/acidvegas/apv.git + ``` + +2. **Navigate to the project directory.** + + ```bash + cd apv + ``` + +3. **Install the package using `setup.py`.** + + ```bash + python setup.py install + ``` + + - **With CloudWatch support:** + + If you plan to use AWS CloudWatch logging, install with the `cloudwatch` extra: + + ```bash + pip install .[cloudwatch] + ``` + + - **Alternatively, install `boto3` separately:** + + ```bash + pip install boto3 + ``` + + - **With ECS support:** + + If you plan to use ECS logging, install with the `ecs` extra: + + ```bash + pip install .[ecs] + ``` + + - **Alternatively, install `ecs-logging` separately:** + + ```bash + pip install ecs-logging + ``` + +## Configuration Options + +The `setup_logging` function accepts the following keyword arguments to customize logging behavior: + +| Name | Default | Description | +|--------------------------|--------------------------|--------------------------------------------------------------------------------------| +| `level` | `INFO` | The logging level. *(`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`)* | +| `date_format` | `'%Y-%m-%d %H:%M:%S'` | The date format for log messages. | +| `log_to_disk` | `False` | Whether to log to disk. | +| `max_log_size` | `10*1024*1024` *(10 MB)* | The maximum size of log files before rotation *(in bytes)*. | +| `max_backups` | `7` | The maximum number of backup log files to keep. | +| `log_file_name` | `'app'` | The base name of the log file. | +| `json_log` | `False` | Whether to log in JSON format. | +| `ecs_log` | `False` | Whether to log in ECS format. | +| `show_details` | `False` | Whether to include module name, function name, & line number in log messages. | +| `compress_backups` | `False` | Whether to compress old log files using gzip. | +| `enable_graylog` | `False` | Whether to enable logging to a Graylog server. | +| `graylog_host` | `None` | The Graylog server host. *(Required if `enable_graylog` is `True`)* | +| `graylog_port` | `None` | The Graylog server port. *(Required if `enable_graylog` is `True`)* | +| `enable_cloudwatch` | `False` | Whether to enable logging to AWS CloudWatch Logs. | +| `cloudwatch_group_name` | `None` | The name of the CloudWatch log group. *(Required if `enable_cloudwatch` is `True`)* | +| `cloudwatch_stream_name` | `None` | The name of the CloudWatch log stream. *(Required if `enable_cloudwatch` is `True`)* | + +## Usage + +### Basic Console Logging + +```python +import logging +import apv + +# Set up basic console logging +apv.setup_logging(level='INFO') + +logging.info('This is an info message.') +logging.error('This is an error message.') +``` + +### Console Logging with Details + +```python +import logging +import apv + +# Set up console logging with detailed information +apv.setup_logging(level='DEBUG', show_details=True) + +logging.debug('Debug message with details.') +``` + +### File Logging with Rotation + +```python +import logging +import apv + +# Set up file logging with log rotation +apv.setup_logging( + level='INFO', + log_to_disk=True, + max_log_size=10*1024*1024, # 10 MB + max_backups=5, + log_file_name='application_log' +) + +logging.info('This message will be logged to a file.') +``` + +### File Logging with Compression and JSON Format + +```python +import logging +import apv + +# Set up file logging with compression and JSON format +apv.setup_logging( + level='DEBUG', + log_to_disk=True, + max_log_size=5*1024*1024, # 5 MB + max_backups=7, + log_file_name='json_log', + json_log=True, + compress_backups=True +) + +logging.debug('This is a debug message in JSON format.') +``` + +### Graylog Integration + +```python +import logging +import apv + +# Set up logging to Graylog server +apv.setup_logging( + level='INFO', + enable_graylog=True, + graylog_host='graylog.example.com', + graylog_port=12201 +) + +logging.info('This message will be sent to Graylog.') +``` + +### AWS CloudWatch Integration + +```python +import logging +import apv + +# Set up logging to AWS CloudWatch Logs +apv.setup_logging( + level='INFO', + enable_cloudwatch=True, + cloudwatch_group_name='my_log_group', + cloudwatch_stream_name='my_log_stream' +) + +logging.info('This message will be sent to AWS CloudWatch.') +``` + +### ECS Logging + +```python +import logging +import apv + +# Set up logging to AWS CloudWatch Logs +apv.setup_logging( + level='INFO', + ecs_log=True +) +``` + +### Mixing it all together + +```python +import logging +import apv + +# Set up logging to all handlers +apv.setup_logging( + level='DEBUG', + log_to_disk=True, + max_log_size=10*1024*1024, + max_backups=7, + log_file_name='app', + json_log=True, + compress_backups=True, + enable_graylog=True, + graylog_host='graylog.example.com', + graylog_port=12201, + enable_cloudwatch=True, + cloudwatch_group_name='my_log_group', + cloudwatch_stream_name='my_log_stream', + show_details=True +) +``` + +## Testing + +To run the test suite, use the following command: + +```bash +python unittest.py +``` + +The test suite will run all the tests and provide output for each test. \ No newline at end of file diff --git a/apv.py b/apv.py new file mode 100644 index 0000000..6fbb9b7 --- /dev/null +++ b/apv.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +# Advanced Python Logging - Developed by acidvegas in Python (https://git.acid.vegas/apv) +# apv.py + +import gzip +import json +import logging +import logging.handlers +import os +import socket + + +class LogColors: + '''ANSI color codes for log messages.''' + + RESET = '\033[0m' + DATE = '\033[90m' # Dark Grey + DEBUG = '\033[96m' # Cyan + INFO = '\033[92m' # Green + WARNING = '\033[93m' # Yellow + ERROR = '\033[91m' # Red + CRITICAL = '\033[97m\033[41m' # White on Red + FATAL = '\033[97m\033[41m' # Same as CRITICAL + NOTSET = '\033[97m' # White text + SEPARATOR = '\033[90m' # Dark Grey + MODULE = '\033[95m' # Pink + FUNCTION = '\033[94m' # Blue + LINE = '\033[33m' # Orange + + +class GZipRotatingFileHandler(logging.handlers.RotatingFileHandler): + '''RotatingFileHandler that compresses old log files using gzip.''' + + def doRollover(self): + '''Compress old log files using gzip.''' + + super().doRollover() + if self.backupCount > 0: + for i in range(self.backupCount, 0, -1): + sfn = f'{self.baseFilename}.{i}' + if os.path.exists(sfn): + with open(sfn, 'rb') as f_in: + with gzip.open(f'{sfn}.gz', 'wb') as f_out: + f_out.writelines(f_in) + os.remove(sfn) + + +class LoggerSetup: + def __init__(self, level='INFO', date_format='%Y-%m-%d %H:%M:%S', + log_to_disk=False, max_log_size=10*1024*1024, + max_backups=7, log_file_name='app', json_log=False, + ecs_log=False, show_details=False, compress_backups=False, + enable_graylog=False, graylog_host=None, graylog_port=None, + enable_cloudwatch=False, cloudwatch_group_name=None, cloudwatch_stream_name=None): + ''' + Initialize the LoggerSetup with provided parameters. + + :param level: The logging level (e.g., 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'). + :param date_format: The date format for log messages. + :param log_to_disk: Whether to log to disk. + :param max_log_size: The maximum size of log files before rotation. + :param max_backups: The maximum number of backup log files to keep. + :param log_file_name: The base name of the log file. + :param json_log: Whether to log in JSON format. + :param show_details: Whether to show detailed log messages. + :param compress_backups: Whether to compress old log files using gzip. + :param enable_graylog: Whether to enable Graylog logging. + :param graylog_host: The Graylog host. + :param graylog_port: The Graylog port. + :param enable_cloudwatch: Whether to enable CloudWatch logging. + :param cloudwatch_group_name: The CloudWatch log group name. + :param cloudwatch_stream_name: The CloudWatch log stream name. + ''' + + self.level = level + self.date_format = date_format + self.log_to_disk = log_to_disk + self.max_log_size = max_log_size + self.max_backups = max_backups + self.log_file_name = log_file_name + self.json_log = json_log + self.ecs_log = ecs_log + self.show_details = show_details + self.compress_backups = compress_backups + self.enable_graylog = enable_graylog + self.graylog_host = graylog_host + self.graylog_port = graylog_port + self.enable_cloudwatch = enable_cloudwatch + self.cloudwatch_group_name = cloudwatch_group_name + self.cloudwatch_stream_name = cloudwatch_stream_name + + + def setup(self): + '''Set up logging with various handlers and options.''' + + # Clear existing handlers + logging.getLogger().handlers.clear() + logging.getLogger().setLevel(logging.DEBUG) # Capture all logs at the root level + + # Convert the level string to a logging level object + level_num = getattr(logging, self.level.upper(), logging.INFO) + + self.setup_console_handler(level_num) + + if self.log_to_disk: + self.setup_file_handler(level_num) + + if self.enable_graylog: + self.setup_graylog_handler(level_num) + + if self.enable_cloudwatch: + self.setup_cloudwatch_handler(level_num) + + + def setup_console_handler(self, level_num: int): + ''' + Set up the console handler with colored output. + + :param level_num: The logging level number. + ''' + + # Define the colored formatter + class ColoredFormatter(logging.Formatter): + def __init__(self, datefmt=None, show_details=False): + super().__init__(datefmt=datefmt) + self.show_details = show_details + self.LEVEL_COLORS = { + 'NOTSET' : LogColors.NOTSET, + 'DEBUG' : LogColors.DEBUG, + 'INFO' : LogColors.INFO, + 'WARNING' : LogColors.WARNING, + 'ERROR' : LogColors.ERROR, + 'CRITICAL' : LogColors.CRITICAL, + 'FATAL' : LogColors.FATAL + } + + def format(self, record): + log_level = record.levelname + message = record.getMessage() + asctime = self.formatTime(record, self.datefmt) + color = self.LEVEL_COLORS.get(log_level, LogColors.RESET) + separator = f'{LogColors.SEPARATOR} ┃ {LogColors.RESET}' + if self.show_details: + module = record.module + line_no = record.lineno + func_name = record.funcName + formatted = ( + f'{LogColors.DATE}{asctime}{LogColors.RESET}' + f'{separator}' + f'{color}{log_level:<8}{LogColors.RESET}' + f'{separator}' + f'{LogColors.MODULE}{module}{LogColors.RESET}' + f'{separator}' + f'{LogColors.FUNCTION}{func_name}{LogColors.RESET}' + f'{separator}' + f'{LogColors.LINE}{line_no}{LogColors.RESET}' + f'{separator}' + f'{message}' + ) + else: + formatted = ( + f'{LogColors.DATE}{asctime}{LogColors.RESET}' + f'{separator}' + f'{color}{log_level:<8}{LogColors.RESET}' + f'{separator}' + f'{message}' + ) + return formatted + + # Create console handler with colored output + console_handler = logging.StreamHandler() + console_handler.setLevel(level_num) + console_formatter = ColoredFormatter(datefmt=self.date_format, show_details=self.show_details) + console_handler.setFormatter(console_formatter) + logging.getLogger().addHandler(console_handler) + + + def setup_file_handler(self, level_num: int): + ''' + Set up the file handler for logging to disk. + + :param level_num: The logging level number. + ''' + + # Create 'logs' directory if it doesn't exist + logs_dir = os.path.join(os.getcwd(), 'logs') + os.makedirs(logs_dir, exist_ok=True) + + # Use the specified log file name and set extension based on json_log + file_extension = '.json' if self.json_log else '.log' + log_file_path = os.path.join(logs_dir, f'{self.log_file_name}{file_extension}') + + # Create the rotating file handler + if self.compress_backups: + file_handler = GZipRotatingFileHandler(log_file_path, maxBytes=self.max_log_size, backupCount=self.max_backups) + else: + file_handler = logging.handlers.RotatingFileHandler(log_file_path, maxBytes=self.max_log_size, backupCount=self.max_backups) + file_handler.setLevel(level_num) + + if self.ecs_log: + try: + import ecs_logging + except ImportError: + raise ImportError("The 'ecs-logging' library is required for ECS logging. Install it with 'pip install ecs-logging'.") + file_formatter = ecs_logging.StdlibFormatter() + elif self.json_log: + # Create the JSON formatter + class JsonFormatter(logging.Formatter): + def format(self, record): + log_record = { + 'time' : self.formatTime(record, self.datefmt), + 'level' : record.levelname, + 'module' : record.module, + 'function' : record.funcName, + 'line' : record.lineno, + 'message' : record.getMessage(), + 'name' : record.name, + 'filename' : record.filename, + 'threadName' : record.threadName, + 'processName' : record.processName, + } + return json.dumps(log_record) + file_formatter = JsonFormatter(datefmt=self.date_format) + else: + file_formatter = logging.Formatter(fmt='%(asctime)s ┃ %(levelname)-8s ┃ %(module)s ┃ %(funcName)s ┃ %(lineno)d ┃ %(message)s', datefmt=self.date_format) + + file_handler.setFormatter(file_formatter) + logging.getLogger().addHandler(file_handler) + + + def setup_graylog_handler(self, level_num: int): + ''' + Set up the Graylog handler. + + :param level_num: The logging level number. + ''' + + graylog_host = self.graylog_host + graylog_port = self.graylog_port + if graylog_host is None or graylog_port is None: + logging.error('Graylog host and port must be specified for Graylog handler.') + return + + class GraylogHandler(logging.Handler): + def __init__(self, graylog_host, graylog_port): + super().__init__() + self.graylog_host = graylog_host + self.graylog_port = graylog_port + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + # Mapping from Python logging levels to Graylog (syslog) levels + self.level_mapping = { + logging.CRITICAL : 2, # Critical + logging.ERROR : 3, # Error + logging.WARNING : 4, # Warning + logging.INFO : 6, # Informational + logging.DEBUG : 7, # Debug + logging.NOTSET : 7 # Default to Debug + } + + def emit(self, record): + try: + log_entry = self.format(record) + graylog_level = self.level_mapping.get(record.levelno, 7) # Default to Debug + gelf_message = { + 'version' : '1.1', + 'host' : socket.gethostname(), + 'short_message' : record.getMessage(), + 'full_message' : log_entry, + 'timestamp' : record.created, + 'level' : graylog_level, + '_logger_name' : record.name, + '_file' : record.pathname, + '_line' : record.lineno, + '_function' : record.funcName, + '_module' : record.module, + } + gelf_json = json.dumps(gelf_message).encode('utf-8') + self.sock.sendto(gelf_json, (self.graylog_host, self.graylog_port)) + except Exception: + self.handleError(record) + + graylog_handler = GraylogHandler(graylog_host, graylog_port) + graylog_handler.setLevel(level_num) + + graylog_formatter = logging.Formatter(fmt='%(message)s') + graylog_handler.setFormatter(graylog_formatter) + logging.getLogger().addHandler(graylog_handler) + + + def setup_cloudwatch_handler(self, level_num: int): + ''' + Set up the CloudWatch handler. + + :param level_num: The logging level number. + ''' + + try: + import boto3 + from botocore.exceptions import ClientError + except ImportError: + raise ImportError('boto3 is required for CloudWatch logging. (pip install boto3)') + + log_group_name = self.cloudwatch_group_name + log_stream_name = self.cloudwatch_stream_name + if not log_group_name or not log_stream_name: + logging.error('CloudWatch log group and log stream must be specified for CloudWatch handler.') + return + + class CloudWatchHandler(logging.Handler): + def __init__(self, log_group_name, log_stream_name): + super().__init__() + self.log_group_name = log_group_name + self.log_stream_name = log_stream_name + self.client = boto3.client('logs') + + # Create log group if it doesn't exist + try: + self.client.create_log_group(logGroupName=self.log_group_name) + except ClientError as e: + if e.response['Error']['Code'] != 'ResourceAlreadyExistsException': + raise e + + # Create log stream if it doesn't exist + try: + self.client.create_log_stream(logGroupName=self.log_group_name, logStreamName=self.log_stream_name) + except ClientError as e: + if e.response['Error']['Code'] != 'ResourceAlreadyExistsException': + raise e + + def _get_sequence_token(self): + try: + response = self.client.describe_log_streams( + logGroupName=self.log_group_name, + logStreamNamePrefix=self.log_stream_name, + limit=1 + ) + log_streams = response.get('logStreams', []) + if log_streams: + return log_streams[0].get('uploadSequenceToken') + else: + return None + except Exception: + return None + + def emit(self, record): + try: + log_entry = self.format(record) + timestamp = int(record.created * 1000) + event = { + 'timestamp': timestamp, + 'message': log_entry + } + sequence_token = self._get_sequence_token() + kwargs = { + 'logGroupName': self.log_group_name, + 'logStreamName': self.log_stream_name, + 'logEvents': [event] + } + if sequence_token: + kwargs['sequenceToken'] = sequence_token + self.client.put_log_events(**kwargs) + except Exception: + self.handleError(record) + + cloudwatch_handler = CloudWatchHandler(log_group_name, log_stream_name) + cloudwatch_handler.setLevel(level_num) + + # Log as JSON + class JsonFormatter(logging.Formatter): + def format(self, record): + log_record = { + 'time' : self.formatTime(record, self.datefmt), + 'level' : record.levelname, + 'module' : record.module, + 'function' : record.funcName, + 'line' : record.lineno, + 'message' : record.getMessage(), + 'name' : record.name, + 'filename' : record.filename, + 'threadName' : record.threadName, + 'processName' : record.processName, + } + return json.dumps(log_record) + + cloudwatch_formatter = JsonFormatter(datefmt=self.date_format) + cloudwatch_handler.setFormatter(cloudwatch_formatter) + logging.getLogger().addHandler(cloudwatch_handler) + + + +def setup_logging(**kwargs): + '''Set up logging with various handlers and options.''' + + logger_setup = LoggerSetup(**kwargs) + logger_setup.setup() \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e8dd5e2 --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# Advanced Python Logging - Developed by acidvegas in Python (https://git.acid.vegas/apv) +# setup.py + +from setuptools import setup, find_packages + +setup( + name='apv', + version='1.0.0', + description='Advanced Python Logging', + author='acidvegas', + url='https://git.acid.vegas/apv', + packages=find_packages(), + install_requires=[ + # No required dependencies for basic functionality + ], + extras_require={ + 'cloudwatch': ['boto3'], + 'ecs' : ['ecs-logging'], + }, + classifiers=[ + 'Programming Language :: Python :: 3', + ], +) \ No newline at end of file diff --git a/unittest.py b/unittest.py new file mode 100644 index 0000000..222c5c4 --- /dev/null +++ b/unittest.py @@ -0,0 +1,95 @@ +#! /usr/bin/env python3 +# Advanced Python Logging - Developed by acidvegas in Python (https://git.acid.vegas/apv) +# unittest.py + +import logging +import random +import time + +# prevent bytecode files (.pyc) from being written +from sys import dont_write_bytecode +dont_write_bytecode = True + +import apv + +# Test console logging with custom date format +apv.setup_logging(level='DEBUG', date_format='%H:%M:%S') +logging.debug('Testing debug message in console.') +logging.info('Testing info message in console.') +logging.warning('Testing warning message in console.') +logging.error('Testing error message in console.') +logging.critical('Testing critical message in console.') + +print() + +# Test console logging with details +time.sleep(2) +apv.setup_logging(level='DEBUG', date_format='%Y-%m-%d %H:%M:%S', show_details=True) +logging.debug('Testing debug message in console with details.') +logging.info('Testing info message in console with details.') +logging.warning('Testing warning message in console with details.') +logging.error('Testing error message in console with details.') +logging.critical('Testing critical message in console with details.') + +print() + +# Test disk logging with JSON and regular rotation +logging.debug('Starting test: Disk logging with JSON and regular rotation...') +time.sleep(2) +apv.setup_logging(level='DEBUG', log_to_disk=True, max_log_size=1024, max_backups=3, log_file_name='json_log', json_log=True, show_details=True) +for i in range(100): + log_level = random.choice([logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]) + logging.log(log_level, f'Log entry {i+1} for JSON & regular rotation test.') + time.sleep(0.1) + +print() + +# Test disk logging with rotation & compression +logging.debug('Starting test: Disk logging with rotation & compression...') +time.sleep(2) +apv.setup_logging(level='DEBUG', log_to_disk=True, max_log_size=1024, max_backups=3, log_file_name='plain_log', show_details=True, compress_backups=True) +for i in range(100): + log_level = random.choice([logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]) + logging.log(log_level, f'Log entry {i+1} for disk rotation & compression test.') + time.sleep(0.1) + +logging.info('Test completed. Check the logs directory for disk logging & JSON logging tests.') + +print() + +try: + import ecs_logging +except ImportError: + pass +else: + # Test ECS logging + logging.debug('Starting test: ECS logging...') + time.sleep(2) + apv.setup_logging(level='DEBUG', ecs_log=True) + logging.debug('This is a test log message to ECS.') + logging.info('This is a test log message to ECS.') + logging.warning('This is a test log message to ECS.') + logging.error('This is a test log message to ECS.') + logging.critical('This is a test log message to ECS.') + +print() + +# Test Graylog handler (Uncomment & configure to test) +# logging.debug('Starting test: Graylog handler...') +# time.sleep(2) +# apv.setup_logging(level='DEBUG', enable_graylog=True, graylog_host='your_graylog_host', graylog_port=12201) +# logging.debug('This is a test log message to Graylog.') +# logging.info('This is a test log message to Graylog.') +# logging.warning('This is a test log message to Graylog.') +# logging.error('This is a test log message to Graylog.') +# logging.critical('This is a test log message to Graylog.') + +# Test CloudWatch handler (Uncomment & configure to test) +# logging.debug('Starting test: CloudWatch handler...') +# time.sleep(2) +# apv.setup_logging(level='DEBUG', enable_cloudwatch=True, cloudwatch_group_name='your_log_group', cloudwatch_stream_name='your_log_stream') +# logging.debug('This is a test log message to CloudWatch.') +# logging.info('This is a test log message to CloudWatch.') +# logging.warning('This is a test log message to CloudWatch.') +# logging.error('This is a test log message to CloudWatch.') +# logging.critical('This is a test log message to CloudWatch.')