alishahryar1--free-claude-code
f77fe8581c
## Problem The server always writes DEBUG logs, producing detailed request traces customers rarely need and allowing rotated files to accumulate without a retention cap. Supervised restarts also need to apply changed logging settings consistently. Closes #1141. ## Changes | Before | After | | --- | --- | | The file sink always starts at `DEBUG`. | `LOG_LEVEL` supports `DEBUG`, `INFO`, `WARNING`, `ERROR`, and `CRITICAL`, with a customer-friendly `INFO` default. | | Structured request traces are emitted at `INFO`. | Structured request traces are emitted at `DEBUG` and remain available for opt-in diagnostics. | | Logs rotate at 50 MB without a retention limit. | Logs retain five rotated files, bounding normal usage to roughly 300 MB including the active file. | | Supervised restarts can keep stale sink and third-party logger levels. | Supervised restarts replace the sink or third-party levels only when their effective settings change. | | The package version is `4.7.2`. | The package version is `4.7.3`. | <!-- greptile_comment --> <h3>Greptile Summary</h3> This PR adds configurable file-log verbosity and improves logging behavior across supervised restarts. The main changes are: - Adds a validated `LOG_LEVEL` setting with an `INFO` default. - Moves structured request traces from `INFO` to `DEBUG`. - Retains five rotated log files. - Replaces the file sink when its normalized path or level changes. - Updates third-party logger levels when verbose logging changes. - Bumps the package version to `4.7.3`. <h3>Confidence Score: 5/5</h3> This looks safe to merge. The supervised restart path now replaces the sink when its normalized path or level changes. Verbosity-only changes update third-party logger levels without replacing the file sink. No blocking issues were found in the changed code. <details><summary><h3><a href="https://www.greptile.com/trex"><img alt="T-Rex" src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg" height="20" align="absmiddle"></a> T-Rex Logs</h3></summary> **What T-Rex did** - T-Rex captured the baseline parent-revision import failure in runtime-logging-01-before.log. - T-Rex re-created the virtual environment for the current revision and captured runtime-logging-02-after.log, which shows the same import failure after uv reinstallation. - T-Rex verified the uv-managed Python 3.14 environment exists but cannot import loguru, as shown in runtime-logging-environment-blocker.log. - Artifacts corresponding to the three runtime-logging logs and the Python artifact were prepared for review. <a href="https://app.greptile.com/trex/runs/14767708/artifacts"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img alt="View all artifacts" src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a> <sub><a href="https://www.greptile.com/trex"><img alt="T-Rex" src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg" height="14" align="absmiddle"></a> Ran code and verified through T-Rex</sub> </details> <h3>Important Files Changed</h3> | Filename | Overview | |----------|----------| | src/free_claude_code/config/logging_config.py | Tracks the active sink path, level, verbosity, and identifier so supervised restarts apply changed logging settings. | | src/free_claude_code/config/settings.py | Adds and validates the `LOG_LEVEL` environment setting. | | src/free_claude_code/runtime/bootstrap.py | Passes the configured log level and third-party verbosity into logging setup. | | src/free_claude_code/core/trace.py | Emits structured request traces at `DEBUG` instead of `INFO`. | | tests/config/test_logging_config.py | Covers path, level, and verbosity changes along with default filtering and retention. | <sub>Reviews (6): Last reviewed commit: ["Make customer logging configurable and s..."](https://github.com/alishahryar1/free-claude-code/commit/0cae08607ee8b92a51b5094de9e01cc89478dbfd) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=44854591)</sub> <!-- /greptile_comment --> --------- Co-authored-by: Alishahryar1 <alishahryar2@gmail.com>
212 行
7.7 KiB
Python
212 行
7.7 KiB
Python
"""Tests for config/logging_config.py."""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from loguru import logger
|
|
|
|
from free_claude_code.config import logging_config
|
|
from free_claude_code.config.logging_config import configure_logging
|
|
|
|
|
|
def test_configure_logging_creates_parent_directories(tmp_path) -> None:
|
|
"""Nested log path: parent directories are created before truncating."""
|
|
log_file = tmp_path / "nested" / "dir" / "app.log"
|
|
configure_logging(str(log_file), force=True)
|
|
assert log_file.is_file()
|
|
|
|
|
|
def test_configure_logging_writes_json_to_file(tmp_path):
|
|
"""configure_logging writes JSON lines to the specified file."""
|
|
log_file = str(tmp_path / "test.log")
|
|
configure_logging(log_file, force=True)
|
|
|
|
# Emit a log via stdlib (intercepted to loguru)
|
|
logger = logging.getLogger("test.module")
|
|
logger.info("Test message for JSON")
|
|
|
|
# Force flush - loguru may buffer
|
|
from loguru import logger as loguru_logger
|
|
|
|
loguru_logger.complete()
|
|
|
|
content = Path(log_file).read_text(encoding="utf-8")
|
|
lines = [line for line in content.strip().split("\n") if line]
|
|
assert len(lines) >= 1
|
|
|
|
# Each line should be valid JSON
|
|
for line in lines:
|
|
record = json.loads(line)
|
|
assert "text" in record or "message" in record or "record" in record
|
|
|
|
|
|
def test_configure_logging_idempotent(tmp_path):
|
|
"""configure_logging is idempotent - safe to call twice with force."""
|
|
log_file = str(tmp_path / "test.log")
|
|
configure_logging(log_file, force=True)
|
|
configure_logging(log_file, force=True) # Should not raise
|
|
|
|
logger = logging.getLogger("test.idempotent")
|
|
logger.info("After second configure")
|
|
|
|
|
|
def test_configure_logging_replaces_sink_when_path_changes(tmp_path):
|
|
"""A path-only restart switches destinations without truncating either file."""
|
|
first_log = tmp_path / "first.log"
|
|
second_log = tmp_path / "nested" / "second.log"
|
|
configure_logging(first_log, force=True)
|
|
|
|
logger.info("first destination")
|
|
from loguru import logger as loguru_logger
|
|
|
|
loguru_logger.complete()
|
|
configure_logging(second_log)
|
|
logger.info("second destination")
|
|
loguru_logger.complete()
|
|
|
|
first_text = first_log.read_text(encoding="utf-8")
|
|
second_text = second_log.read_text(encoding="utf-8")
|
|
assert "first destination" in first_text
|
|
assert "second destination" not in first_text
|
|
assert "second destination" in second_text
|
|
|
|
|
|
def test_telegram_bot_token_redacted_in_message_field(tmp_path) -> None:
|
|
log_file = str(tmp_path / "redact.log")
|
|
configure_logging(log_file, force=True, verbose_third_party=False)
|
|
token = "123456:ABCDEF-ghij-klm"
|
|
logger.info("Calling {}", f"https://api.telegram.org/bot{token}/getMe")
|
|
logger.complete()
|
|
text = Path(log_file).read_text(encoding="utf-8")
|
|
assert token not in text
|
|
assert "bot<redacted>/" in text or "redacted" in text
|
|
|
|
|
|
def test_bearer_substring_redacted_in_log_file(tmp_path) -> None:
|
|
log_file = str(tmp_path / "bearer.log")
|
|
configure_logging(log_file, force=True, verbose_third_party=False)
|
|
secret = "ya29.secret-token-abc"
|
|
logger.info("Request headers: Authorization: Bearer {}", secret)
|
|
logger.complete()
|
|
text = Path(log_file).read_text(encoding="utf-8")
|
|
assert secret not in text
|
|
assert "Bearer" in text
|
|
|
|
|
|
def test_httpx_logger_quieted_when_not_verbose_third_party(tmp_path) -> None:
|
|
log_file = str(tmp_path / "quiet.log")
|
|
configure_logging(log_file, force=True, verbose_third_party=False)
|
|
assert logging.getLogger("httpx").level >= logging.WARNING
|
|
assert logging.getLogger("httpcore").level >= logging.WARNING
|
|
|
|
|
|
def test_httpx_resets_to_notset_when_verbose_third_party(tmp_path) -> None:
|
|
log_file = str(tmp_path / "verbose.log")
|
|
configure_logging(log_file, force=True, verbose_third_party=True)
|
|
assert logging.getLogger("httpx").level == logging.NOTSET
|
|
|
|
|
|
def test_configure_logging_respects_level(tmp_path) -> None:
|
|
"""INFO level suppresses DEBUG messages from the file sink."""
|
|
log_file = str(tmp_path / "level.log")
|
|
configure_logging(log_file, force=True, level="INFO")
|
|
|
|
logger.debug("should not appear")
|
|
logger.info("should appear")
|
|
logger.complete()
|
|
|
|
text = Path(log_file).read_text(encoding="utf-8")
|
|
assert "should appear" in text
|
|
assert "should not appear" not in text
|
|
|
|
|
|
def test_configure_logging_defaults_to_info(tmp_path) -> None:
|
|
"""Customer default keeps lifecycle logs while suppressing debug diagnostics."""
|
|
log_file = str(tmp_path / "default.log")
|
|
configure_logging(log_file, force=True)
|
|
|
|
logger.debug("debug message")
|
|
logger.info("info message")
|
|
logger.complete()
|
|
|
|
text = Path(log_file).read_text(encoding="utf-8")
|
|
assert "debug message" not in text
|
|
assert "info message" in text
|
|
|
|
|
|
def test_file_sink_bounds_rotated_log_retention(tmp_path) -> None:
|
|
"""Five archives plus the active 50 MB file bound normal disk usage."""
|
|
log_file = tmp_path / "bounded.log"
|
|
|
|
with patch.object(logging_config.logger, "add", return_value=1) as add:
|
|
logging_config._add_file_sink(log_file, "INFO")
|
|
|
|
assert add.call_args.kwargs["rotation"] == "50 MB"
|
|
assert add.call_args.kwargs["retention"] == 5
|
|
|
|
|
|
def test_configure_logging_handles_level_change_on_restart(tmp_path) -> None:
|
|
"""Restart with a different level replaces the sink without truncating."""
|
|
log_file = str(tmp_path / "restart.log")
|
|
|
|
# First call: DEBUG level
|
|
configure_logging(log_file, force=True, level="DEBUG")
|
|
logger.debug("first debug")
|
|
logger.complete()
|
|
|
|
# Second call: change to INFO (simulates supervised restart)
|
|
configure_logging(log_file, level="INFO")
|
|
logger.debug("second debug")
|
|
logger.info("second info")
|
|
logger.complete()
|
|
|
|
text = Path(log_file).read_text(encoding="utf-8")
|
|
# First DEBUG message was written before the level change
|
|
assert "first debug" in text
|
|
# Second DEBUG is suppressed by the new INFO level
|
|
assert "second debug" not in text
|
|
# INFO messages appear regardless
|
|
assert "second info" in text
|
|
|
|
|
|
def test_configure_logging_skips_when_level_unchanged(tmp_path) -> None:
|
|
"""When level hasn't changed, the existing sink is reused."""
|
|
log_file = str(tmp_path / "same.log")
|
|
configure_logging(log_file, force=True, level="WARNING")
|
|
logger.warning("first warning")
|
|
logger.complete()
|
|
|
|
# Second call with same level — should be a no-op for the sink
|
|
configure_logging(log_file, level="WARNING")
|
|
logger.warning("second warning")
|
|
logger.complete()
|
|
|
|
text = Path(log_file).read_text(encoding="utf-8")
|
|
assert "first warning" in text
|
|
assert "second warning" in text
|
|
|
|
|
|
def test_configure_logging_updates_verbosity_on_same_level(tmp_path) -> None:
|
|
"""When verbose_third_party changes but level stays the same, third-party
|
|
logger levels are updated without touching the file sink."""
|
|
log_file = str(tmp_path / "verbosity.log")
|
|
|
|
# Start with verbosity off (third-party loggers at WARNING)
|
|
configure_logging(log_file, force=True, level="DEBUG", verbose_third_party=False)
|
|
assert logging.getLogger("httpx").level >= logging.WARNING
|
|
assert logging.getLogger("httpcore").level >= logging.WARNING
|
|
assert logging.getLogger("telegram").level >= logging.WARNING
|
|
|
|
# Restart with same level but verbosity on
|
|
configure_logging(log_file, level="DEBUG", verbose_third_party=True)
|
|
assert logging.getLogger("httpx").level == logging.NOTSET
|
|
assert logging.getLogger("httpcore").level == logging.NOTSET
|
|
assert logging.getLogger("telegram").level == logging.NOTSET
|
|
|
|
# Log file sink still works
|
|
logger.info("still logging")
|
|
logger.complete()
|
|
assert "still logging" in Path(log_file).read_text(encoding="utf-8")
|