Skip to content

Commit 8191a08

Browse files
committed
use a LoggerAdapter
1 parent 1a592f9 commit 8191a08

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
@@ -13,7 +13,7 @@
1313
# limitations under the License.
1414

1515
from functools import cached_property
16-
from logging import DEBUG, getLogger
16+
from logging import DEBUG, NOTSET, Logger, LoggerAdapter, getLogger
1717
from os import environ
1818
from sys import stderr
1919

@@ -34,40 +34,78 @@
3434
entry_points,
3535
)
3636

37-
_logger = getLogger(__name__)
38-
3937
SKIPPED_INSTRUMENTATIONS_WILDCARD = "*"
4038
OTEL_LOG_LEVEL = "OTEL_LOG_LEVEL"
4139
_DEBUG_LOG_LEVELS = frozenset(("trace", "debug"))
4240

4341

44-
def _diagnostic_debug_enabled() -> bool:
42+
def _otel_log_level_allows_debug() -> bool:
4543
log_level = environ.get(OTEL_LOG_LEVEL, "").strip().lower()
4644
return log_level in _DEBUG_LOG_LEVELS
4745

4846

49-
def _logger_handles_debug() -> bool:
50-
return _logger.isEnabledFor(DEBUG) and _logger.hasHandlers()
51-
52-
53-
def _format_diagnostic_arg(arg: object) -> object:
47+
def _format_log_arg(arg: object) -> object:
5448
if isinstance(arg, str):
5549
return repr(arg)
5650

5751
return arg
5852

5953

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

63-
if not _diagnostic_debug_enabled() or _logger_handles_debug():
64-
return
84+
stderr.write(f"DEBUG:{self.logger.name}:{message}\n")
85+
stderr.flush()
6586

66-
if args:
67-
message = message % tuple(_format_diagnostic_arg(arg) for arg in args)
87+
def _logger_would_emit(self, level: int) -> bool:
88+
# If the logger itself would reject this level, don't bother walking handlers.
89+
if not self.logger.isEnabledFor(level):
90+
return False
6891

69-
stderr.write(f"DEBUG:{__name__}:{message}\n")
70-
stderr.flush()
92+
logger: Logger | None = self.logger
93+
while logger:
94+
for handler in logger.handlers:
95+
if handler.level == NOTSET or level >= handler.level:
96+
return True
97+
98+
# If we get here, this logger's handlers would not emit the record.
99+
# If propagation is disabled, parent handlers will not see it either.
100+
if not logger.propagate:
101+
break
102+
103+
logger = logger.parent
104+
105+
return False
106+
107+
108+
_logger = _OtelLogLevelLoggerAdapter(getLogger(__name__), {})
71109

72110

73111
class _EntryPointDistFinder:
@@ -99,12 +137,14 @@ def _load_distro() -> BaseDistro:
99137
if distro_name is None or distro_name == entry_point.name:
100138
distro = entry_point.load()()
101139
if not isinstance(distro, BaseDistro):
102-
_debug(
140+
_logger.debug(
103141
"%s is not an OpenTelemetry Distro. Skipping",
104142
entry_point.name,
105143
)
106144
continue
107-
_debug("Distribution %s will be configured", entry_point.name)
145+
_logger.debug(
146+
"Distribution %s will be configured", entry_point.name
147+
)
108148
return distro
109149
except Exception as exc: # pylint: disable=broad-except
110150
_logger.exception(
@@ -130,14 +170,16 @@ def _load_instrumentors(distro):
130170
break
131171

132172
if entry_point.name in package_to_exclude:
133-
_debug("Instrumentation skipped for library %s", entry_point.name)
173+
_logger.debug(
174+
"Instrumentation skipped for library %s", entry_point.name
175+
)
134176
continue
135177

136178
try:
137179
entry_point_dist = entry_point_finder.dist_for(entry_point)
138180
conflict = get_dist_dependency_conflicts(entry_point_dist)
139181
if conflict:
140-
_debug(
182+
_logger.debug(
141183
"Skipping instrumentation %s: %s",
142184
entry_point.name,
143185
conflict,
@@ -146,13 +188,13 @@ def _load_instrumentors(distro):
146188

147189
# tell instrumentation to not run dep checks again as we already did it above
148190
distro.load_instrumentor(entry_point, skip_dep_check=True)
149-
_debug("Instrumented %s", entry_point.name)
191+
_logger.debug("Instrumented %s", entry_point.name)
150192
except DependencyConflictError as exc:
151193
# Dependency conflicts are generally caught from get_dist_dependency_conflicts
152194
# returning a DependencyConflict. Keeping this error handling in case custom
153195
# distro and instrumentor behavior raises a DependencyConflictError later.
154196
# See https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3610
155-
_debug(
197+
_logger.debug(
156198
"Skipping instrumentation %s: %s",
157199
entry_point.name,
158200
exc.conflict,
@@ -162,7 +204,7 @@ def _load_instrumentors(distro):
162204
# ModuleNotFoundError is raised when the library is not installed
163205
# and the instrumentation is not required to be loaded.
164206
# See https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3421
165-
_debug(
207+
_logger.debug(
166208
"Skipping instrumentation %s: %s", entry_point.name, exc.msg
167209
)
168210
continue

opentelemetry-instrumentation/tests/auto_instrumentation/test_load.py

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414
# type: ignore
1515

16+
from logging import DEBUG, INFO, NullHandler, getLogger
1617
from unittest import TestCase
1718
from unittest.mock import Mock, call, patch
1819

@@ -209,27 +210,32 @@ def _instrumentation_failed_to_load_call(entry_point, dependency_conflict):
209210
"Skipping instrumentation %s: %s", entry_point, dependency_conflict
210211
)
211212

212-
def test_writes_diagnostic_output_for_debug_levels(self):
213+
def test_writes_otel_log_level_output_for_debug_levels(self):
213214
for log_level in ("debug", "trace"):
214215
with (
215216
self.subTest(log_level=log_level),
216217
patch.dict(
217218
"os.environ", {"OTEL_LOG_LEVEL": log_level}, clear=True
218219
),
219-
patch(
220-
"opentelemetry.instrumentation.auto_instrumentation._load._logger"
221-
) as logger_mock,
222220
patch(
223221
"opentelemetry.instrumentation.auto_instrumentation._load.stderr"
224222
) as stderr_mock,
225223
):
224+
logger_mock = Mock()
226225
logger_mock.isEnabledFor.return_value = True
227-
logger_mock.hasHandlers.return_value = False
226+
logger_mock.handlers = []
227+
logger_mock.parent = None
228+
logger_mock.propagate = True
229+
logger_mock.name = (
230+
"opentelemetry.instrumentation."
231+
"auto_instrumentation._load"
232+
)
233+
logger = _load._OtelLogLevelLoggerAdapter(logger_mock, {})
228234

229-
_load._debug("Instrumented %s", "requests")
235+
logger.debug("Instrumented %s", "requests")
230236

231-
logger_mock.debug.assert_called_once_with(
232-
"Instrumented %s", "requests"
237+
logger_mock.log.assert_called_once_with(
238+
DEBUG, "Instrumented %s", "requests", extra={}
233239
)
234240
stderr_mock.write.assert_called_once_with(
235241
"DEBUG:"
@@ -238,37 +244,115 @@ def test_writes_diagnostic_output_for_debug_levels(self):
238244
)
239245
stderr_mock.flush.assert_called_once()
240246

241-
def test_does_not_write_diagnostic_output(self):
247+
def test_does_not_write_otel_log_level_output(self):
242248
cases = (
243-
("debug", True),
249+
("debug", DEBUG),
244250
("info", False),
245251
("debug2", False),
246252
("debugger", False),
247253
)
248254

249-
for log_level, logger_has_handlers in cases:
255+
for log_level, handler_level in cases:
250256
with (
251257
self.subTest(log_level=log_level),
252258
patch.dict(
253259
"os.environ", {"OTEL_LOG_LEVEL": log_level}, clear=True
254260
),
255-
patch(
256-
"opentelemetry.instrumentation.auto_instrumentation._load._logger"
257-
) as logger_mock,
258261
patch(
259262
"opentelemetry.instrumentation.auto_instrumentation._load.stderr"
260263
) as stderr_mock,
261264
):
265+
logger_mock = Mock()
262266
logger_mock.isEnabledFor.return_value = True
263-
logger_mock.hasHandlers.return_value = logger_has_handlers
267+
logger_mock.handlers = (
268+
[Mock(level=handler_level)] if handler_level else []
269+
)
270+
logger_mock.parent = None
271+
logger_mock.propagate = True
272+
logger = _load._OtelLogLevelLoggerAdapter(logger_mock, {})
273+
274+
logger.debug("Instrumented %s", "requests")
275+
276+
logger_mock.log.assert_called_once_with(
277+
DEBUG, "Instrumented %s", "requests", extra={}
278+
)
279+
stderr_mock.write.assert_not_called()
280+
stderr_mock.flush.assert_not_called()
281+
282+
@patch.dict("os.environ", {"OTEL_LOG_LEVEL": "debug"}, clear=True)
283+
def test_otel_log_level_output_uses_logger_hierarchy_handlers(self):
284+
parent_logger = getLogger("opentelemetry.test.auto_instrumentation")
285+
logger = getLogger("opentelemetry.test.auto_instrumentation.loader")
286+
287+
original_parent_handlers = parent_logger.handlers[:]
288+
original_parent_level = parent_logger.level
289+
original_parent_propagate = parent_logger.propagate
290+
original_logger_handlers = logger.handlers[:]
291+
original_logger_level = logger.level
292+
original_logger_propagate = logger.propagate
293+
294+
try:
295+
parent_logger.handlers = []
296+
parent_logger.setLevel(DEBUG)
297+
parent_logger.propagate = False
298+
logger.handlers = []
299+
logger.setLevel(DEBUG)
300+
logger.propagate = True
301+
302+
otel_log_level_logger = _load._OtelLogLevelLoggerAdapter(
303+
logger, {}
304+
)
305+
306+
with patch(
307+
"opentelemetry.instrumentation.auto_instrumentation._load.stderr"
308+
) as stderr_mock:
309+
otel_log_level_logger.debug("Instrumented %s", "requests")
310+
311+
stderr_mock.write.assert_called_once_with(
312+
"DEBUG:"
313+
"opentelemetry.test.auto_instrumentation.loader:"
314+
"Instrumented 'requests'\n"
315+
)
316+
stderr_mock.flush.assert_called_once()
317+
318+
handler = NullHandler()
319+
handler.setLevel(INFO)
320+
parent_logger.addHandler(handler)
321+
otel_log_level_logger = _load._OtelLogLevelLoggerAdapter(
322+
logger, {}
323+
)
264324

265-
_load._debug("Instrumented %s", "requests")
325+
with patch(
326+
"opentelemetry.instrumentation.auto_instrumentation._load.stderr"
327+
) as stderr_mock:
328+
otel_log_level_logger.debug("Instrumented %s", "requests")
266329

267-
logger_mock.debug.assert_called_once_with(
268-
"Instrumented %s", "requests"
330+
stderr_mock.write.assert_called_once_with(
331+
"DEBUG:"
332+
"opentelemetry.test.auto_instrumentation.loader:"
333+
"Instrumented 'requests'\n"
269334
)
335+
stderr_mock.flush.assert_called_once()
336+
337+
handler.setLevel(DEBUG)
338+
otel_log_level_logger = _load._OtelLogLevelLoggerAdapter(
339+
logger, {}
340+
)
341+
342+
with patch(
343+
"opentelemetry.instrumentation.auto_instrumentation._load.stderr"
344+
) as stderr_mock:
345+
otel_log_level_logger.debug("Instrumented %s", "requests")
346+
270347
stderr_mock.write.assert_not_called()
271348
stderr_mock.flush.assert_not_called()
349+
finally:
350+
parent_logger.handlers = original_parent_handlers
351+
parent_logger.setLevel(original_parent_level)
352+
parent_logger.propagate = original_parent_propagate
353+
logger.handlers = original_logger_handlers
354+
logger.setLevel(original_logger_level)
355+
logger.propagate = original_logger_propagate
272356

273357
@patch.dict(
274358
"os.environ",

0 commit comments

Comments
 (0)