Skip to content

Commit 02f5edb

Browse files
committed
fix(opentelemetry-instrumentation-logging): Ensure that logging is properly configured independently of the order of instrumentation (this helps especially in case of autoinstrumentation)
- add method to perform proper logging initialisation - add unittests
1 parent d5b5925 commit 02f5edb

3 files changed

Lines changed: 108 additions & 13 deletions

File tree

CHANGELOG.md

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

3434
### Fixed
3535

36+
- `opentelemetry-instrumentation-logging` Ensure that logging is properly configured independently of the order of instrumentation (this helps especially in case of autoinstrumentation)
3637
- `opentelemetry-instrumentation-grpc` AioClientInterceptor should propagate with a Metadata object
3738
([#2363](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2363))
3839
- `opentelemetry-instrumentation-boto3sqs` Instrument Session and resource

instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/__init__.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# pylint: disable=empty-docstring,no-value-for-parameter,no-member,no-name-in-module
1616

1717
import logging # pylint: disable=import-self
18+
from collections import defaultdict
1819
from os import environ
1920
from typing import Collection
2021

@@ -46,6 +47,24 @@
4647
}
4748

4849

50+
def apply_log_settings(format: str, level: int) -> None:
51+
"""
52+
Apply the logging format to the root logger.
53+
54+
Ensures that the logging format is applied either by calling logging.basicConfig
55+
or by setting the logging format on the root logger.
56+
This is required in case of auto-instrumentation, where the root logger is configured
57+
before we can inject the logging format here.
58+
"""
59+
root_logger = logging.getLogger()
60+
if root_logger.hasHandlers():
61+
root_logger.setLevel(level)
62+
for handler in root_logger.handlers:
63+
handler.setFormatter(logging.Formatter(format))
64+
else:
65+
logging.basicConfig(format=format, level=level)
66+
67+
4968
class LoggingInstrumentor(BaseInstrumentor): # pylint: disable=empty-docstring
5069
__doc__ = f"""An instrumentor for stdlib logging module.
5170
@@ -77,13 +96,17 @@ def log_hook(span: Span, record: LogRecord):
7796

7897
_old_factory = None
7998
_log_hook = None
99+
_old_level = None
100+
_old_format = None
80101

81102
def instrumentation_dependencies(self) -> Collection[str]:
82103
return _instruments
83104

84105
def _instrument(self, **kwargs):
85106
provider = kwargs.get("tracer_provider", None) or get_tracer_provider()
86107
old_factory = logging.getLogRecordFactory()
108+
self.old_level = logging.getLogger().level
109+
self.old_format = logging.getLogger().handlers[0].formatter._fmt
87110
LoggingInstrumentor._old_factory = old_factory
88111
LoggingInstrumentor._log_hook = kwargs.get("log_hook", None)
89112

@@ -144,9 +167,10 @@ def record_factory(*args, **kwargs):
144167
)
145168
log_level = log_level or logging.INFO
146169

147-
logging.basicConfig(format=log_format, level=log_level)
170+
apply_log_settings(format=log_format, level=log_level)
148171

149172
def _uninstrument(self, **kwargs):
150173
if LoggingInstrumentor._old_factory:
151174
logging.setLogRecordFactory(LoggingInstrumentor._old_factory)
152175
LoggingInstrumentor._old_factory = None
176+
apply_log_settings(self._old_format, self._old_level or logging.NOTSET)

instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from opentelemetry.instrumentation.logging import ( # pylint: disable=no-name-in-module
2222
DEFAULT_LOGGING_FORMAT,
23-
LoggingInstrumentor,
23+
LoggingInstrumentor, apply_log_settings,
2424
)
2525
from opentelemetry.test.test_base import TestBase
2626
from opentelemetry.trace import ProxyTracer, get_tracer
@@ -106,28 +106,28 @@ def test_trace_context_injection(self):
106106
def test_trace_context_injection_without_span(self):
107107
self.assert_trace_context_injected("0", "0", False)
108108

109-
@mock.patch("logging.basicConfig")
110-
def test_basic_config_called(self, basic_config_mock):
109+
@mock.patch("opentelemetry.instrumentation.logging.apply_log_settings")
110+
def test_basic_config_called(self, apply_log_settings_mock):
111111
LoggingInstrumentor().uninstrument()
112112
LoggingInstrumentor().instrument()
113-
self.assertFalse(basic_config_mock.called)
113+
self.assertTrue(apply_log_settings_mock.called)
114114
LoggingInstrumentor().uninstrument()
115115

116116
env_patch = mock.patch.dict(
117117
"os.environ", {"OTEL_PYTHON_LOG_CORRELATION": "true"}
118118
)
119119
env_patch.start()
120120
LoggingInstrumentor().instrument()
121-
basic_config_mock.assert_called_with(
121+
apply_log_settings_mock.assert_called_with(
122122
format=DEFAULT_LOGGING_FORMAT, level=logging.INFO
123123
)
124124
env_patch.stop()
125125

126-
@mock.patch("logging.basicConfig")
127-
def test_custom_format_and_level_env(self, basic_config_mock):
126+
@mock.patch("opentelemetry.instrumentation.logging.apply_log_settings")
127+
def test_custom_format_and_level_env(self, apply_log_settings_mock):
128128
LoggingInstrumentor().uninstrument()
129129
LoggingInstrumentor().instrument()
130-
self.assertFalse(basic_config_mock.called)
130+
self.assertTrue(apply_log_settings_mock.called)
131131
LoggingInstrumentor().uninstrument()
132132

133133
env_patch = mock.patch.dict(
@@ -140,22 +140,22 @@ def test_custom_format_and_level_env(self, basic_config_mock):
140140
)
141141
env_patch.start()
142142
LoggingInstrumentor().instrument()
143-
basic_config_mock.assert_called_with(
143+
apply_log_settings_mock.assert_called_with(
144144
format="%(message)s %(otelSpanID)s", level=logging.ERROR
145145
)
146146
env_patch.stop()
147147

148-
@mock.patch("logging.basicConfig")
148+
@mock.patch("opentelemetry.instrumentation.logging.apply_log_settings")
149149
def test_custom_format_and_level_api(
150-
self, basic_config_mock
150+
self, apply_log_settings_mock
151151
): # pylint: disable=no-self-use
152152
LoggingInstrumentor().uninstrument()
153153
LoggingInstrumentor().instrument(
154154
set_logging_format=True,
155155
logging_format="%(message)s span_id=%(otelSpanID)s",
156156
log_level=logging.WARNING,
157157
)
158-
basic_config_mock.assert_called_with(
158+
apply_log_settings_mock.assert_called_with(
159159
format="%(message)s span_id=%(otelSpanID)s", level=logging.WARNING
160160
)
161161

@@ -207,3 +207,73 @@ def test_uninstrumented(self):
207207
self.assertFalse(hasattr(record, "otelTraceID"))
208208
self.assertFalse(hasattr(record, "otelServiceName"))
209209
self.assertFalse(hasattr(record, "otelTraceSampled"))
210+
211+
212+
class TestLogSetup(TestBase):
213+
"""
214+
Tests the apply_log_settings function
215+
"""
216+
def setUp(self):
217+
super().setUp()
218+
219+
def tearDown(self):
220+
super().tearDown()
221+
222+
@mock.patch("logging.Formatter")
223+
@mock.patch("logging.getLogger")
224+
@mock.patch("logging.basicConfig")
225+
def test_apply_log_settings_initialized(self, basic_config_mock, get_logger_mock, formatter_mock):
226+
"""
227+
Tests that apply_log_settings tries to apply format and loglevel to the
228+
configured logging module if the logging module has already been initialized
229+
"""
230+
# Prepare
231+
log_level = logging.DEBUG
232+
log_format = "custom %(message)s"
233+
formatter = "formatter"
234+
235+
formatter_mock.return_value = formatter
236+
237+
handler_mock = mock.MagicMock()
238+
239+
root_logger_mock = mock.MagicMock()
240+
root_logger_mock.hasHandlers.return_value = True
241+
root_logger_mock.handlers = [handler_mock]
242+
243+
get_logger_mock.return_value = root_logger_mock
244+
245+
# Act
246+
apply_log_settings(log_format, log_level)
247+
248+
# Assert
249+
get_logger_mock.assert_called_once_with()
250+
root_logger_mock.hasHandlers.assert_called_once()
251+
root_logger_mock.setLevel.assert_called_once_with(log_level)
252+
handler_mock.setFormatter.assert_called_once_with(formatter)
253+
formatter_mock.assert_called_once_with(log_format)
254+
255+
self.assertFalse(basic_config_mock.called)
256+
257+
@mock.patch("logging.getLogger")
258+
@mock.patch("logging.basicConfig")
259+
def test_apply_log_settings_uninitialized(self, basic_config_mock, get_logger_mock):
260+
"""
261+
Tests that apply_log_settings calls logging.basicConfig with the
262+
provided format and level when the logging module has not been
263+
initialized
264+
"""
265+
log_level = logging.DEBUG
266+
log_format = "custom %(message)s"
267+
268+
root_logger_mock = mock.MagicMock()
269+
root_logger_mock.hasHandlers.return_value = False
270+
271+
get_logger_mock.return_value = root_logger_mock
272+
273+
apply_log_settings(log_format, log_level)
274+
275+
root_logger_mock.hasHandlers.assert_called_once()
276+
get_logger_mock.assert_called_once_with()
277+
basic_config_mock.assert_called_once_with(
278+
format=log_format, level=log_level
279+
)

0 commit comments

Comments
 (0)