Skip to content

feat: implement log rotation with safe stream swapping and backup management #47

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions application/logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import os
from pathlib import Path
from typing import Optional
from clp_logging.handlers import CLPFileHandler, CLPLogLevelTimeout, EOF_CHAR, FLUSH_FRAME
from datetime import datetime

class RotatingCLPFileHandler(CLPFileHandler):
"""
Extends `CLPFileHandler` to support file size-based log rotation.
"""

def __init__(
self,
filename_prefix: str,
log_dir: Path,
max_bytes: int,
backup_count: int = 5,
mode: str = "ab",
enable_compression: bool = True,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Consider replacing the boolean argument with a more descriptive parameter.

Some style guides discourage using boolean-typed parameters because they make function calls less self-explanatory. For instance, you could replace enable_compression: bool = True with an enum or separate methods to increase clarity. However, this is a minor style preference and not strictly required.

🧰 Tools
🪛 Ruff (0.8.2)

19-19: Boolean-typed positional argument in function definition

(FBT001)


19-19: Boolean default positional argument in function definition

(FBT002)

timestamp_format: str = "%Y%m%d_%H%M%S",
loglevel_timeout: Optional[CLPLogLevelTimeout] = None,
) -> None:
self.filename_prefix = filename_prefix
self.log_dir = Path(log_dir)
self.max_bytes = max_bytes
self.backup_count = backup_count
self.timestamp_format = timestamp_format

# Ensure log directory exists
os.makedirs(log_dir, exist_ok=True)

# Initialize the base filename with a timestamp
self.current_log_file = self._generate_log_filename()

super().__init__(
fpath=self.current_log_file,
mode=mode,
enable_compression=enable_compression,
timestamp_format=timestamp_format,
loglevel_timeout=loglevel_timeout,
)

def _generate_log_filename(self) -> Path:
"""
Generate a log filename with the timestamp and prefix.
"""
start_time = datetime.now()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Consider specifying a timezone for consistent log timestamps.

Calling datetime.now() without a timezone defaults to local time, which may result in ambiguity if your system’s timezone changes. If you prefer UTC-based logs or consistent timestamps across systems, specify datetime.now(timezone.utc).

- start_time = datetime.now()
+ from datetime import timezone
+ start_time = datetime.now(timezone.utc)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
start_time = datetime.now()
from datetime import timezone
start_time = datetime.now(timezone.utc)
🧰 Tools
🪛 Ruff (0.8.2)

47-47: datetime.datetime.now() called without a tz argument

(DTZ005)

timestamp = start_time.strftime(self.timestamp_format)
self.current_log_file = Path(f"{self.log_dir}/{self.filename_prefix}_{timestamp}.clp.zst")

return self.current_log_file

def _should_rotate(self) -> bool:
"""
Check if the current file exceeds the maximum size.
"""
try:
return self.current_log_file.stat().st_size >= self.max_bytes
except FileNotFoundError:
return False


def _rotate(self) -> None:
"""
Perform log rotation by finalizing the current stream, creating a new log file,
and reinitializing the handler with the new stream.

This method is responsible for:
1. Flushing and writing any remaining buffered data in the current stream.
2. Writing a logical end-of-file (EOF) marker to signal the end of the log stream.
3. Closing the current stream to release any associated resources.
4. Generating a new log file name based on the current timestamp.
5. Opening a new stream for the new log file and reinitializing the handler to use it.
6. Removing old log files if the number of backups exceeds the specified backup count.

Thread safety is ensured by acquiring and releasing the handler's lock during the
transition between streams.
"""
try:
self.ostream.flush(FLUSH_FRAME)
self.ostream.write(EOF_CHAR)
finally:
self.ostream.close()

# Generate a new log filename
new_log_file = self._generate_log_filename()

# Initialize the new stream
try:
new_stream = open(new_log_file, "ab")
except Exception as e:
raise RuntimeError(f"Failed to open new log file {new_log_file}: {e}")

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Use more specific exception handling and consider raising exceptions with context.

Catching a broad Exception can make debugging more difficult and may mask unexpected issues. Also, raising with from e preserves the original traceback context. Using a context manager (e.g. with open(...) as f) isn’t straightforward here because you need to keep the stream open after initialization, but you can still refine your exception handling to be more explicit:

 try:
-    new_stream = open(new_log_file, "ab")
-except Exception as e:
-    raise RuntimeError(f"Failed to open new log file {new_log_file}: {e}")
+    new_stream = open(new_log_file, "ab")
+except OSError as err:
+    raise RuntimeError(f"Failed to open new log file {new_log_file}") from err
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
new_stream = open(new_log_file, "ab")
except Exception as e:
raise RuntimeError(f"Failed to open new log file {new_log_file}: {e}")
new_stream = open(new_log_file, "ab")
except OSError as err:
raise RuntimeError(f"Failed to open new log file {new_log_file}") from err
🧰 Tools
🪛 Ruff (0.8.2)

90-90: Use a context manager for opening files

(SIM115)


91-91: Do not catch blind exception: Exception

(BLE001)


92-92: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


92-92: Avoid specifying long messages outside the exception class

(TRY003)

self.acquire()
try:
self.stream = new_stream
self.init(new_stream)
finally:
self.release()

# Remove old backups
self._remove_old_backups()

def _remove_old_backups(self) -> None:
"""
Remove old log files exceeding the backup count.
"""
if not self.backup_count:
return

log_files = sorted(
self.log_dir.glob(f"{self.filename_prefix}_*.clp.zst"),
key=os.path.getmtime,
)
if len(log_files) > self.backup_count:
for old_file in log_files[:-self.backup_count]:
try:
old_file.unlink()
except Exception as e:
print(f"Error deleting old log file {old_file}: {e}")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Catch more targeted exceptions when removing old log files.

Catching Exception is overly broad and may inadvertently hide important errors. Consider using OSError (or a more specific exception) to handle file-related errors.

- except Exception as e:
+ except OSError as e:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except Exception as e:
print(f"Error deleting old log file {old_file}: {e}")
except OSError as e:
print(f"Error deleting old log file {old_file}: {e}")
🧰 Tools
🪛 Ruff (0.8.2)

119-119: Do not catch blind exception: Exception

(BLE001)


def _write(self, loglevel: int, msg: str) -> None:
"""
Override `_write` to add rotation logic.
"""
if self._should_rotate():
self._rotate()
super()._write(loglevel, msg)
25 changes: 19 additions & 6 deletions log_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,25 @@ formatters:
datefmt: '%Y-%m-%d %H:%M:%S'

handlers:
console:
class: logging.StreamHandler
level: DEBUG
file:
class: logging.FileHandler
level: NOTSET
formatter: default
stream: ext://sys.stderr
filename: 'example.log' # Output file path
encoding: utf-8
mode: w # 'w' to overwrite each time, 'a' to append

CLP_file:
class: application.logger.RotatingCLPFileHandler
level: NOTSET
formatter: default
filename_prefix: 'ictrl_'
log_dir: 'logs'
max_bytes: 5242880 # Rotate after 5 MB
backup_count: 20 # Keep 20 backup files
enable_compression: True
timestamp_format: '%Y-%m-%d_%H-%M-%S'

root:
level: DEBUG
handlers: [console]
level: NOTSET
handlers: [file, CLP_file]