watchtower

repository·main·Indexed 21 days ago

https://github.com/kislyuk/watchtower

A lightweight Python log handler that sends logs directly to AWS CloudWatch Logs using boto3. It features asynchronous batching via CloudWatchLogHandler to optimize API usage and supports structured JSON logging through CloudWatchLogFormatter. The library includes integrations for Flask and Django, supports YAML configuration via dictConfig, and provides automatic filtering to prevent infinite log loops from boto3, botocore, and urllib3.

Tokens
3.3K
Snippets
8
Records
14
Agent score
24%

What's inside watchtower

  1. Understand Log Stream Naming and High-Volume Logging

    main

    For high-volume applications using process pools, avoid sending logs from multiple independent processes to the same log stream. This causes sequence token synchronization errors and performance overhead.

    Best Practice: Use unique log stream names per source. Watchtower's default format is {machine_name}/{program_name}/{logger_name}/{process_id}. You can also use template variables like {logger_name} or {strftime} to partition logs into different streams.

  2. Watchtower compatibility with AWS Lambda

    main

    Watchtower is not recommended for use in AWS Lambda environments for two reasons:

    1. Redundancy: AWS Lambda automatically captures all stderr output and sends it to CloudWatch Logs under the /aws/lambda/ prefix. Using watchtower is unnecessary.
    2. Execution Model: AWS Lambda freezes the execution environment once an invocation completes. Because watchtower relies on asynchronous background processes and threads to send logs, these processes will be suspended and unable to complete their work before the environment freezes, leading to incorrect behavior or lost logs.
  3. Avoid infinite log loops from Boto3/botocore/urllib3

    main

    Because watchtower uses boto3 to transmit logs, the act of sending logs can trigger DEBUG level messages from boto3, botocore, and urllib3. This can create a self-perpetuating loop of log messages.

    watchtower.CloudWatchLogHandler automatically handles this by attaching a filter that:

    1. Drops all DEBUG level messages from boto3, botocore, and urllib3.
    2. Drops all messages from these libraries during flush() and close() operations.

    Note that this filter only applies to the watchtower handler. If you have other handlers (like a console handler), they will still receive and print these DEBUG messages. For example, the following configuration will print botocore debug logs to stderr but will NOT send them to CloudWatch:

    import watchtower, logging
    logging.basicConfig(level=logging.DEBUG)
    logger = logging.getLogger()
    logger.addHandler(watchtower.CloudWatchLogHandler())
  4. Configure IAM permissions for Watchtower

    main

    The process running Watchtower requires IAM permissions to call the CloudWatch Logs API.

    • Recommended Policy: Use the AWS managed policy arn:aws:iam::aws:policy/AWSOpsWorksCloudWatchLogs for standard logging permissions.
    • Tagging Support: If you use the log_group_tags parameter in the handler, you must also grant the logs:TagResource permission.
    • AWS Environments: When running on EC2 or other AWS compute resources, boto3 (and thus Watchtower) will automatically use instance metadata (IMDS) or container credentials.
  5. How CloudWatchLogHandler manages log batches

    main

    When use_queues=True (the default), CloudWatchLogHandler operates using a producer-consumer model to minimize performance impact on your application:

    1. Queuing: Each unique log stream gets its own queue.Queue.
    2. Background Threading: A dedicated daemon thread is spawned for each stream to monitor the queue.
    3. Batching Triggers: The background thread pulls messages from the queue and triggers a put_log_events call to AWS when one of these conditions is met:
      • send_interval has elapsed.
      • max_batch_size (in bytes) is reached.
      • max_batch_count (number of messages) is reached.
    4. Flushing and Closing:
      • Calling .flush() forces the handler to send all currently queued messages.
      • Calling .close() signals the background threads to finish processing existing messages and then shut down. It is recommended to call .close() during application shutdown to ensure no logs are lost.
  6. Integrate Watchtower with Django

    main

    In a Django project, configure watchtower within the LOGGING dictionary in settings.py.

    Important Note on Debugging: In the Django debug server (manage.py runserver), certain system loggers can cause deadlocks due to threading in the logging handler. It is recommended to set propagate: False for the django logger in development, or use production WSGI servers (like gunicorn or uwsgi) where this limitation does not apply.

    import boto3
    
    AWS_REGION_NAME = "us-west-2"
    
    boto3_logs_client = boto3.client("logs", region_name=AWS_REGION_NAME)
    
    LOGGING = {
        'version': 1,
        'disable_existing_loggers': False,
        'root': {
            'level': 'DEBUG',
            'handlers': ['watchtower', 'console'],
        },
        'handlers': {
            'console': {
                'class': 'logging.StreamHandler',
            },
            'watchtower': {
                'class': 'watchtower.CloudWatchLogHandler',
                'boto3_client': boto3_logs_client,
                'log_group_name': 'YOUR_DJANGO_PROJECT_NAME',
                'level': 'DEBUG'
            }
        },
        'loggers': {
            'django': {
                'level': 'DEBUG',
                'handlers': ['console'],
                'propagate': False
            }
        }
    }
  7. Basic usage of CloudWatchLogHandler

    main

    To use Watchtower, instantiate watchtower.CloudWatchLogHandler and add it to a Python logger. By default, logs are sent to a log group named watchtower in your AWS account. Watchtower aggregates logs into batches and sends them every 60 seconds by default to optimize API usage.

    import watchtower, logging
    logging.basicConfig(level=logging.INFO)
    logger = logging.getLogger(__name__)
    logger.addHandler(watchtower.CloudWatchLogHandler())
    logger.info("Hi")
    logger.info(dict(foo="bar", details={}))
  8. Integrate Watchtower with Flask

    main

    To send Flask logs to a specific CloudWatch stream, create a handler using the application name as the log_group_name and add it to both the app logger and relevant system loggers (like werkzeug).

    import watchtower, flask, logging
    
    logging.basicConfig(level=logging.INFO)
    app = flask.Flask("loggable")
    handler = watchtower.CloudWatchLogHandler(log_group_name=app.name)
    app.logger.addHandler(handler)
    logging.getLogger("werkzeug").addHandler(handler)
    
    @app.route('/')
    def hello_world():
        return 'Hello World!'
    
    if __name__ == '__main__':
        app.run()
  9. Configure Watchtower via YAML dictConfig

    main

    You can use Python's logging.config.dictConfig to load Watchtower settings from a YAML file. This allows you to specify parameters like log_stream_name, send_interval, and boto3_profile_name externally.

    # Example logging.yml
    version: 1
    disable_existing_loggers: False
    handlers:
      watchtower:
        class: watchtower.CloudWatchLogHandler
        level: DEBUG
        log_group_name: watchtower
        log_stream_name: "{logger_name}-{strftime:%y-%m-%d}"
        send_interval: 10
        create_log_group: False
        # Optional: use a specific AWS profile
        # boto3_profile_name: watchtowerlogger
    root:
      level: DEBUG
      handlers: [watchtower]
    import logging.config
    import yaml
    
    with open('logging.yml') as log_config:
        config_dict = yaml.safe_load(log_config)
        logging.config.dictConfig(config_dict)
  10. Send structured JSON logs with CloudWatchLogFormatter

    main

    The CloudWatchLogFormatter (the default formatter for CloudWatchLogHandler) is designed to handle structured data. If you pass a dictionary to your logger instead of a string, the formatter will serialize it as a JSON object, which CloudWatch Logs can then parse and index.

    Structured Logging Example

    import logging
    import watchtower
    
    logger = logging.getLogger(__name__)
    logger.addHandler(watchtower.CloudWatchLogHandler())
    
    # Passing a dictionary creates a structured JSON log in CloudWatch
    logger.critical({"request": "hello", "metadata": {"size": 9000}})

    Including LogRecord Attributes

    You can automatically include metadata from the Python LogRecord (like levelname, filename, process, etc.) inside the JSON message by setting add_log_record_attrs on the handler's formatter.

    import logging
    import watchtower
    
    logger = logging.getLogger(__name__)
    handler = watchtower.CloudWatchLogHandler()
    # Enable forwarding of specific attributes
    handler.formatter.add_log_record_attrs = ["levelname", "filename", "process", "thread"]
    logger.addHandler(handler)
    
    logger.critical({"request": "hello"})

    This results in a CloudWatch event where the message field is a JSON string containing both your custom data and the requested metadata.

    import logging
    import watchtower
    
    logger = logging.getLogger(__name__)
    handler = watchtower.CloudWatchLogHandler()
    handler.formatter.add_log_record_attrs = ["levelname", "filename", "process", "thread"]
    logger.addHandler(handler)
    
    logger.critical({"request": "hello"})