Skip to content

Commit eb2baa5

Browse files
committed
use a LoggerAdapter
1 parent 10033d8 commit eb2baa5

2 files changed

Lines changed: 168 additions & 42 deletions

File tree

  • opentelemetry-instrumentation
    • src/opentelemetry/instrumentation/auto_instrumentation
    • tests/auto_instrumentation

opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/_load.py

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
from functools import cached_property
5-
from logging import DEBUG, getLogger
5+
from logging import DEBUG, NOTSET, Logger, LoggerAdapter, getLogger
66
from os import environ
77
from sys import stderr
88

@@ -23,40 +23,78 @@
2323
entry_points,
2424
)
2525

26-
_logger = getLogger(__name__)
27-
2826
SKIPPED_INSTRUMENTATIONS_WILDCARD = "*"
2927
OTEL_LOG_LEVEL = "OTEL_LOG_LEVEL"
3028
_DEBUG_LOG_LEVELS = frozenset(("trace", "debug"))
3129

3230

33-
def _diagnostic_debug_enabled() -> bool:
31+
def _otel_log_level_allows_debug() -> bool:
3432
log_level = environ.get(OTEL_LOG_LEVEL, "").strip().lower()
3533
return log_level in _DEBUG_LOG_LEVELS
3634

3735

38-
def _logger_handles_debug() -> bool:
39-
return _logger.isEnabledFor(DEBUG) and _logger.hasHandlers()
40-
41-
42-
def _format_diagnostic_arg(arg: object) -> object:
36+
def _format_log_arg(arg: object) -> object:
4337
if isinstance(arg, str):
4438
return repr(arg)
4539

4640
return arg
4741

4842

49-
def _debug(message: str, *args: object) -> None:
50-
_logger.debug(message, *args)
43+
class _OtelLogLevelLoggerAdapter(LoggerAdapter):
44+
"""Write startup debug messages to stderr when logging would drop them.
45+
46+
Auto-instrumentation usually runs from sitecustomize before the
47+
application configures logging, so normal logger.debug calls are often
48+
not visible even when OTEL_LOG_LEVEL=debug. This adapter keeps normal
49+
logging behavior, but also writes the same startup messages to stderr when
50+
OTEL_LOG_LEVEL asks for debug output and Python logging would not emit them.
51+
"""
52+
53+
def __init__(self, logger, extra):
54+
super().__init__(logger, extra)
55+
# This adapter is built when this module is imported. That covers the
56+
# sitecustomize path where auto-instrumentation runs before application
57+
# logging setup, and the edge case where Python logging is set up and then
58+
# auto_instrumentation.initialize() is invoked explicitly. If this adapter
59+
# is used after application code can change logging, it should be modified
60+
# to not use the cached value in the _logger_emits_debug field.
61+
self._logger_emits_debug = self._logger_would_emit(DEBUG)
62+
63+
def debug(self, msg: str, *args: object, **kwargs: object) -> None:
64+
super().debug(msg, *args, **kwargs)
65+
66+
if not _otel_log_level_allows_debug() or self._logger_emits_debug:
67+
return
68+
69+
message = msg
70+
if args:
71+
message = message % tuple(_format_log_arg(arg) for arg in args)
5172

52-
if not _diagnostic_debug_enabled() or _logger_handles_debug():
53-
return
73+
stderr.write(f"DEBUG:{self.logger.name}:{message}\n")
74+
stderr.flush()
5475

55-
if args:
56-
message = message % tuple(_format_diagnostic_arg(arg) for arg in args)
76+
def _logger_would_emit(self, level: int) -> bool:
77+
# If the logger itself would reject this level, don't bother walking handlers.
78+
if not self.logger.isEnabledFor(level):
79+
return False
5780

58-
stderr.write(f"DEBUG:{__name__}:{message}\n")
59-
stderr.flush()
81+
logger: Logger | None = self.logger
82+
while logger:
83+
for handler in logger.handlers:
84+
if handler.level == NOTSET or level >= handler.level:
85+
return True
86+
87+
# If we get here, this logger's handlers would not emit the record.
88+
# If propagation is disabled, parent handlers will not see it either.
89+
if not logger.propagate:
90+
break
91+
92+
logger = logger.parent
93+
94+
return False
95+
96+
97+
_logger = _OtelLogLevelLoggerAdapter(getLogger(__name__), {})
6098

6199

62100
class _EntryPointDistFinder:
@@ -88,12 +126,14 @@ def _load_distro() -> BaseDistro:
88126
if distro_name is None or distro_name == entry_point.name:
89127
distro = entry_point.load()()
90128
if not isinstance(distro, BaseDistro):
91-
_debug(
129+
_logger.debug(
92130
"%s is not an OpenTelemetry Distro. Skipping",
93131
entry_point.name,
94132
)
95133
continue
96-
_debug("Distribution %s will be configured", entry_point.name)
134+
_logger.debug(
135+
"Distribution %s will be configured", entry_point.name
136+
)
97137
return distro
98138
except Exception as exc: # pylint: disable=broad-except
99139
_logger.exception(
@@ -119,14 +159,16 @@ def _load_instrumentors(distro):
119159
break
120160

121161
if entry_point.name in package_to_exclude:
122-
_debug("Instrumentation skipped for library %s", entry_point.name)
162+
_logger.debug(
163+
"Instrumentation skipped for library %s", entry_point.name
164+
)
123165
continue
124166

125167
try:
126168
entry_point_dist = entry_point_finder.dist_for(entry_point)
127169
conflict = get_dist_dependency_conflicts(entry_point_dist)
128170
if conflict:
129-
_debug(
171+
_logger.debug(
130172
"Skipping instrumentation %s: %s",
131173
entry_point.name,
132174
conflict,
@@ -135,13 +177,13 @@ def _load_instrumentors(distro):
135177

136178
# tell instrumentation to not run dep checks again as we already did it above
137179
distro.load_instrumentor(entry_point, skip_dep_check=True)
138-
_debug("Instrumented %s", entry_point.name)
180+
_logger.debug("Instrumented %s", entry_point.name)
139181
except DependencyConflictError as exc:
140182
# Dependency conflicts are generally caught from get_dist_dependency_conflicts
141183
# returning a DependencyConflict. Keeping this error handling in case custom
142184
# distro and instrumentor behavior raises a DependencyConflictError later.
143185
# See https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3610
144-
_debug(
186+
_logger.debug(
145187
"Skipping instrumentation %s: %s",
146188
entry_point.name,
147189
exc.conflict,
@@ -151,7 +193,7 @@ def _load_instrumentors(distro):
151193
# ModuleNotFoundError is raised when the library is not installed
152194
# and the instrumentation is not required to be loaded.
153195
# See https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3421
154-
_debug(
196+
_logger.debug(
155197
"Skipping instrumentation %s: %s", entry_point.name, exc.msg
156198
)
157199
continue

opentelemetry-instrumentation/tests/auto_instrumentation/test_load.py

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# SPDX-License-Identifier: Apache-2.0
33
# type: ignore
44

5+
from logging import DEBUG, INFO, NullHandler, getLogger
56
from unittest import TestCase
67
from unittest.mock import Mock, call, patch
78

@@ -198,27 +199,32 @@ def _instrumentation_failed_to_load_call(entry_point, dependency_conflict):
198199
"Skipping instrumentation %s: %s", entry_point, dependency_conflict
199200
)
200201

201-
def test_writes_diagnostic_output_for_debug_levels(self):
202+
def test_writes_otel_log_level_output_for_debug_levels(self):
202203
for log_level in ("debug", "trace"):
203204
with (
204205
self.subTest(log_level=log_level),
205206
patch.dict(
206207
"os.environ", {"OTEL_LOG_LEVEL": log_level}, clear=True
207208
),
208-
patch(
209-
"opentelemetry.instrumentation.auto_instrumentation._load._logger"
210-
) as logger_mock,
211209
patch(
212210
"opentelemetry.instrumentation.auto_instrumentation._load.stderr"
213211
) as stderr_mock,
214212
):
213+
logger_mock = Mock()
215214
logger_mock.isEnabledFor.return_value = True
216-
logger_mock.hasHandlers.return_value = False
215+
logger_mock.handlers = []
216+
logger_mock.parent = None
217+
logger_mock.propagate = True
218+
logger_mock.name = (
219+
"opentelemetry.instrumentation."
220+
"auto_instrumentation._load"
221+
)
222+
logger = _load._OtelLogLevelLoggerAdapter(logger_mock, {})
217223

218-
_load._debug("Instrumented %s", "requests")
224+
logger.debug("Instrumented %s", "requests")
219225

220-
logger_mock.debug.assert_called_once_with(
221-
"Instrumented %s", "requests"
226+
logger_mock.log.assert_called_once_with(
227+
DEBUG, "Instrumented %s", "requests", extra={}
222228
)
223229
stderr_mock.write.assert_called_once_with(
224230
"DEBUG:"
@@ -227,37 +233,115 @@ def test_writes_diagnostic_output_for_debug_levels(self):
227233
)
228234
stderr_mock.flush.assert_called_once()
229235

230-
def test_does_not_write_diagnostic_output(self):
236+
def test_does_not_write_otel_log_level_output(self):
231237
cases = (
232-
("debug", True),
238+
("debug", DEBUG),
233239
("info", False),
234240
("debug2", False),
235241
("debugger", False),
236242
)
237243

238-
for log_level, logger_has_handlers in cases:
244+
for log_level, handler_level in cases:
239245
with (
240246
self.subTest(log_level=log_level),
241247
patch.dict(
242248
"os.environ", {"OTEL_LOG_LEVEL": log_level}, clear=True
243249
),
244-
patch(
245-
"opentelemetry.instrumentation.auto_instrumentation._load._logger"
246-
) as logger_mock,
247250
patch(
248251
"opentelemetry.instrumentation.auto_instrumentation._load.stderr"
249252
) as stderr_mock,
250253
):
254+
logger_mock = Mock()
251255
logger_mock.isEnabledFor.return_value = True
252-
logger_mock.hasHandlers.return_value = logger_has_handlers
256+
logger_mock.handlers = (
257+
[Mock(level=handler_level)] if handler_level else []
258+
)
259+
logger_mock.parent = None
260+
logger_mock.propagate = True
261+
logger = _load._OtelLogLevelLoggerAdapter(logger_mock, {})
262+
263+
logger.debug("Instrumented %s", "requests")
264+
265+
logger_mock.log.assert_called_once_with(
266+
DEBUG, "Instrumented %s", "requests", extra={}
267+
)
268+
stderr_mock.write.assert_not_called()
269+
stderr_mock.flush.assert_not_called()
270+
271+
@patch.dict("os.environ", {"OTEL_LOG_LEVEL": "debug"}, clear=True)
272+
def test_otel_log_level_output_uses_logger_hierarchy_handlers(self):
273+
parent_logger = getLogger("opentelemetry.test.auto_instrumentation")
274+
logger = getLogger("opentelemetry.test.auto_instrumentation.loader")
275+
276+
original_parent_handlers = parent_logger.handlers[:]
277+
original_parent_level = parent_logger.level
278+
original_parent_propagate = parent_logger.propagate
279+
original_logger_handlers = logger.handlers[:]
280+
original_logger_level = logger.level
281+
original_logger_propagate = logger.propagate
282+
283+
try:
284+
parent_logger.handlers = []
285+
parent_logger.setLevel(DEBUG)
286+
parent_logger.propagate = False
287+
logger.handlers = []
288+
logger.setLevel(DEBUG)
289+
logger.propagate = True
290+
291+
otel_log_level_logger = _load._OtelLogLevelLoggerAdapter(
292+
logger, {}
293+
)
294+
295+
with patch(
296+
"opentelemetry.instrumentation.auto_instrumentation._load.stderr"
297+
) as stderr_mock:
298+
otel_log_level_logger.debug("Instrumented %s", "requests")
299+
300+
stderr_mock.write.assert_called_once_with(
301+
"DEBUG:"
302+
"opentelemetry.test.auto_instrumentation.loader:"
303+
"Instrumented 'requests'\n"
304+
)
305+
stderr_mock.flush.assert_called_once()
306+
307+
handler = NullHandler()
308+
handler.setLevel(INFO)
309+
parent_logger.addHandler(handler)
310+
otel_log_level_logger = _load._OtelLogLevelLoggerAdapter(
311+
logger, {}
312+
)
253313

254-
_load._debug("Instrumented %s", "requests")
314+
with patch(
315+
"opentelemetry.instrumentation.auto_instrumentation._load.stderr"
316+
) as stderr_mock:
317+
otel_log_level_logger.debug("Instrumented %s", "requests")
255318

256-
logger_mock.debug.assert_called_once_with(
257-
"Instrumented %s", "requests"
319+
stderr_mock.write.assert_called_once_with(
320+
"DEBUG:"
321+
"opentelemetry.test.auto_instrumentation.loader:"
322+
"Instrumented 'requests'\n"
258323
)
324+
stderr_mock.flush.assert_called_once()
325+
326+
handler.setLevel(DEBUG)
327+
otel_log_level_logger = _load._OtelLogLevelLoggerAdapter(
328+
logger, {}
329+
)
330+
331+
with patch(
332+
"opentelemetry.instrumentation.auto_instrumentation._load.stderr"
333+
) as stderr_mock:
334+
otel_log_level_logger.debug("Instrumented %s", "requests")
335+
259336
stderr_mock.write.assert_not_called()
260337
stderr_mock.flush.assert_not_called()
338+
finally:
339+
parent_logger.handlers = original_parent_handlers
340+
parent_logger.setLevel(original_parent_level)
341+
parent_logger.propagate = original_parent_propagate
342+
logger.handlers = original_logger_handlers
343+
logger.setLevel(original_logger_level)
344+
logger.propagate = original_logger_propagate
261345

262346
@patch.dict(
263347
"os.environ",

0 commit comments

Comments
 (0)