From 2760a149269c429b6ef34208b7e2d063072abf0e Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Tue, 10 Mar 2026 18:23:18 -0500 Subject: [PATCH 1/2] feat: add OTLP logging export Adds OTLP logging handler that exports logs to OpenTelemetry collectors. Configured via MELLEA_LOGS_OTLP and OTEL_EXPORTER_OTLP_LOGS_ENDPOINT environment variables. Integrates with existing FancyLogger infrastructure. Signed-off-by: Alex Bozarth --- docs/dev/telemetry.md | 111 +++++++++++++++++- mellea/core/utils.py | 9 ++ mellea/telemetry/__init__.py | 20 +++- mellea/telemetry/logging.py | 130 +++++++++++++++++++++ test/telemetry/test_logging.py | 199 +++++++++++++++++++++++++++++++++ 5 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 mellea/telemetry/logging.py create mode 100644 test/telemetry/test_logging.py diff --git a/docs/dev/telemetry.md b/docs/dev/telemetry.md index 37485b41f..09fb342ae 100644 --- a/docs/dev/telemetry.md +++ b/docs/dev/telemetry.md @@ -52,6 +52,13 @@ Telemetry is configured via environment variables: | `MELLEA_METRICS_PROMETHEUS` | Enable Prometheus metric reader (registers with prometheus_client registry) | `false` | | `OTEL_METRIC_EXPORT_INTERVAL` | Export interval in milliseconds | `60000` | +#### Logging Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `MELLEA_LOGS_OTLP` | Enable OTLP logs exporter | `false` | +| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | OTLP logs-specific endpoint (overrides general endpoint) | None | + ### Application Trace Scope The application tracer (`mellea.application`) instruments: @@ -561,6 +568,105 @@ Enable at least one exporter: - OTLP: `export MELLEA_METRICS_OTLP=true` + endpoint - Prometheus: `export MELLEA_METRICS_PROMETHEUS=true` +### OTLP Logging Export + +Mellea's internal logging (via `FancyLogger`) can export logs to OTLP collectors for centralized log aggregation and analysis. This complements the existing tracing and metrics capabilities. + +**Note**: OTLP logging is **disabled by default** and requires explicit enablement. When disabled, Mellea's logging works normally (console + REST handlers) with zero overhead. + +#### Configuration + +Enable OTLP log export with environment variables: + +```bash +# Enable OTLP logs exporter +export MELLEA_LOGS_OTLP=true + +# Configure OTLP endpoint (required) +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 + +# Optional: Use logs-specific endpoint (overrides general endpoint) +export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://localhost:4318 + +# Optional: Set service name +export OTEL_SERVICE_NAME=my-mellea-app +``` + +#### How It Works + +When enabled, Mellea's `FancyLogger` adds an OpenTelemetry `LoggingHandler` alongside existing handlers: +- **Console handler**: Continues to work normally +- **REST handler**: Continues to work normally +- **OTLP handler**: Exports logs to configured OTLP collector (new) + +Logs are exported using OpenTelemetry's Logs API with batched processing for efficiency. + +#### OTLP Collector Setup Example + +```bash +# Create otel-collector-config.yaml +cat > otel-collector-config.yaml < Any: + """Set up the LoggerProvider with OTLP exporter. + + Returns: + LoggerProvider instance or None if OpenTelemetry is not available or no endpoint configured + """ + if not _OTEL_AVAILABLE: + return None + + if not _OTLP_LOGS_ENDPOINT: + warnings.warn( + "OTLP logs exporter is enabled (MELLEA_LOGS_OTLP=true) but no endpoint is configured. " + "Set OTEL_EXPORTER_OTLP_LOGS_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT to export logs.", + UserWarning, + stacklevel=2, + ) + return None + + resource = Resource.create({"service.name": _SERVICE_NAME}) # type: ignore + logger_provider = LoggerProvider(resource=resource) # type: ignore + + try: + otlp_exporter = OTLPLogExporter(endpoint=_OTLP_LOGS_ENDPOINT) # type: ignore + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(otlp_exporter) # type: ignore + ) + except Exception as e: + warnings.warn( + f"Failed to initialize OTLP logs exporter: {e}. " + "Logs will not be exported via OTLP.", + UserWarning, + stacklevel=2, + ) + return None + + set_logger_provider(logger_provider) # type: ignore + return logger_provider + + +# Initialize logger provider if OTLP logging is enabled +_logger_provider = None + +if _LOGS_OTLP: + _logger_provider = _setup_logger_provider() + + +def get_otlp_handler() -> Any: + """Get an OTLP logging handler for Python's logging module. + + Returns: + LoggingHandler instance if OTLP logging is enabled and configured, + None otherwise. + + Example: + import logging + from mellea.telemetry.logging import get_otlp_handler + + logger = logging.getLogger("my_app") + handler = get_otlp_handler() + if handler: + logger.addHandler(handler) + logger.info("This log will be exported via OTLP") + """ + if _logger_provider is None: + return None + + try: + handler = LoggingHandler(logger_provider=_logger_provider) # type: ignore + return handler + except Exception as e: + warnings.warn( + f"Failed to create OTLP logging handler: {e}. " + "Logs will not be exported via OTLP.", + UserWarning, + stacklevel=2, + ) + return None diff --git a/test/telemetry/test_logging.py b/test/telemetry/test_logging.py new file mode 100644 index 000000000..edd437418 --- /dev/null +++ b/test/telemetry/test_logging.py @@ -0,0 +1,199 @@ +"""Unit tests for OpenTelemetry logging instrumentation.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +# Check if OpenTelemetry is available +try: + from opentelemetry._logs import set_logger_provider + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter + from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler + + OTEL_AVAILABLE = True +except ImportError: + OTEL_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not OTEL_AVAILABLE, reason="OpenTelemetry not installed" +) + + +def _reset_logging_modules(): + """Helper to reset logging state and reload modules.""" + import importlib + import logging + + import mellea.core.utils + import mellea.telemetry.logging + from mellea.core.utils import FancyLogger + + # Clear any existing handlers from previous tests + fancy_logger = logging.getLogger("fancy_logger") + fancy_logger.handlers.clear() + + # Reset FancyLogger singleton + FancyLogger.logger = None + + # Force reload of logging module and core.utils to pick up env vars + importlib.reload(mellea.telemetry.logging) + importlib.reload(mellea.core.utils) + + +@pytest.fixture +def clean_logging_env(monkeypatch): + """Clean logging environment variables before each test.""" + monkeypatch.delenv("MELLEA_LOGS_OTLP", raising=False) + monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) + monkeypatch.delenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", raising=False) + monkeypatch.delenv("OTEL_SERVICE_NAME", raising=False) + + _reset_logging_modules() + yield + _reset_logging_modules() + + +@pytest.fixture +def enable_otlp_logging(monkeypatch): + """Enable OTLP logging with endpoint for tests.""" + monkeypatch.setenv("MELLEA_LOGS_OTLP", "true") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + + _reset_logging_modules() + yield + _reset_logging_modules() + + +# Configuration Tests + + +def test_otlp_logging_disabled_by_default(clean_logging_env): + """Test that OTLP logging is disabled by default.""" + from mellea.telemetry.logging import get_otlp_handler + + handler = get_otlp_handler() + assert handler is None + + +def test_otlp_logging_enabled_with_env_var(enable_otlp_logging): + """Test that OTLP logging can be enabled via environment variable.""" + from mellea.telemetry.logging import get_otlp_handler + + handler = get_otlp_handler() + assert handler is not None + assert isinstance(handler, LoggingHandler) # type: ignore + + +def test_otlp_logging_enabled_without_endpoint_warns(monkeypatch, clean_logging_env): + """Test that enabling OTLP without endpoint produces warning.""" + monkeypatch.setenv("MELLEA_LOGS_OTLP", "true") + # No endpoint set + + import importlib + + import mellea.telemetry.logging + + with pytest.warns(UserWarning, match="no endpoint is configured"): + importlib.reload(mellea.telemetry.logging) + + from mellea.telemetry.logging import get_otlp_handler + + handler = get_otlp_handler() + assert handler is None + + +def test_otlp_logging_with_various_truthy_values(monkeypatch, clean_logging_env): + """Test that various truthy values enable OTLP logging.""" + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + + for value in ["true", "True", "TRUE", "1", "yes", "Yes", "YES"]: + monkeypatch.setenv("MELLEA_LOGS_OTLP", value) + + import importlib + + import mellea.telemetry.logging + + importlib.reload(mellea.telemetry.logging) + + from mellea.telemetry.logging import get_otlp_handler + + handler = get_otlp_handler() + assert handler is not None, f"Failed for value: {value}" + + +def test_logs_specific_endpoint_takes_precedence(monkeypatch, clean_logging_env): + """Test that OTEL_EXPORTER_OTLP_LOGS_ENDPOINT takes precedence.""" + monkeypatch.setenv("MELLEA_LOGS_OTLP", "true") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "http://localhost:4318/logs") + + import importlib + + import mellea.telemetry.logging + + importlib.reload(mellea.telemetry.logging) + + # Verify the logs-specific endpoint is used + assert mellea.telemetry.logging._OTLP_LOGS_ENDPOINT == "http://localhost:4318/logs" + + +# Handler Integration Tests + + +def test_get_otlp_handler_can_be_added_to_logger(enable_otlp_logging): + """Test that OTLP handler can be added to a Python logger.""" + import logging + + from mellea.telemetry.logging import get_otlp_handler + + logger = logging.getLogger("test_logger") + handler = get_otlp_handler() + + assert handler is not None + logger.addHandler(handler) + + # Verify handler was added + assert handler in logger.handlers + + # Clean up + logger.removeHandler(handler) + + +# FancyLogger Integration Tests + + +def test_fancy_logger_includes_otlp_handler_when_enabled(enable_otlp_logging): + """Test that FancyLogger includes OTLP handler when enabled.""" + from mellea.core.utils import FancyLogger + + logger = FancyLogger.get_logger() + + # Check that logger has handlers + assert len(logger.handlers) > 0 + + # Check if any handler is a LoggingHandler (OTLP) + has_otlp_handler = any(isinstance(h, LoggingHandler) for h in logger.handlers) # type: ignore + assert has_otlp_handler, "FancyLogger should have OTLP handler when enabled" + + +def test_fancy_logger_works_without_otlp(clean_logging_env): + """Test that FancyLogger works normally when OTLP is disabled.""" + from mellea.core.utils import FancyLogger + + logger = FancyLogger.get_logger() + + # Should still have REST and console handlers + assert len(logger.handlers) >= 2 + + # Should not have OTLP handler + has_otlp_handler = any(isinstance(h, LoggingHandler) for h in logger.handlers) # type: ignore + assert not has_otlp_handler, ( + "FancyLogger should not have OTLP handler when disabled" + ) + + # Verify logger can log messages (backward compatibility) + logger.info("Test message") + + +# Made with Bob From 25b9ac7331276f87455fd71ccb9a425b6bc4787d Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Fri, 13 Mar 2026 09:27:15 -0500 Subject: [PATCH 2/2] refactor: rename get_otlp_handler to get_otlp_log_handler Renamed function for clarity to indicate it's specifically for log handling. Updated all references in telemetry module, core utils, and tests. Signed-off-by: Alex Bozarth --- mellea/core/utils.py | 4 ++-- mellea/telemetry/__init__.py | 8 ++++---- mellea/telemetry/logging.py | 10 +++++----- test/telemetry/test_logging.py | 25 +++++++++++-------------- 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/mellea/core/utils.py b/mellea/core/utils.py index eafe40671..22286a439 100644 --- a/mellea/core/utils.py +++ b/mellea/core/utils.py @@ -126,9 +126,9 @@ def get_logger(): logger.addHandler(stream_handler) # Add OTLP handler if enabled - from ..telemetry import get_otlp_handler + from ..telemetry import get_otlp_log_handler - otlp_handler = get_otlp_handler() + otlp_handler = get_otlp_log_handler() if otlp_handler: otlp_handler.setFormatter(JsonFormatter()) logger.addHandler(otlp_handler) diff --git a/mellea/telemetry/__init__.py b/mellea/telemetry/__init__.py index 1a4839f16..3f5dc0072 100644 --- a/mellea/telemetry/__init__.py +++ b/mellea/telemetry/__init__.py @@ -42,7 +42,7 @@ are gracefully disabled. Install with: pip install mellea[telemetry] Example: - from mellea.telemetry import trace_application, create_counter, get_otlp_handler + from mellea.telemetry import trace_application, create_counter, get_otlp_log_handler import logging # Trace application operations @@ -56,12 +56,12 @@ def my_function(): # Export logs via OTLP logger = logging.getLogger("my_app") - handler = get_otlp_handler() + handler = get_otlp_log_handler() if handler: logger.addHandler(handler) """ -from .logging import get_otlp_handler +from .logging import get_otlp_log_handler from .metrics import ( create_counter, create_histogram, @@ -85,7 +85,7 @@ def my_function(): "create_histogram", "create_up_down_counter", "end_backend_span", - "get_otlp_handler", + "get_otlp_log_handler", "is_application_tracing_enabled", "is_backend_tracing_enabled", "is_metrics_enabled", diff --git a/mellea/telemetry/logging.py b/mellea/telemetry/logging.py index 0ac0da289..0fea4b6c0 100644 --- a/mellea/telemetry/logging.py +++ b/mellea/telemetry/logging.py @@ -13,11 +13,11 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 Programmatic usage: - from mellea.telemetry.logging import get_otlp_handler + from mellea.telemetry.logging import get_otlp_log_handler import logging logger = logging.getLogger("my_logger") - handler = get_otlp_handler() + handler = get_otlp_log_handler() if handler: logger.addHandler(handler) logger.info("This log will be exported via OTLP") @@ -97,7 +97,7 @@ def _setup_logger_provider() -> Any: _logger_provider = _setup_logger_provider() -def get_otlp_handler() -> Any: +def get_otlp_log_handler() -> Any: """Get an OTLP logging handler for Python's logging module. Returns: @@ -106,10 +106,10 @@ def get_otlp_handler() -> Any: Example: import logging - from mellea.telemetry.logging import get_otlp_handler + from mellea.telemetry.logging import get_otlp_log_handler logger = logging.getLogger("my_app") - handler = get_otlp_handler() + handler = get_otlp_log_handler() if handler: logger.addHandler(handler) logger.info("This log will be exported via OTLP") diff --git a/test/telemetry/test_logging.py b/test/telemetry/test_logging.py index edd437418..595def8c0 100644 --- a/test/telemetry/test_logging.py +++ b/test/telemetry/test_logging.py @@ -70,17 +70,17 @@ def enable_otlp_logging(monkeypatch): def test_otlp_logging_disabled_by_default(clean_logging_env): """Test that OTLP logging is disabled by default.""" - from mellea.telemetry.logging import get_otlp_handler + from mellea.telemetry.logging import get_otlp_log_handler - handler = get_otlp_handler() + handler = get_otlp_log_handler() assert handler is None def test_otlp_logging_enabled_with_env_var(enable_otlp_logging): """Test that OTLP logging can be enabled via environment variable.""" - from mellea.telemetry.logging import get_otlp_handler + from mellea.telemetry.logging import get_otlp_log_handler - handler = get_otlp_handler() + handler = get_otlp_log_handler() assert handler is not None assert isinstance(handler, LoggingHandler) # type: ignore @@ -97,9 +97,9 @@ def test_otlp_logging_enabled_without_endpoint_warns(monkeypatch, clean_logging_ with pytest.warns(UserWarning, match="no endpoint is configured"): importlib.reload(mellea.telemetry.logging) - from mellea.telemetry.logging import get_otlp_handler + from mellea.telemetry.logging import get_otlp_log_handler - handler = get_otlp_handler() + handler = get_otlp_log_handler() assert handler is None @@ -116,9 +116,9 @@ def test_otlp_logging_with_various_truthy_values(monkeypatch, clean_logging_env) importlib.reload(mellea.telemetry.logging) - from mellea.telemetry.logging import get_otlp_handler + from mellea.telemetry.logging import get_otlp_log_handler - handler = get_otlp_handler() + handler = get_otlp_log_handler() assert handler is not None, f"Failed for value: {value}" @@ -141,14 +141,14 @@ def test_logs_specific_endpoint_takes_precedence(monkeypatch, clean_logging_env) # Handler Integration Tests -def test_get_otlp_handler_can_be_added_to_logger(enable_otlp_logging): +def test_get_otlp_log_handler_can_be_added_to_logger(enable_otlp_logging): """Test that OTLP handler can be added to a Python logger.""" import logging - from mellea.telemetry.logging import get_otlp_handler + from mellea.telemetry.logging import get_otlp_log_handler logger = logging.getLogger("test_logger") - handler = get_otlp_handler() + handler = get_otlp_log_handler() assert handler is not None logger.addHandler(handler) @@ -194,6 +194,3 @@ def test_fancy_logger_works_without_otlp(clean_logging_env): # Verify logger can log messages (backward compatibility) logger.info("Test message") - - -# Made with Bob