"""Application logging (requirements §11).
Deliberately narrow: one rotating file handler, attached to the `trader`
logger and to every third-party logger this app is known to trigger, and one
helper that renders a config safely.
**Why third-party loggers get the same handler explicitly, rather than
relying on propagation.** `yfinance` logs through `logging.getLogger
('yfinance')` — a sibling of `"trader"`, not a child of it, so it never
inherited the handler attached here. With no handler anywhere in ITS
ancestor chain either (the root logger has none by default), those records
fell through to Python's built-in `logging.lastResort` handler: a bare
`StreamHandler(sys.stderr)` with no formatter, printing just the raw
message. Confirmed live, 2026-08-21: `daemon-error.log` filled with lines
like `HTTP Error 404: {"quoteSummary": ...}` — a real yfinance-logged ERROR
(an ETF with no fundamentals, an already-handled, expected case — see
`marketdata/analysts.py`) with none of the `%(asctime)s %(levelname)s
%(name)s` context every line in `trader.log` carries, making a deploy's
"check the error log" step impossible to read at a glance. `_THIRD_PARTY_
LOGGER_NAMES` is deliberately a short, explicit list — not the root logger —
so a future noisy DEBUG-level library does not silently start writing to
this file just because it happens to log through the standard module.
The helper is not a convenience. `EffectiveTradingConfig` marks its credential
fields `repr=False`, so `repr(config)` is safe — but `dataclasses.asdict()`
walks the fields directly and returns the secrets in clear text. Logging is the
first thing in this codebase that writes to a durable file, so the first
`logger.debug("config=%s", asdict(config))` would put a live Alpaca key on disk.
Log `safe_config_summary(config)` instead, never the config.
"""
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from pydantic import ValidationError
from sqlalchemy.engine import make_url
from trader.config.loader import EffectiveTradingConfig
from trader.errors import ConfigError
__all__ = ["LOGGER_NAME", "configure_logging", "safe_config_summary"]
LOGGER_NAME = "trader"
#: Third-party loggers known to emit records this app cares about seeing,
#: with no handler of their own. Each gets `configure_logging`'s handler
#: attached directly, same as `"trader"` — see the module docstring for why
#: this is an explicit list rather than the root logger.
_THIRD_PARTY_LOGGER_NAMES = ("yfinance",)
_DEFAULT_LOG_PATH = Path("logs/trader.log")
_MAX_BYTES = 5 * 1024 * 1024
_BACKUP_COUNT = 3
class _LazyDirRotatingFileHandler(RotatingFileHandler):
"""A `RotatingFileHandler` that creates its parent directory lazily.
Constructed with `delay=True`, the base class already defers opening the
file itself until the first record is emitted — so a process that
configures logging but never logs anything (e.g. printing `--help`)
never touches the filesystem. But `FileHandler.__init__` still resolves
`baseFilename` eagerly and nothing else creates the parent directory, so
without this override the *first* emit from a real run would fail with
`FileNotFoundError`. Overriding `_open()` — the base class's own hook for
"actually touch the filesystem now" — creates the directory at exactly
the moment the delayed open happens, so "never logs" still means "creates
nothing" and "does log" still works.
"""
def _open(self) -> object:
Path(self.baseFilename).parent.mkdir(parents=True, exist_ok=True)
return super()._open()
[docs]
def safe_config_summary(config: EffectiveTradingConfig) -> dict[str, str]:
"""Render a config for logging with the credentials removed.
Returns only the operational fields. `api_key` and `api_secret` are absent
by construction rather than masked, so a future field added to the config
cannot leak through this function by default.
"""
return {
"trading_mode": config.trading_mode.value,
"live_trading_enabled": str(config.live_trading_enabled),
# `render_as_string(hide_password=True)`, never a bare `str(...)` —
# `config.database_url` carries a real Postgres password (issue #26),
# and this function's whole contract is "credentials removed".
"database_url": make_url(config.database_url).render_as_string(
hide_password=True
),
# Whether `assert_live_orders_allowed()` would pass — which it does in
# paper mode, since the gate exists to stop *real-money* orders. Named
# `gate_passes` rather than `live_orders_allowed` because the latter
# reads as "this account trades live", which is a different question.
"gate_passes": str(not config.is_live or config.live_trading_enabled),
}