Skip to content

Commit b9c58e1

Browse files
committed
feat(sdk-v2): avoid duplicate logs in cloudwatch
1 parent 360b922 commit b9c58e1

9 files changed

Lines changed: 91 additions & 12 deletions

File tree

.github/workflows/main.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ jobs:
9898

9999
needs: check-static-and-test-status
100100

101-
if: contains(github.ref, 'tags/v')
101+
if: contains(github.ref, 'tags/v') # both v1.x.x & v2.X.X
102102

103103
runs-on: ubuntu-latest
104104

@@ -124,6 +124,8 @@ jobs:
124124
docs:
125125
name: Build and publish docs
126126

127+
if: startsWith(github.ref, 'tags/v2.') # Only for version 2.X.X
128+
127129
needs: deploy
128130

129131
runs-on: ubuntu-latest

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
10+
## [2.0.1] - 2025-10-15
11+
### Fixed
12+
- Logging duplication on CloudWatch side
13+
14+
915
## [2.0.0] - 2025-09-11
1016
### Changed
1117
- Full migration from old `v1`of `pydantic` to `v2`dependencies:

docs/antora-playbook.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ content:
77
start_path: docs
88
branches: []
99
# branches: HEAD # Use this for local development
10-
tags: [v2.0.0]
10+
tags: [v2.0.1]
1111
asciidoc:
1212
attributes:
1313
page-toclevels: 5

docs/antora.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
name: corva-sdk
2-
version: ~
2+
version: 2.0.1
33
nav: [modules/ROOT/nav.adoc]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66
name = "corva-sdk"
77
description = "SDK for building Corva DevCenter Python apps."
88
readme = "README.md"
9-
version = "2.0.0"
9+
version = "2.0.1"
1010
license = { text = "The Unlicense" }
1111
authors = [
1212
{ name = "Jordan Ambra", email = "jordan.ambra@corva.ai" }

src/corva/configuration.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,8 @@ class Settings(pydantic_settings.BaseSettings):
4343
MAX_RETRY_COUNT: int = 3 # If `0` then retries will be disabled
4444
BACKOFF_FACTOR: float = 1.0
4545

46+
# OTEL
47+
OTEL_LOG_SENDING_DISABLED: bool = False
48+
4649

4750
SETTINGS = Settings() # type: ignore[call-arg]

src/corva/logger.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import contextlib
22
import logging
3-
import logging.config
43
import sys
54
import time
5+
from contextlib import suppress
66
from typing import Optional
77

88
from corva.configuration import SETTINGS
@@ -14,10 +14,37 @@
1414

1515
logging.getLogger("urllib3.connectionpool").setLevel(SETTINGS.LOG_LEVEL)
1616

17-
# unset to pass messages to ancestor loggers, including OTel Log Sending handler
17+
# Disable propagation to avoid duplicate CloudWatch logs from AWS Lambda's
18+
# root handler. We will explicitly attach any OTel log handlers directly
19+
# to CORVA_LOGGER within LoggingContext when log sending is enabled.
1820
# see https://github.com/corva-ai/otel/pull/37
1921
# see https://corvaqa.atlassian.net/browse/EE-31
20-
# CORVA_LOGGER.propagate = False
22+
CORVA_LOGGER.propagate = False
23+
24+
25+
def _is_otel_handler(handler: logging.Handler) -> bool:
26+
"""Best-effort detection of OTel log sending handlers.
27+
28+
We match by class/module name to avoid importing OTel directly.
29+
"""
30+
try:
31+
module = getattr(handler.__class__, "__module__", "") or ""
32+
name = handler.__class__.__name__
33+
ident = f"{module}.{name}".lower()
34+
return ("otel" in ident) or ("opentelemetry" in ident)
35+
except Exception:
36+
return False
37+
38+
39+
def _gather_otel_handlers_from_root() -> list[logging.Handler]:
40+
"""Collect OTel handlers already attached to the root logger.
41+
42+
We reuse existing handler instances and attach them to CORVA_LOGGER
43+
to keep OTel log sending while propagation is disabled.
44+
"""
45+
root = logging.getLogger()
46+
handlers = getattr(root, "handlers", []) or []
47+
return [h for h in handlers if _is_otel_handler(h)]
2148

2249

2350
def get_formatter(
@@ -219,9 +246,24 @@ def set_formatter(self):
219246

220247
def __enter__(self):
221248
self.old_handlers = self.logger.handlers
222-
self.logger.handlers = (
223-
[self.handler, self.user_handler] if self.user_handler else [self.handler]
224-
)
249+
250+
# Build the handler chain for CORVA_LOGGER.
251+
handlers = [self.handler]
252+
if self.user_handler:
253+
handlers.append(self.user_handler)
254+
255+
# If OTel log sending is enabled and an OTel handler exists on root,
256+
# attach it to CORVA_LOGGER as well so propagation can remain disabled
257+
# (avoids AWS root duplication) while still exporting logs via OTel.
258+
259+
if not SETTINGS.OTEL_LOG_SENDING_DISABLED:
260+
# Fail-safe: never break logging if detection fails
261+
with suppress(Exception):
262+
for handler in _gather_otel_handlers_from_root():
263+
if handler not in handlers:
264+
handlers.append(handler)
265+
266+
self.logger.handlers = handlers
225267

226268
return self
227269

src/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
VERSION = "2.0.0"
1+
VERSION = "2.0.1"

tests/unit/test_logging.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import datetime
22
import logging
3+
from unittest.mock import MagicMock
34

45
import freezegun
56
import pytest
67
from pytest_mock import MockerFixture
78

89
from corva import Logger
9-
from corva.configuration import SETTINGS
10+
from corva.configuration import SETTINGS, Settings
1011
from corva.handlers import scheduled, stream, task
12+
from corva.logger import LoggingContext
1113
from corva.models.context import CorvaContext
1214
from corva.models.scheduled.raw import RawScheduledDataTimeEvent, RawScheduledEvent
1315
from corva.models.scheduled.scheduler_type import SchedulerType
@@ -326,3 +328,27 @@ def app(event, api):
326328

327329
assert 'The app failed to execute.' in captured.out
328330
assert 'The app failed to execute.' in captured.err
331+
332+
333+
@pytest.mark.parametrize("cls_name", ("opentelemetry", "otel"))
334+
def test__otel_handler_passed_to_logging_context__success(monkeypatch, cls_name):
335+
336+
otel_handler = MagicMock()
337+
otel_handler.__class__.__name__ = cls_name
338+
339+
# Attach to the root logger
340+
logging.getLogger().addHandler(otel_handler)
341+
342+
monkeypatch.setenv("OTEL_SDK_DISABLED", "false")
343+
344+
with LoggingContext(
345+
aws_request_id=MagicMock(),
346+
asset_id=MagicMock(),
347+
app_connection_id=MagicMock(),
348+
handler=MagicMock(),
349+
user_handler=MagicMock(),
350+
logger=MagicMock(),
351+
) as context:
352+
assert otel_handler in context.logger.handlers
353+
354+
logging.getLogger().removeHandler(otel_handler)

0 commit comments

Comments
 (0)