diff --git a/docs/development.md b/docs/development.md index ef66219b..430cd87d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -51,7 +51,7 @@ codegate/ │ ├── cli.py # Command-line interface │ ├── config.py # Configuration management │ ├── exceptions.py # Shared exceptions -│ ├── logging.py # Logging setup +│ ├── codegate_logging.py # Logging setup │ ├── prompts.py # Prompts management │ ├── server.py # Main server implementation │ └── providers/ # External service providers diff --git a/docs/logging.md b/docs/logging.md index e1e54e80..885a6d7e 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -1,6 +1,6 @@ # Logging System -The logging system in Codegate (`logging.py`) provides a flexible and structured logging solution with support for both JSON and text formats. +The logging system in Codegate (`codegate_logging.py`) provides a flexible and structured logging solution with support for both JSON and text formats. ## Log Routing @@ -18,9 +18,9 @@ When using JSON format (default), log entries include: ```json { "timestamp": "YYYY-MM-DDThh:mm:ss.mmmZ", - "level": "LOG_LEVEL", + "log_level": "LOG_LEVEL", "module": "MODULE_NAME", - "message": "Log message", + "event": "Log message", "extra": { // Additional fields as you desire } @@ -49,9 +49,9 @@ YYYY-MM-DDThh:mm:ss.mmmZ - LEVEL - NAME - MESSAGE ### Basic Logging ```python -import logging +import structlog -logger = logging.getLogger(__name__) +logger = structlog.get_logger(__name__) # Different log levels logger.info("This is an info message") diff --git a/poetry.lock b/poetry.lock index 83d082b5..66a75b80 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -2768,6 +2768,23 @@ files = [ [package.dependencies] pbr = ">=2.0.0" +[[package]] +name = "structlog" +version = "24.4.0" +description = "Structured Logging for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "structlog-24.4.0-py3-none-any.whl", hash = "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610"}, + {file = "structlog-24.4.0.tar.gz", hash = "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4"}, +] + +[package.extras] +dev = ["freezegun (>=0.2.8)", "mypy (>=1.4)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "rich", "simplejson", "twisted"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"] +tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] +typing = ["mypy (>=1.4)", "rich", "twisted"] + [[package]] name = "sympy" version = "1.13.1" @@ -3349,4 +3366,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.11" -content-hash = "40b22a2043a7302787c57a1bdaa5f1f416e9959b7815cffc39fbb506e4d84541" +content-hash = "91b84718c2ece34bca2a3437b0215f5010d0bda333cf827253e1435d9f93af30" diff --git a/pyproject.toml b/pyproject.toml index b6f5567f..5d6ed018 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ weaviate = ">=0.1.2" weaviate-client = ">=4.9.3" torch = ">=2.5.1" transformers = ">=4.46.3" - +structlog = ">=24.4.0" litellm = "^1.52.16" llama_cpp_python = ">=0.3.2" diff --git a/src/codegate/cli.py b/src/codegate/cli.py index b6911bfe..b90d501e 100644 --- a/src/codegate/cli.py +++ b/src/codegate/cli.py @@ -5,6 +5,7 @@ from typing import Dict, Optional import click +import structlog from codegate.codegate_logging import LogFormat, LogLevel, setup_logging from codegate.config import Config, ConfigurationError @@ -140,7 +141,8 @@ def serve( cli_provider_urls=cli_provider_urls, ) - logger = setup_logging(cfg.log_level, cfg.log_format) + setup_logging(cfg.log_level, cfg.log_format) + logger = structlog.get_logger("codegate") logger.info( "Starting server", extra={ diff --git a/src/codegate/codegate_logging.py b/src/codegate/codegate_logging.py index a57a1579..80ff53ca 100644 --- a/src/codegate/codegate_logging.py +++ b/src/codegate/codegate_logging.py @@ -1,9 +1,9 @@ -import datetime -import json import logging import sys from enum import Enum -from typing import Any, Optional +from typing import Optional + +import structlog class LogLevel(str, Enum): @@ -46,123 +46,6 @@ def _missing_(cls, value: str) -> Optional["LogFormat"]: ) -class JSONFormatter(logging.Formatter): - """Custom formatter that outputs log records as JSON.""" - - def __init__(self) -> None: - """Initialize the JSON formatter.""" - super().__init__() - self.default_time_format = "%Y-%m-%dT%H:%M:%S" - self.default_msec_format = "%s.%03dZ" - - def format(self, record: logging.LogRecord) -> str: - """Format the log record as a JSON string. - - Args: - record: The log record to format - - Returns: - str: JSON formatted log entry - """ - # Create the base log entry - log_entry: dict[str, Any] = { - "timestamp": self.formatTime(record, self.default_time_format), - "level": record.levelname, - "module": record.module, - "message": record.getMessage(), - "extra": {}, - } - - # Add extra fields from the record - extra_attrs = {} - for key, value in record.__dict__.items(): - if key not in { - "args", - "asctime", - "created", - "exc_info", - "exc_text", - "filename", - "funcName", - "levelname", - "levelno", - "lineno", - "module", - "msecs", - "msg", - "name", - "pathname", - "process", - "processName", - "relativeCreated", - "stack_info", - "thread", - "threadName", - "extra", - }: - extra_attrs[key] = value - - # Handle the explicit extra parameter if present - if hasattr(record, "extra"): - try: - if isinstance(record.extra, dict): - extra_attrs.update(record.extra) - except Exception: - extra_attrs["unserializable_extra"] = str(record.extra) - - # Add all extra attributes to the log entry - if extra_attrs: - try: - json.dumps(extra_attrs) # Test if serializable - log_entry["extra"] = extra_attrs - except (TypeError, ValueError): - # If serialization fails, convert values to strings - serializable_extra = {} - for key, value in extra_attrs.items(): - try: - json.dumps({key: value}) # Test individual value - serializable_extra[key] = value - except (TypeError, ValueError): - serializable_extra[key] = str(value) - log_entry["extra"] = serializable_extra - - # Handle exception info if present - if record.exc_info: - log_entry["extra"]["exception"] = self.formatException(record.exc_info) - - # Handle stack info if present - if record.stack_info: - log_entry["extra"]["stack_info"] = self.formatStack(record.stack_info) - - return json.dumps(log_entry) - - -class TextFormatter(logging.Formatter): - """Standard text formatter with consistent timestamp format.""" - - def __init__(self) -> None: - """Initialize the text formatter.""" - super().__init__( - fmt="%(asctime)s - %(levelname)s - %(name)s - %(message)s", - datefmt="%Y-%m-%dT%H:%M:%S.%03dZ", - ) - - def formatTime( # noqa: N802 - self, record: logging.LogRecord, datefmt: Optional[str] = None - ) -> str: - """Format the time with millisecond precision. - - Args: - record: The log record - datefmt: The date format string (ignored as we use a fixed format) - - Returns: - str: Formatted timestamp - """ - ct = datetime.datetime.fromtimestamp(record.created, datetime.UTC) - return ct.strftime(self.datefmt) - - def setup_logging( log_level: Optional[LogLevel] = None, log_format: Optional[LogFormat] = None ) -> logging.Logger: @@ -181,10 +64,52 @@ def setup_logging( if log_format is None: log_format = LogFormat.JSON - # Create formatters - json_formatter = JSONFormatter() - text_formatter = TextFormatter() - formatter = json_formatter if log_format == LogFormat.JSON else text_formatter + # The configuration was taken from structlog documentation + # https://www.structlog.org/en/stable/standard-library.html + # Specifically the section "Rendering Using structlog-based Formatters Within logging" + + # Adds log level and timestamp to log entries + shared_processors = [ + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="%Y-%m-%dT%H:%M:%S.%03dZ", utc=True), + structlog.processors.CallsiteParameterAdder( + [ + structlog.processors.CallsiteParameter.MODULE, + ] + ), + ] + # Not sure why this is needed. I think it is a wrapper for the standard logging module. + # Should allow to log both with structlog and the standard logging module: + # import logging + # import structlog + # logging.getLogger("stdlog").info("woo") + # structlog.get_logger("structlog").info("amazing", events="oh yes") + structlog.configure( + processors=shared_processors + + [ + # Prepare event dict for `ProcessorFormatter`. + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) + + # The config aboves adds the following keys to all log entries: _record & _from_structlog. + # remove_processors_meta removes them. + processors = shared_processors + [structlog.stdlib.ProcessorFormatter.remove_processors_meta] + # Choose the processors based on the log format + if log_format == LogFormat.JSON: + processors = processors + [ + structlog.processors.dict_tracebacks, + structlog.processors.JSONRenderer(), + ] + else: + processors = processors + [structlog.dev.ConsoleRenderer()] + formatter = structlog.stdlib.ProcessorFormatter( + # foreign_pre_chain run ONLY on `logging` entries that do NOT originate within structlog. + foreign_pre_chain=shared_processors, + processors=processors, + ) # Create handlers for stdout and stderr stdout_handler = logging.StreamHandler(sys.stdout) @@ -208,7 +133,7 @@ def setup_logging( root_logger.addHandler(stderr_handler) # Create a logger for our package - logger = logging.getLogger("codegate") + logger = structlog.get_logger("codegate") logger.debug( "Logging initialized", extra={ @@ -217,5 +142,3 @@ def setup_logging( "handlers": ["stdout", "stderr"], }, ) - - return logger diff --git a/src/codegate/config.py b/src/codegate/config.py index 1f304227..c88fc4d0 100644 --- a/src/codegate/config.py +++ b/src/codegate/config.py @@ -5,13 +5,14 @@ from pathlib import Path from typing import Dict, Optional, Union +import structlog import yaml -from codegate.codegate_logging import LogFormat, LogLevel, setup_logging +from codegate.codegate_logging import LogFormat, LogLevel from codegate.exceptions import ConfigurationError from codegate.prompts import PromptConfig -logger = setup_logging() +logger = structlog.get_logger("codegate") # Default provider URLs DEFAULT_PROVIDER_URLS = { diff --git a/src/codegate/inference/inference_engine.py b/src/codegate/inference/inference_engine.py index 74d2808c..f9ae59b1 100644 --- a/src/codegate/inference/inference_engine.py +++ b/src/codegate/inference/inference_engine.py @@ -1,6 +1,7 @@ +import structlog from llama_cpp import Llama -from codegate.codegate_logging import setup_logging +logger = structlog.get_logger("codegate") class LlamaCppInferenceEngine: @@ -21,7 +22,6 @@ def __new__(cls): def __init__(self): if not hasattr(self, "models"): self.__models = {} - self.__logger = setup_logging() def __del__(self): self.__close_models() @@ -32,7 +32,7 @@ async def __get_model(self, model_path, embedding=False, n_ctx=512, n_gpu_layers is loaded and added to __models and returned. """ if model_path not in self.__models: - self.__logger.info( + logger.info( f"Loading model from {model_path} with parameters " f"n_gpu_layers={n_gpu_layers} and n_ctx={n_ctx}" ) diff --git a/src/codegate/pipeline/fim/secret_analyzer.py b/src/codegate/pipeline/fim/secret_analyzer.py index 68f38351..3c7ce0dd 100644 --- a/src/codegate/pipeline/fim/secret_analyzer.py +++ b/src/codegate/pipeline/fim/secret_analyzer.py @@ -1,9 +1,9 @@ +import structlog from litellm import ChatCompletionRequest -from codegate.codegate_logging import setup_logging from codegate.pipeline.base import PipelineContext, PipelineResponse, PipelineResult, PipelineStep -logger = setup_logging() +logger = structlog.get_logger("codegate") class SecretAnalyzer(PipelineStep): diff --git a/src/codegate/pipeline/secrets/secrets.py b/src/codegate/pipeline/secrets/secrets.py index 45bfb1aa..f9c3d0df 100644 --- a/src/codegate/pipeline/secrets/secrets.py +++ b/src/codegate/pipeline/secrets/secrets.py @@ -1,8 +1,8 @@ import re +import structlog from litellm import ChatCompletionRequest -from codegate.codegate_logging import setup_logging from codegate.pipeline.base import ( PipelineContext, PipelineResult, @@ -11,6 +11,8 @@ from codegate.pipeline.secrets.gatecrypto import CodeGateCrypto from codegate.pipeline.secrets.signatures import CodegateSignatures +logger = structlog.get_logger("codegate") + class CodegateSecrets(PipelineStep): """Pipeline step that handles secret information requests.""" @@ -21,7 +23,6 @@ def __init__(self): self.crypto = CodeGateCrypto() self._session_store = {} self._encrypted_to_session = {} # Reverse lookup index - self.__logger = setup_logging() @property def name(self) -> str: @@ -87,7 +88,7 @@ def _redeact_text(self, text: str) -> str: if not matches: return text - self.__logger.debug(f"Found {len(matches)} secrets in the user message") + logger.debug(f"Found {len(matches)} secrets in the user message") # Convert line positions to absolute positions and extend boundaries absolute_matches = [] @@ -133,7 +134,7 @@ def _redeact_text(self, text: str) -> str: self._encrypted_to_session[encrypted_value] = session_id # Print the session store - self.__logger.info(f"Session store: {self._session_store}") + logger.info(f"Session store: {self._session_store}") # Create the replacement string replacement = f"REDACTED<${encrypted_value}>" @@ -155,12 +156,12 @@ def _redeact_text(self, text: str) -> str: protected_string = "".join(protected_text) # Log the findings - self.__logger.info("\nFound secrets:") + logger.info("\nFound secrets:") for secret in found_secrets: - self.__logger.info(f"\nService: {secret['service']}") - self.__logger.info(f"Type: {secret['type']}") - self.__logger.info(f"Original: {secret['original']}") - self.__logger.info(f"Encrypted: REDACTED<${secret['encrypted']}>") + logger.info(f"\nService: {secret['service']}") + logger.info(f"Type: {secret['type']}") + logger.info(f"Original: {secret['original']}") + logger.info(f"Encrypted: REDACTED<${secret['encrypted']}>") (f"\nProtected text:\n{protected_string}") return protected_string @@ -181,7 +182,7 @@ def _get_original_value(self, encrypted_value: str) -> str: if session_id: return self._session_store[session_id]["original"] except Exception as e: - self.__logger.error(f"Error looking up original value: {e}") + logger.error(f"Error looking up original value: {e}") return encrypted_value def get_by_session_id(self, session_id: str) -> dict | None: @@ -197,7 +198,7 @@ def get_by_session_id(self, session_id: str) -> dict | None: try: return self._session_store.get(session_id) except Exception as e: - self.__logger.error(f"Error looking up by session ID: {e}") + logger.error(f"Error looking up by session ID: {e}") return None def _cleanup_session_store(self): @@ -215,9 +216,9 @@ def _cleanup_session_store(self): self._session_store.clear() self._encrypted_to_session.clear() - self.__logger.info("Session stores securely wiped") + logger.info("Session stores securely wiped") except Exception as e: - self.__logger.error(f"Error during secure cleanup: {e}") + logger.error(f"Error during secure cleanup: {e}") def _unredact_text(self, protected_text: str) -> str: """ @@ -253,7 +254,7 @@ def _unredact_text(self, protected_text: str) -> str: # Join all parts together unprotected_text = "".join(result) - self.__logger.info(f"\nUnprotected text:\n{unprotected_text}") + logger.info(f"\nUnprotected text:\n{unprotected_text}") return unprotected_text async def process( @@ -293,7 +294,7 @@ async def process( return PipelineResult(request=request) except Exception as e: - self.__logger.error(f"CodegateSecrets operation failed: {e}") + logger.error(f"CodegateSecrets operation failed: {e}") finally: # Clean up sensitive data diff --git a/src/codegate/pipeline/secrets/signatures.py b/src/codegate/pipeline/secrets/signatures.py index ce0d3cf0..0f96423b 100644 --- a/src/codegate/pipeline/secrets/signatures.py +++ b/src/codegate/pipeline/secrets/signatures.py @@ -1,14 +1,13 @@ # signatures.py -import logging import re from pathlib import Path from threading import Lock from typing import ClassVar, Dict, List, NamedTuple, Optional +import structlog import yaml -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +logger = structlog.get_logger("codegate") class Match(NamedTuple): diff --git a/src/codegate/providers/base.py b/src/codegate/providers/base.py index 6710528f..7a597dca 100644 --- a/src/codegate/providers/base.py +++ b/src/codegate/providers/base.py @@ -1,17 +1,18 @@ from abc import ABC, abstractmethod from typing import Any, AsyncIterator, Callable, Dict, Optional, Union +import structlog from fastapi import APIRouter, Request from litellm import ModelResponse from litellm.types.llms.openai import ChatCompletionRequest -from codegate.codegate_logging import setup_logging from codegate.pipeline.base import PipelineResult, SequentialPipelineProcessor from codegate.providers.completion.base import BaseCompletionHandler from codegate.providers.formatting.input_pipeline import PipelineResponseFormatter from codegate.providers.normalizer.base import ModelInputNormalizer, ModelOutputNormalizer -logger = setup_logging() +logger = structlog.get_logger("codegate") + StreamGenerator = Callable[[AsyncIterator[Any]], AsyncIterator[str]] diff --git a/tests/test_cli.py b/tests/test_cli.py index 64f8a53e..e5ed7e98 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,6 +20,14 @@ def cli_runner() -> CliRunner: @pytest.fixture def mock_logging(monkeypatch: Any) -> MagicMock: + """Mock the logging function.""" + mock = MagicMock() + monkeypatch.setattr("codegate.cli.structlog.get_logger", mock) + return mock + + +@pytest.fixture +def mock_setup_logging(monkeypatch: Any) -> MagicMock: """Mock the setup_logging function.""" mock = MagicMock() monkeypatch.setattr("codegate.cli.setup_logging", mock) @@ -47,7 +55,9 @@ def test_cli_version(cli_runner: CliRunner) -> None: assert result.exit_code == 0 -def test_serve_default_options(cli_runner: CliRunner, mock_logging: Any) -> None: +def test_serve_default_options( + cli_runner: CliRunner, mock_logging: Any, mock_setup_logging: Any +) -> None: """Test serve command with default options.""" with patch("uvicorn.run") as mock_run: logger_instance = MagicMock() @@ -55,7 +65,8 @@ def test_serve_default_options(cli_runner: CliRunner, mock_logging: Any) -> None result = cli_runner.invoke(cli, ["serve"]) assert result.exit_code == 0 - mock_logging.assert_called_once_with(LogLevel.INFO, LogFormat.JSON) + mock_setup_logging.assert_called_once_with(LogLevel.INFO, LogFormat.JSON) + mock_logging.assert_called_with("codegate") logger_instance.info.assert_any_call( "Starting server", extra={ @@ -70,7 +81,9 @@ def test_serve_default_options(cli_runner: CliRunner, mock_logging: Any) -> None mock_run.assert_called_once() -def test_serve_custom_options(cli_runner: CliRunner, mock_logging: Any) -> None: +def test_serve_custom_options( + cli_runner: CliRunner, mock_logging: Any, mock_setup_logging: Any +) -> None: """Test serve command with custom options.""" with patch("uvicorn.run") as mock_run: logger_instance = MagicMock() @@ -91,7 +104,8 @@ def test_serve_custom_options(cli_runner: CliRunner, mock_logging: Any) -> None: ) assert result.exit_code == 0 - mock_logging.assert_called_once_with(LogLevel.DEBUG, LogFormat.TEXT) + mock_setup_logging.assert_called_once_with(LogLevel.DEBUG, LogFormat.TEXT) + mock_logging.assert_called_with("codegate") logger_instance.info.assert_any_call( "Starting server", extra={ @@ -121,7 +135,7 @@ def test_serve_invalid_log_level(cli_runner: CliRunner) -> None: def test_serve_with_config_file( - cli_runner: CliRunner, mock_logging: Any, temp_config_file: Path + cli_runner: CliRunner, mock_logging: Any, temp_config_file: Path, mock_setup_logging: Any ) -> None: """Test serve command with config file.""" with patch("uvicorn.run") as mock_run: @@ -130,7 +144,8 @@ def test_serve_with_config_file( result = cli_runner.invoke(cli, ["serve", "--config", str(temp_config_file)]) assert result.exit_code == 0 - mock_logging.assert_called_once_with(LogLevel.DEBUG, LogFormat.JSON) + mock_setup_logging.assert_called_once_with(LogLevel.DEBUG, LogFormat.JSON) + mock_logging.assert_called_with("codegate") logger_instance.info.assert_any_call( "Starting server", extra={ @@ -153,7 +168,11 @@ def test_serve_with_nonexistent_config_file(cli_runner: CliRunner) -> None: def test_serve_priority_resolution( - cli_runner: CliRunner, mock_logging: Any, temp_config_file: Path, env_vars: Any + cli_runner: CliRunner, + mock_logging: Any, + temp_config_file: Path, + env_vars: Any, + mock_setup_logging: Any, ) -> None: """Test serve command respects configuration priority.""" with patch("uvicorn.run") as mock_run: @@ -177,7 +196,8 @@ def test_serve_priority_resolution( ) assert result.exit_code == 0 - mock_logging.assert_called_once_with(LogLevel.ERROR, LogFormat.TEXT) + mock_setup_logging.assert_called_once_with(LogLevel.ERROR, LogFormat.TEXT) + mock_logging.assert_called_with("codegate") logger_instance.info.assert_any_call( "Starting server", extra={ diff --git a/tests/test_logging.py b/tests/test_logging.py index d2160de9..81bf3011 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,82 +1,38 @@ -import json import logging from io import StringIO +import structlog +from structlog.testing import capture_logs + from codegate.codegate_logging import ( - JSONFormatter, LogFormat, LogLevel, - TextFormatter, setup_logging, ) -def test_json_formatter(): - log_record = logging.LogRecord( - name="test", - level=logging.INFO, - pathname=__file__, - lineno=10, - msg="Test message", - args=(), - exc_info=None, - ) - formatter = JSONFormatter() - formatted_log = formatter.format(log_record) - log_entry = json.loads(formatted_log) - - assert log_entry["level"] == "INFO" - assert log_entry["module"] == "test_logging" - assert log_entry["message"] == "Test message" - assert "timestamp" in log_entry - assert "extra" in log_entry - - -def test_text_formatter(): - log_record = logging.LogRecord( - name="test", - level=logging.INFO, - pathname=__file__, - lineno=10, - msg="Test message", - args=(), - exc_info=None, - ) - formatter = TextFormatter() - formatted_log = formatter.format(log_record) - - assert "INFO" in formatted_log - assert "test" in formatted_log - assert "Test message" in formatted_log - - -def test_setup_logging_json_format(): +def test_setup_logging(): setup_logging(log_level=LogLevel.DEBUG, log_format=LogFormat.JSON) - logger = logging.getLogger("codegate") - log_output = StringIO() - handler = logging.StreamHandler(log_output) - handler.setFormatter(JSONFormatter()) - logger.addHandler(handler) - logger.debug("Debug message") - log_output.seek(0) - log_entry = json.loads(log_output.getvalue().strip()) + with capture_logs() as cap_logs: + logger = structlog.get_logger("codegate") + logger.debug("Debug message") + + # cap_logs is a dictionary with the list of log entries + log_entry = cap_logs[0] - assert log_entry["level"] == "DEBUG" - assert log_entry["message"] == "Debug message" + assert log_entry["log_level"] == "debug" + assert log_entry["event"] == "Debug message" -def test_setup_logging_text_format(): +def test_logging_stream_output(): setup_logging(log_level=LogLevel.DEBUG, log_format=LogFormat.TEXT) logger = logging.getLogger("codegate") log_output = StringIO() handler = logging.StreamHandler(log_output) - handler.setFormatter(TextFormatter()) logger.addHandler(handler) logger.debug("Debug message") log_output.seek(0) formatted_log = log_output.getvalue().strip() - - assert "DEBUG" in formatted_log assert "Debug message" in formatted_log