
Combining Exception Handling with Logging
Exception handling and logging are two fundamental pillars of robust Python programming. While exception handling helps your program deal with unexpected situations gracefully, logging provides the visibility you need to understand what happened and why. When combined effectively, they create a powerful system for building reliable, maintainable applications.
Why Combine Exception Handling with Logging?
Simply catching exceptions isn't enough - you need to know when and why they occur. Logging provides the perfect mechanism to record exception details without disrupting your program's flow. Instead of just printing error messages to the console, which might get missed, logging allows you to: - Persist error information for later analysis - Capture contextual data around the exception - Control where and how errors are recorded - Add severity levels to different types of exceptions
Let's start with a basic example of exception handling without logging:
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero!")
This approach has several limitations. The error message might get lost among other output, and you lose valuable debugging information like the traceback.
Basic Integration of Logging and Exceptions
Here's how you can enhance this with Python's built-in logging module:
import logging
# Configure basic logging
logging.basicConfig(level=logging.ERROR, filename='app.log')
try:
result = 10 / 0
except ZeroDivisionError as e:
logging.error("Division by zero attempted: %s", e)
This approach saves the error to a file, but we're still missing the traceback. Let's improve it:
import logging
import traceback
logging.basicConfig(level=logging.ERROR, filename='app.log')
try:
result = 10 / 0
except ZeroDivisionError:
logging.error("Division error occurred:\n%s", traceback.format_exc())
Logging Level | Usage with Exceptions | Typical Scenario |
---|---|---|
ERROR | Expected exceptions | Division by zero, file not found |
WARNING | Recoverable issues | Deprecated API usage |
CRITICAL | Unrecoverable errors | Database connection lost |
INFO | Normal operation | Successful operations |
DEBUG | Detailed debugging | Step-by-step execution |
The real power comes when you use the exc_info
parameter:
import logging
logging.basicConfig(level=logging.ERROR, filename='app.log')
try:
result = 10 / 0
except ZeroDivisionError:
logging.error("Division by zero error", exc_info=True)
This automatically captures the full traceback and includes it in the log.
Structured Exception Logging
For more complex applications, you'll want to create a more structured approach:
import logging
# Create a dedicated logger for exceptions
exception_logger = logging.getLogger('exception_handler')
exception_logger.setLevel(logging.ERROR)
# Create file handler
file_handler = logging.FileHandler('exceptions.log')
file_handler.setLevel(logging.ERROR)
# Create formatter
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler.setFormatter(formatter)
# Add handler to logger
exception_logger.addHandler(file_handler)
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError as e:
exception_logger.error("Division by zero in safe_divide: %s/%s", a, b, exc_info=True)
return None
except TypeError as e:
exception_logger.error("Type error in safe_divide: %s", e, exc_info=True)
return None
Key benefits of using logging with exceptions: - Centralized error tracking across your application - Configurable output destinations (files, console, network) - Automatic timestamping of when errors occurred - Flexible formatting of error messages - Different log levels for different severity of issues
Contextual Logging with Exceptions
Often, you need more context than just the exception itself. Here's how to add contextual information:
import logging
from logging import LoggerAdapter
class ContextLogger:
def __init__(self, logger, context):
self.logger = logger
self.context = context
def error(self, msg, *args, **kwargs):
if 'extra' not in kwargs:
kwargs['extra'] = {}
kwargs['extra'].update(self.context)
self.logger.error(msg, *args, **kwargs)
# Usage
logger = logging.getLogger('app')
context_logger = ContextLogger(logger, {'user_id': 123, 'operation': 'file_processing'})
try:
# Some operation that might fail
process_file('data.txt')
except Exception as e:
context_logger.error("Failed to process file: %s", e, exc_info=True)
Context Field | Example Value | Purpose |
---|---|---|
user_id | 12345 | Identify affected user |
request_id | req-abc123 | Track specific request |
module | data_processor | Locate source of error |
operation | file_upload | Identify failed operation |
timestamp | 2023-12-01T10:30:00 | Exact time of failure |
Advanced Exception Handling Patterns
For enterprise applications, consider these patterns:
import logging
from functools import wraps
def log_exceptions(logger):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
logger.error(
"Exception in %s: %s",
func.__name__,
e,
exc_info=True,
extra={'args': args, 'kwargs': kwargs}
)
raise # Re-raise the exception
return wrapper
return decorator
# Usage
logger = logging.getLogger('app')
@log_exceptions(logger)
def process_data(data):
# Your code here
return data.upper()
Best practices for combining logging and exceptions: - Always include tracebacks for debugging - Add relevant context to help with investigation - Use appropriate log levels for different exception types - Don't log sensitive information in error messages - Consider using structured logging formats like JSON - Rotate log files to prevent disk space issues - Set up alerts for critical errors
Handling Specific Exception Types
Different exceptions should often be handled and logged differently:
import logging
logger = logging.getLogger('app')
def handle_file_operations(filename):
try:
with open(filename, 'r') as file:
content = file.read()
return process_content(content)
except FileNotFoundError:
logger.warning("File not found: %s", filename)
return None
except PermissionError:
logger.error("Permission denied for file: %s", filename)
raise # Re-raise critical errors
except UnicodeDecodeError as e:
logger.error("Encoding error in file %s: %s", filename, e, exc_info=True)
return None
except Exception as e:
logger.critical("Unexpected error processing file %s: %s", filename, e, exc_info=True)
raise
Logging Configuration Best Practices
A well-configured logging setup is crucial:
import logging
import logging.config
LOGGING_CONFIG = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'detailed': {
'format': '%(asctime)s %(name)-15s %(levelname)-8s %(message)s'
}
},
'handlers': {
'file': {
'class': 'logging.FileHandler',
'level': 'ERROR',
'formatter': 'detailed',
'filename': 'errors.log',
},
'console': {
'class': 'logging.StreamHandler',
'level': 'INFO',
'formatter': 'detailed',
}
},
'root': {
'level': 'INFO',
'handlers': ['file', 'console']
},
}
logging.config.dictConfig(LOGGING_CONFIG)
Critical considerations for production logging: - Separate error logs from regular application logs - Implement log rotation to manage file sizes - Use appropriate log levels for different environments - Include correlation IDs for distributed systems - Monitor log files for error patterns - Regularly review and clean up old log files
Real-World Example: Database Operations
Here's a practical example combining exception handling and logging for database operations:
import logging
import psycopg2
from psycopg2 import OperationalError, ProgrammingError
logger = logging.getLogger('database')
class DatabaseManager:
def __init__(self, connection_string):
self.connection_string = connection_string
self.connection = None
def connect(self):
try:
self.connection = psycopg2.connect(self.connection_string)
logger.info("Database connection established")
return True
except OperationalError as e:
logger.error("Failed to connect to database: %s", e, exc_info=True)
return False
def execute_query(self, query, params=None):
try:
with self.connection.cursor() as cursor:
cursor.execute(query, params or ())
result = cursor.fetchall()
logger.debug("Query executed successfully: %s", query)
return result
except ProgrammingError as e:
logger.error("SQL error in query %s: %s", query, e, exc_info=True)
raise
except OperationalError as e:
logger.critical("Database connection lost: %s", e, exc_info=True)
self.reconnect()
raise
except Exception as e:
logger.error("Unexpected error executing query: %s", e, exc_info=True)
raise
def reconnect(self):
logger.warning("Attempting to reconnect to database")
self.connect()
This approach ensures that: - Connection errors are logged as errors - SQL syntax errors are logged with full tracebacks - Connection loss triggers critical logs and reconnection attempts - Successful operations are logged at debug level - Unexpected errors are captured with complete context
By combining exception handling with strategic logging, you create applications that not only handle errors gracefully but also provide the diagnostic information needed to fix issues quickly and prevent them from recurring.