Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ DISCORD_TOKEN=your_bot_token_here

# Logging (optional)
# Levels: DEBUG, INFO, WARNING, ERROR
# Logs are plain single-line stdout.
LOG_LEVEL=INFO

# Channel ID for reminders (optional)
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ guild_config (
- `typer_bot/utils/config.py`: Centralized configuration (data paths via env vars).
- `typer_bot/utils/prediction_parser.py`: Central logic for parsing "2-1" or "2:1" strings.
- `typer_bot/utils/scoring.py`: Point calculation using season scoring rules.
- `typer_bot/utils/logger.py`: structured logging configuration for local and deployed environments.
- `typer_bot/utils/logger.py`: plain stdout logging setup with contextual fields.
- `typer_bot/utils/db_backup.py`: Automatic database backup after successful score calculation.
- `scripts/restore_db.py`: Manual database restore from a host or container shell.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ uv run python -m typer_bot

Disposable non-production deployments can auto-seed an empty database by setting `SEED_TEST_DATA=true` and `TEST_GUILD_ID`.

Logs are plain single-line stdout; set `LOG_LEVEL=DEBUG` when troubleshooting.

Run checks:

```bash
Expand Down
118 changes: 118 additions & 0 deletions tests/test_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Tests for runtime logging setup."""

import io
import logging
import re

import pytest

from typer_bot.utils import logger as logger_module


@pytest.fixture(autouse=True)
def restore_logging_state():
root_logger = logging.getLogger()
root_handlers = list(root_logger.handlers)
root_level = root_logger.level
discord_level = logging.getLogger("discord").level
discord_http_level = logging.getLogger("discord.http").level
trace_id = logger_module.get_trace_id()
log_context = logger_module.get_log_context()

yield

root_logger.handlers.clear()
root_logger.handlers.extend(root_handlers)
root_logger.setLevel(root_level)
logging.getLogger("discord").setLevel(discord_level)
logging.getLogger("discord.http").setLevel(discord_http_level)
logger_module.set_trace_id(trace_id)
logger_module.clear_log_context()
logger_module.set_log_context(**log_context)


def _configure_and_emit(monkeypatch, output: io.StringIO, logger_name: str = "test.logger") -> str:
monkeypatch.setattr(logger_module.sys, "stdout", output)

logger_module.setup_logging(logging.INFO)
logging.getLogger(logger_name).info("readable message")

return output.getvalue().splitlines()[-1]


def test_setup_logging_emits_plain_logs(monkeypatch):
output = io.StringIO()

log_line = _configure_and_emit(monkeypatch, output, "test.plain")

assert "\x1b[" not in log_line
assert not log_line.startswith("{")
assert re.match(r"^20\d{2}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00\s", log_line)
assert "INFO" in log_line
assert "test.plain" in log_line
assert "readable message" in log_line


def test_setup_logging_includes_context_and_extra_fields(monkeypatch):
output = io.StringIO()
monkeypatch.setattr(logger_module.sys, "stdout", output)

logger_module.set_trace_id("req-1")
logger_module.set_log_context(guild_id="guild-1")
logger_module.setup_logging(logging.INFO)
logging.getLogger("test.context").info(
"context message",
extra={
"event_type": "prediction.saved",
"error_detail": "Fixture not found",
"payload": {2: "second", "token": "secret-value", "safe": "visible"},
"token": "secret-value",
},
)

log_line = output.getvalue().splitlines()[-1]
assert "context message" in log_line
assert "secret-value" not in log_line
assert 'error_detail="Fixture not found"' in log_line
assert "event_type=prediction.saved" in log_line
assert "guild_id=guild-1" in log_line
assert "payload={2:second,safe:visible,token:[REDACTED]}" in log_line
assert "token=[REDACTED]" in log_line
assert "trace_id=req-1" in log_line


def test_setup_logging_uses_stdout(monkeypatch):
output = io.StringIO()

_configure_and_emit(monkeypatch, output)

assert "readable message" in output.getvalue()


def test_setup_logging_respects_level(monkeypatch):
output = io.StringIO()
monkeypatch.setattr(logger_module.sys, "stdout", output)

logger_module.setup_logging(logging.WARNING)
logging.getLogger("test.level").info("hidden message")
logging.getLogger("test.level").warning("visible message")

logged = output.getvalue()
assert "hidden message" not in logged
assert "WARNING" in logged
assert "visible message" in logged


def test_setup_logging_uses_log_level_env(monkeypatch):
output = io.StringIO()
monkeypatch.setattr(logger_module.sys, "stdout", output)
monkeypatch.setenv("LOG_LEVEL", "WARNING")

logger_module.setup_logging()
logging.getLogger("test.env_level").info("hidden message")
logging.getLogger("test.env_level").warning("visible message")

logged = output.getvalue()
assert "hidden message" not in logged
assert "WARNING" in logged
assert "visible message" in logged
Loading
Loading