Skip to content

Commit c30fa40

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 - extend CHANGELOG
1 parent da78275 commit c30fa40

File tree

3 files changed

+114
-12
lines changed

3 files changed

+114
-12
lines changed

CHANGELOG.md

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

5858
### Fixed
5959

60+
- `opentelemetry-instrumentation-logging` Ensure that logging is properly configured independently of the order of instrumentation. This is especially useful in case of automatic instrumentation)
61+
([#2384](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2384))
6062
- `opentelemetry-instrumentation-dbapi` Fix compatibility with Psycopg3 to extract libpq build version
6163
([#2500](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2500))
6264
- `opentelemetry-instrumentation-grpc` AioClientInterceptor should propagate with a Metadata object

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@
4646
}
4747

4848

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

7898
_old_factory = None
7999
_log_hook = None
100+
_old_level = None
101+
_old_format = None
80102

81103
def instrumentation_dependencies(self) -> Collection[str]:
82104
return _instruments
83105

84106
def _instrument(self, **kwargs):
85107
provider = kwargs.get("tracer_provider", None) or get_tracer_provider()
86108
old_factory = logging.getLogRecordFactory()
109+
self._old_level = logging.getLogger().level
110+
self._old_format = logging.getLogger().handlers[0].formatter._fmt
87111
LoggingInstrumentor._old_factory = old_factory
88112
LoggingInstrumentor._log_hook = kwargs.get("log_hook", None)
89113

@@ -144,9 +168,10 @@ def record_factory(*args, **kwargs):
144168
)
145169
log_level = log_level or logging.INFO
146170

147-
logging.basicConfig(format=log_format, level=log_level)
171+
apply_log_settings(format=log_format, level=log_level)
148172

149173
def _uninstrument(self, **kwargs):
150174
if LoggingInstrumentor._old_factory:
151175
logging.setLogRecordFactory(LoggingInstrumentor._old_factory)
152176
LoggingInstrumentor._old_factory = None
177+
apply_log_settings(self._old_format, self._old_level or logging.NOTSET)

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

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from opentelemetry.instrumentation.logging import ( # pylint: disable=no-name-in-module
2222
DEFAULT_LOGGING_FORMAT,
2323
LoggingInstrumentor,
24+
apply_log_settings,
2425
)
2526
from opentelemetry.test.test_base import TestBase
2627
from opentelemetry.trace import ProxyTracer, get_tracer
@@ -106,28 +107,28 @@ def test_trace_context_injection(self):
106107
def test_trace_context_injection_without_span(self):
107108
self.assert_trace_context_injected("0", "0", False)
108109

109-
@mock.patch("logging.basicConfig")
110-
def test_basic_config_called(self, basic_config_mock):
110+
@mock.patch("opentelemetry.instrumentation.logging.apply_log_settings")
111+
def test_basic_config_called(self, apply_log_settings_mock):
111112
LoggingInstrumentor().uninstrument()
112113
LoggingInstrumentor().instrument()
113-
self.assertFalse(basic_config_mock.called)
114+
self.assertTrue(apply_log_settings_mock.called)
114115
LoggingInstrumentor().uninstrument()
115116

116117
env_patch = mock.patch.dict(
117118
"os.environ", {"OTEL_PYTHON_LOG_CORRELATION": "true"}
118119
)
119120
env_patch.start()
120121
LoggingInstrumentor().instrument()
121-
basic_config_mock.assert_called_with(
122+
apply_log_settings_mock.assert_called_with(
122123
format=DEFAULT_LOGGING_FORMAT, level=logging.INFO
123124
)
124125
env_patch.stop()
125126

126-
@mock.patch("logging.basicConfig")
127-
def test_custom_format_and_level_env(self, basic_config_mock):
127+
@mock.patch("opentelemetry.instrumentation.logging.apply_log_settings")
128+
def test_custom_format_and_level_env(self, apply_log_settings_mock):
128129
LoggingInstrumentor().uninstrument()
129130
LoggingInstrumentor().instrument()
130-
self.assertFalse(basic_config_mock.called)
131+
self.assertTrue(apply_log_settings_mock.called)
131132
LoggingInstrumentor().uninstrument()
132133

133134
env_patch = mock.patch.dict(
@@ -140,22 +141,22 @@ def test_custom_format_and_level_env(self, basic_config_mock):
140141
)
141142
env_patch.start()
142143
LoggingInstrumentor().instrument()
143-
basic_config_mock.assert_called_with(
144+
apply_log_settings_mock.assert_called_with(
144145
format="%(message)s %(otelSpanID)s", level=logging.ERROR
145146
)
146147
env_patch.stop()
147148

148-
@mock.patch("logging.basicConfig")
149+
@mock.patch("opentelemetry.instrumentation.logging.apply_log_settings")
149150
def test_custom_format_and_level_api(
150-
self, basic_config_mock
151+
self, apply_log_settings_mock
151152
): # pylint: disable=no-self-use
152153
LoggingInstrumentor().uninstrument()
153154
LoggingInstrumentor().instrument(
154155
set_logging_format=True,
155156
logging_format="%(message)s span_id=%(otelSpanID)s",
156157
log_level=logging.WARNING,
157158
)
158-
basic_config_mock.assert_called_with(
159+
apply_log_settings_mock.assert_called_with(
159160
format="%(message)s span_id=%(otelSpanID)s", level=logging.WARNING
160161
)
161162

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

0 commit comments

Comments
 (0)