diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 757c5a44..40109fe0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -98,7 +98,7 @@ jobs: needs: check-static-and-test-status - if: contains(github.ref, 'tags/v') + if: contains(github.ref, 'tags/v') # both v1.x.x & v2.X.X runs-on: ubuntu-latest @@ -124,6 +124,8 @@ jobs: docs: name: Build and publish docs + if: startsWith(github.ref, 'tags/v2.') # Only for version 2.X.X + needs: deploy runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cb7c28f..fab87934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] + +## [2.0.1] - 2025-10-15 +### Fixed +- Logging duplication on CloudWatch side + + ## [2.0.0] - 2025-09-11 ### Changed - Full migration from old `v1`of `pydantic` to `v2`dependencies: diff --git a/docs/antora-playbook.yml b/docs/antora-playbook.yml index 42354400..30a2d678 100644 --- a/docs/antora-playbook.yml +++ b/docs/antora-playbook.yml @@ -7,7 +7,7 @@ content: start_path: docs branches: [] # branches: HEAD # Use this for local development - tags: [v2.0.0] + tags: [v2.0.1] asciidoc: attributes: page-toclevels: 5 diff --git a/docs/antora.yml b/docs/antora.yml index da9cec15..70550115 100644 --- a/docs/antora.yml +++ b/docs/antora.yml @@ -1,3 +1,3 @@ name: corva-sdk -version: ~ +version: 2.0.1 nav: [modules/ROOT/nav.adoc] diff --git a/pyproject.toml b/pyproject.toml index 19dcb57c..7483f652 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "corva-sdk" description = "SDK for building Corva DevCenter Python apps." readme = "README.md" -version = "2.0.0" +version = "2.0.1" license = { text = "The Unlicense" } authors = [ { name = "Jordan Ambra", email = "jordan.ambra@corva.ai" } @@ -14,13 +14,13 @@ authors = [ keywords = ["corva", "sdk"] requires-python = ">=3.9,<4.0" dependencies = [ - "fakeredis >=2.30.0, <2.32.0", + "fakeredis >= 2.30.0, <2.32.0", "pydantic >= 2.0, <3.0", - "pydantic-settings >=2.0, <3.0", + "pydantic-settings >= 2.0, <3.0", "redis == 6.4.0", - "requests >=2.32.3, <3.0.0", + "requests >= 2.32.3, <3.0.0", "urllib3 == 2.5.0", - "semver==3.0.4" + "semver == 3.0.4" ] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/src/corva/configuration.py b/src/corva/configuration.py index 41ce9fe1..6d9e11a3 100644 --- a/src/corva/configuration.py +++ b/src/corva/configuration.py @@ -43,5 +43,8 @@ class Settings(pydantic_settings.BaseSettings): MAX_RETRY_COUNT: int = 3 # If `0` then retries will be disabled BACKOFF_FACTOR: float = 1.0 + # OTEL + OTEL_LOG_SENDING_DISABLED: bool = False + SETTINGS = Settings() # type: ignore[call-arg] diff --git a/src/corva/handlers.py b/src/corva/handlers.py index 9433f352..862eeb86 100644 --- a/src/corva/handlers.py +++ b/src/corva/handlers.py @@ -372,7 +372,7 @@ def wrapper( api_key: str, aws_request_id: str, logging_ctx: LoggingContext, - redis_client: Optional[redis.Redis] = None, # noqa, for safe reasons + redis_client: Optional[redis.Redis] = None, # noqa ) -> Any: status = TaskStatus.fail data: Dict[str, Union[dict, str]] = {"payload": {}} diff --git a/src/corva/logger.py b/src/corva/logger.py index fff95d12..1857b2b2 100644 --- a/src/corva/logger.py +++ b/src/corva/logger.py @@ -1,8 +1,8 @@ import contextlib import logging -import logging.config import sys import time +from contextlib import suppress from typing import Optional from corva.configuration import SETTINGS @@ -14,10 +14,37 @@ logging.getLogger("urllib3.connectionpool").setLevel(SETTINGS.LOG_LEVEL) -# unset to pass messages to ancestor loggers, including OTel Log Sending handler +# Disable propagation to avoid duplicate CloudWatch logs from AWS Lambda's +# root handler. We will explicitly attach any OTel log handlers directly +# to CORVA_LOGGER within LoggingContext when log sending is enabled. # see https://github.com/corva-ai/otel/pull/37 # see https://corvaqa.atlassian.net/browse/EE-31 -# CORVA_LOGGER.propagate = False +CORVA_LOGGER.propagate = False + + +def _is_otel_handler(handler: logging.Handler) -> bool: + """Best-effort detection of OTel log sending handlers. + + We match by class/module name to avoid importing OTel directly. + """ + try: + module = getattr(handler.__class__, "__module__", "") or "" + name = handler.__class__.__name__ + ident = f"{module}.{name}".lower() + return ("otel" in ident) or ("opentelemetry" in ident) + except Exception: + return False + + +def _gather_otel_handlers_from_root() -> list[logging.Handler]: + """Collect OTel handlers already attached to the root logger. + + We reuse existing handler instances and attach them to CORVA_LOGGER + to keep OTel log sending while propagation is disabled. + """ + root = logging.getLogger() + handlers = getattr(root, "handlers", []) or [] + return [h for h in handlers if _is_otel_handler(h)] def get_formatter( @@ -219,9 +246,24 @@ def set_formatter(self): def __enter__(self): self.old_handlers = self.logger.handlers - self.logger.handlers = ( - [self.handler, self.user_handler] if self.user_handler else [self.handler] - ) + + # Build the handler chain for CORVA_LOGGER. + handlers = [self.handler] + if self.user_handler: + handlers.append(self.user_handler) + + # If OTel log sending is enabled and an OTel handler exists on root, + # attach it to CORVA_LOGGER as well so propagation can remain disabled + # (avoids AWS root duplication) while still exporting logs via OTel. + + if not SETTINGS.OTEL_LOG_SENDING_DISABLED: + # Fail-safe: never break logging if detection fails + with suppress(Exception): + for handler in _gather_otel_handlers_from_root(): + if handler not in handlers: + handlers.append(handler) + + self.logger.handlers = handlers return self diff --git a/src/version.py b/src/version.py index 21014090..b46c2e74 100644 --- a/src/version.py +++ b/src/version.py @@ -1 +1 @@ -VERSION = "2.0.0" +VERSION = "2.0.1" diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index 29c1eda3..d10f454e 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -1,5 +1,6 @@ import datetime import logging +from unittest.mock import MagicMock import freezegun import pytest @@ -8,6 +9,7 @@ from corva import Logger from corva.configuration import SETTINGS from corva.handlers import scheduled, stream, task +from corva.logger import LoggingContext from corva.models.context import CorvaContext from corva.models.scheduled.raw import RawScheduledDataTimeEvent, RawScheduledEvent from corva.models.scheduled.scheduler_type import SchedulerType @@ -326,3 +328,27 @@ def app(event, api): assert 'The app failed to execute.' in captured.out assert 'The app failed to execute.' in captured.err + + +@pytest.mark.parametrize("cls_name", ("opentelemetry", "otel")) +def test__otel_handler_passed_to_logging_context__success(monkeypatch, cls_name): + + otel_handler = MagicMock() + otel_handler.__class__.__name__ = cls_name + + # Attach to the root logger + logging.getLogger().addHandler(otel_handler) + + monkeypatch.setenv("OTEL_SDK_DISABLED", "false") + + with LoggingContext( + aws_request_id=MagicMock(), + asset_id=MagicMock(), + app_connection_id=MagicMock(), + handler=MagicMock(), + user_handler=MagicMock(), + logger=MagicMock(), + ) as context: + assert otel_handler in context.logger.handlers + + logging.getLogger().removeHandler(otel_handler)