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>
187 行
5.8 KiB
Python
187 行
5.8 KiB
Python
"""Structured TRACE logging assertions."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from loguru import logger
|
|
|
|
from free_claude_code.config.logging_config import configure_logging
|
|
from free_claude_code.core.trace import (
|
|
TRACE_PAYLOAD_BINDING,
|
|
trace_event,
|
|
traced_async_stream,
|
|
)
|
|
|
|
|
|
class _CloseTrackingIterator:
|
|
def __init__(
|
|
self,
|
|
chunks: list[str],
|
|
*,
|
|
iteration_error: Exception | None = None,
|
|
close_error: Exception | None = None,
|
|
) -> None:
|
|
self._chunks = iter(chunks)
|
|
self._iteration_error = iteration_error
|
|
self._close_error = close_error
|
|
self.close_calls = 0
|
|
|
|
def __aiter__(self) -> _CloseTrackingIterator:
|
|
return self
|
|
|
|
async def __anext__(self) -> str:
|
|
try:
|
|
return next(self._chunks)
|
|
except StopIteration:
|
|
if self._iteration_error is not None:
|
|
error = self._iteration_error
|
|
self._iteration_error = None
|
|
raise error from None
|
|
raise StopAsyncIteration from None
|
|
|
|
async def aclose(self) -> None:
|
|
self.close_calls += 1
|
|
if self._close_error is not None:
|
|
raise self._close_error
|
|
|
|
|
|
def _json_log_rows(log_file: str) -> list[dict]:
|
|
logger.complete()
|
|
text = Path(log_file).read_text(encoding="utf-8").strip()
|
|
if not text:
|
|
return []
|
|
return [json.loads(line) for line in text.split("\n")]
|
|
|
|
|
|
def test_trace_payload_merged_into_json_line(tmp_path) -> None:
|
|
log_file = str(tmp_path / "t.log")
|
|
configure_logging(log_file, force=True, level="DEBUG")
|
|
trace_event(stage="s", event="e.v1", source="unit", hello="world", n=42)
|
|
row = _json_log_rows(log_file)[-1]
|
|
assert row["level"] == "DEBUG"
|
|
assert row["trace"] is True
|
|
assert row["stage"] == "s"
|
|
assert row["event"] == "e.v1"
|
|
assert row["source"] == "unit"
|
|
assert row["hello"] == "world"
|
|
assert row["n"] == 42
|
|
assert TRACE_PAYLOAD_BINDING == "trace_payload"
|
|
|
|
|
|
def test_trace_payload_excluded_from_default_info_logs(tmp_path) -> None:
|
|
log_file = str(tmp_path / "default.log")
|
|
configure_logging(log_file, force=True)
|
|
|
|
trace_event(stage="s", event="hidden", source="unit")
|
|
logger.info("visible lifecycle event")
|
|
|
|
rows = _json_log_rows(log_file)
|
|
assert [row["message"] for row in rows] == ["visible lifecycle event"]
|
|
|
|
|
|
def test_sanitize_masks_nested_api_key_strings() -> None:
|
|
"""Credential-shaped keys redact without touching normal message text."""
|
|
from free_claude_code.core.trace import sanitize_trace_value
|
|
|
|
out = sanitize_trace_value(
|
|
{"outer": {"api_key": "secret", "text": "visible"}},
|
|
)
|
|
assert out["outer"]["api_key"] == "<redacted>"
|
|
assert out["outer"]["text"] == "visible"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_traced_async_stream_logs_completion(tmp_path) -> None:
|
|
log_file = str(tmp_path / "complete.log")
|
|
configure_logging(log_file, force=True, level="DEBUG")
|
|
|
|
source = _CloseTrackingIterator(["hello", " world"])
|
|
|
|
chunks = [
|
|
chunk
|
|
async for chunk in traced_async_stream(
|
|
source,
|
|
stage="egress",
|
|
source="unit",
|
|
complete_event="stream.completed",
|
|
interrupted_event="stream.interrupted",
|
|
extra={"request_id": "req_complete"},
|
|
)
|
|
]
|
|
|
|
assert chunks == ["hello", " world"]
|
|
assert source.close_calls == 1
|
|
rows = _json_log_rows(log_file)
|
|
completed = [row for row in rows if row.get("event") == "stream.completed"]
|
|
assert len(completed) == 1
|
|
assert completed[0]["request_id"] == "req_complete"
|
|
assert completed[0]["stream_chunks"] == 2
|
|
assert completed[0]["outcome"] == "ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_traced_async_stream_logs_real_exception(tmp_path) -> None:
|
|
log_file = str(tmp_path / "error.log")
|
|
configure_logging(log_file, force=True, level="DEBUG")
|
|
|
|
source = _CloseTrackingIterator(
|
|
["before"],
|
|
iteration_error=RuntimeError("boom"),
|
|
close_error=RuntimeError("close boom"),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
async for _chunk in traced_async_stream(
|
|
source,
|
|
stage="egress",
|
|
source="unit",
|
|
complete_event="stream.completed",
|
|
interrupted_event="stream.interrupted",
|
|
extra={"request_id": "req_error"},
|
|
):
|
|
pass
|
|
|
|
assert source.close_calls == 1
|
|
|
|
rows = _json_log_rows(log_file)
|
|
interrupted = [row for row in rows if row.get("event") == "stream.interrupted"]
|
|
assert len(interrupted) == 1
|
|
assert interrupted[0]["request_id"] == "req_error"
|
|
assert interrupted[0]["stream_chunks"] == 1
|
|
assert interrupted[0]["outcome"] == "error"
|
|
assert interrupted[0]["exc_type"] == "RuntimeError"
|
|
close_failed = [
|
|
row for row in rows if row.get("event") == "stream.input.close_failed"
|
|
]
|
|
assert len(close_failed) == 1
|
|
assert close_failed[0]["owner"] == "traced_async_stream"
|
|
assert close_failed[0]["close_exc_type"] == "RuntimeError"
|
|
assert close_failed[0]["preserved_exc_type"] == "RuntimeError"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_traced_async_stream_closes_quietly_on_generator_exit(tmp_path) -> None:
|
|
log_file = str(tmp_path / "generator_exit.log")
|
|
configure_logging(log_file, force=True, level="DEBUG")
|
|
|
|
source = _CloseTrackingIterator(["first", "second"])
|
|
|
|
stream = traced_async_stream(
|
|
source,
|
|
stage="egress",
|
|
source="unit",
|
|
complete_event="stream.completed",
|
|
interrupted_event="stream.interrupted",
|
|
extra={"request_id": "req_closed"},
|
|
)
|
|
|
|
assert await anext(stream) == "first"
|
|
await stream.aclose()
|
|
|
|
assert source.close_calls == 1
|
|
rows = _json_log_rows(log_file)
|
|
events = {row.get("event") for row in rows}
|
|
assert "stream.completed" not in events
|
|
assert "stream.interrupted" not in events
|