Skip to content

Commit 57f2755

Browse files
authored
Fix logging without a tty (#436)
This fixes logging without an iteractive session, e.g. docker without -it or by default on k8s. The issue there is that without explicitly adding handlers without a tty the log are not sent to stderr. Also we need to take care of the propagation in case the OTel Logging handler is configured on the root logger to have also these logs exported.
1 parent d379f74 commit 57f2755

File tree

2 files changed

+150
-49
lines changed

2 files changed

+150
-49
lines changed

src/elasticotel/distro/config.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
OpAMPRemoteConfigParseException,
3232
)
3333
from opentelemetry._opamp.proto import opamp_pb2 as opamp_pb2
34+
from opentelemetry.sdk._logs import LoggingHandler
3435
from opentelemetry.sdk.environment_variables import OTEL_LOG_LEVEL, OTEL_TRACES_SAMPLER_ARG
3536

3637

@@ -100,19 +101,24 @@ def log_env_vars(self):
100101
for k, v in os.environ.items()
101102
if k.startswith("OTEL_") or k.startswith("ELASTIC_OTEL_")
102103
]
103-
# we rely on the application setting up logging and we don't want to interfere with that BUT:
104-
# - by default the python root logger should be at WARNING level
105-
# - the EDOT Configuration dump is at INFO level
106-
# - at startup we don't have application logging already setup
107-
# So we add a temporary handler just for printing our config
108-
handler = logging.StreamHandler()
109-
logger.addHandler(handler)
104+
110105
logger.info("EDOT Configuration")
111106
for k, v in sorted(env_vars):
112107
logger.info("%s: %s", k, v)
113-
logger.handlers.remove(handler)
114108

115-
def _handle_logging(self):
109+
def _setup_logging(self):
110+
# Ensure these loggers have a handler so logs are actually output
111+
# Without a handler, logs won't be displayed when running without a TTY (e.g., docker run without -it)
112+
# We only add a handler if neither the logger nor root has any handlers configured
113+
for _logger in self._get_loggers():
114+
if not _logger.handlers:
115+
handler = logging.StreamHandler()
116+
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
117+
_logger.addHandler(handler)
118+
# We need to propagate if we have the OTel handler otherwise we don't see logs shipped, in the other
119+
# cases we shouldn't
120+
_logger.propagate = any(isinstance(_handler, LoggingHandler) for _handler in logging.root.handlers)
121+
116122
# do validation, we only validate logging_level because sampling_rate is handled by the sdk already
117123
logging_level = _LOG_LEVELS_MAP.get(self.logging_level.value)
118124
if logging_level is None:
@@ -121,15 +127,23 @@ def _handle_logging(self):
121127
return
122128

123129
# apply logging_level changes since these are not handled by the sdk
124-
logging.getLogger("opentelemetry").setLevel(logging_level)
125-
logging.getLogger("elasticotel").setLevel(logging_level)
130+
self.update_loggers(logging_level)
131+
132+
@staticmethod
133+
def _get_loggers() -> list[logging.Logger]:
134+
return [logging.getLogger("opentelemetry"), logging.getLogger("elasticotel")]
135+
136+
def update_loggers(self, logging_level: int):
137+
"""Update upstream and distro logging levels"""
138+
for logger in self._get_loggers():
139+
logger.setLevel(logging_level)
126140

127141
def __post_init__(self):
128142
# we need to initialize each config item when we instantiate the Config and not at declaration time
129143
self.sampling_rate.init()
130144
self.logging_level.init()
131145

132-
self._handle_logging()
146+
self._setup_logging()
133147

134148

135149
def _handle_logging_level(remote_config) -> ConfigUpdate:
@@ -142,10 +156,8 @@ def _handle_logging_level(remote_config) -> ConfigUpdate:
142156
logger.error("Logging level not handled: %s", config_logging_level)
143157
error_message = f"Logging level not handled: {config_logging_level}"
144158
else:
145-
# update upstream and distro logging levels
146-
logging.getLogger("opentelemetry").setLevel(logging_level)
147-
logging.getLogger("elasticotel").setLevel(logging_level)
148159
if _config:
160+
_config.update_loggers(logging_level)
149161
_config.logging_level.update(value=config_logging_level)
150162
error_message = ""
151163
return ConfigUpdate(error_message=error_message)

tests/distro/test_distro.py

Lines changed: 123 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
OTEL_TRACES_SAMPLER_ARG,
4444
)
4545
from opentelemetry import trace
46+
from opentelemetry.sdk._logs import LoggingHandler
4647
from opentelemetry.sdk.trace import sampling
4748
from opentelemetry._opamp.proto import opamp_pb2 as opamp_pb2
4849
from opentelemetry.util._once import Once
@@ -408,7 +409,102 @@ def test_configurator_does_not_log_otel_env_vars_by_default(self, logger_mock):
408409
logger_mock.assert_not_called()
409410

410411

412+
class TestConfig(TestCase):
413+
def tearDown(self):
414+
for _logger in Config._get_loggers():
415+
for handler in _logger.handlers:
416+
_logger.removeHandler(handler)
417+
_logger.propagate = True
418+
419+
def test_constructor(self):
420+
config = Config()
421+
422+
self.assertEqual(config.sampling_rate.value, "1.0")
423+
self.assertEqual(config.logging_level.value, "warn")
424+
425+
def test_logging_setup_add_handlers_only_once(self):
426+
config = Config()
427+
428+
self.assertTrue(logging.root.handlers)
429+
430+
config._setup_logging()
431+
432+
for _logger in config._get_loggers():
433+
with self.subTest(logger=_logger):
434+
self.assertEqual(len(_logger.handlers), 1)
435+
self.assertFalse(_logger.propagate)
436+
437+
config._setup_logging()
438+
439+
for _logger in config._get_loggers():
440+
with self.subTest(logger=_logger):
441+
self.assertEqual(len(_logger.handlers), 1)
442+
self.assertFalse(_logger.propagate)
443+
444+
# cleanup
445+
_logger.removeHandler(_logger.handlers[0])
446+
447+
def test_logging_setup_set_propagate_true_if_sdk_logging_handler_found(self):
448+
root_logger = logging.getLogger()
449+
handler = LoggingHandler(logger_provider=mock.Mock())
450+
root_logger.addHandler(handler)
451+
452+
config = Config()
453+
454+
config._setup_logging()
455+
456+
for _logger in config._get_loggers():
457+
with self.subTest(logger=_logger):
458+
self.assertEqual(len(_logger.handlers), 1)
459+
self.assertTrue(_logger.propagate)
460+
461+
# cleanup
462+
_logger.removeHandler(_logger.handlers[0])
463+
_logger.propagate = True
464+
465+
root_logger.removeHandler(handler)
466+
467+
@mock.patch.dict(os.environ, {"OTEL_ENV": "otel", "ELASTIC_OTEL_ENV": "elastic_otel", "FOO": "foo"})
468+
def test_log_env_vars_logs_configuration(self):
469+
config = Config()
470+
with self.assertLogs("elasticotel.distro.config", level="INFO") as cm:
471+
config.log_env_vars()
472+
self.assertEqual(
473+
cm.output,
474+
[
475+
"INFO:elasticotel.distro.config:EDOT Configuration",
476+
"INFO:elasticotel.distro.config:ELASTIC_OTEL_ENV: elastic_otel",
477+
"INFO:elasticotel.distro.config:OTEL_ENV: otel",
478+
],
479+
)
480+
481+
def test_to_dict(self):
482+
config = Config()
483+
self.assertEqual(config.to_dict(), {"logging_level": "warn", "sampling_rate": "1.0"})
484+
485+
@mock.patch.object(logging, "getLogger")
486+
def test_update_loggers(self, get_logger_mock):
487+
config = Config()
488+
489+
config.update_loggers(logging.WARNING)
490+
491+
get_logger_mock.assert_has_calls(
492+
[
493+
mock.call("opentelemetry"),
494+
mock.call("elasticotel"),
495+
mock.call().setLevel(logging.WARNING),
496+
mock.call().setLevel(logging.WARNING),
497+
]
498+
)
499+
500+
411501
class TestOpAMPHandler(TestCase):
502+
def tearDown(self):
503+
for _logger in Config._get_loggers():
504+
for handler in _logger.handlers:
505+
_logger.removeHandler(handler)
506+
_logger.propagate = True
507+
412508
@mock.patch.object(logging, "getLogger")
413509
def test_does_nothing_without_remote_config(self, get_logger_mock):
414510
message = opamp_pb2.ServerToAgent()
@@ -419,20 +515,20 @@ def test_does_nothing_without_remote_config(self, get_logger_mock):
419515
get_logger_mock.assert_not_called()
420516

421517
@mock.patch("elasticotel.distro.config._get_config")
422-
@mock.patch.object(Config, "_handle_logging")
423-
@mock.patch.object(logging, "getLogger")
424-
def test_ignores_non_elastic_filename(self, get_logger_mock, handle_logging_mock, get_config_mock):
425-
get_config_mock.return_value = Config()
518+
def test_ignores_non_elastic_filename(self, get_config_mock):
519+
config = Config()
520+
config.update_loggers = mock.Mock()
521+
get_config_mock.return_value = config
426522
agent = mock.Mock()
427523
client = mock.Mock()
428-
config = opamp_pb2.AgentConfigMap()
429-
config.config_map["non-elastic"].body = json.dumps({"logging_level": "trace"}).encode()
430-
config.config_map["non-elastic"].content_type = "application/json"
431-
remote_config = opamp_pb2.AgentRemoteConfig(config=config, config_hash=b"1234")
524+
agent_config = opamp_pb2.AgentConfigMap()
525+
agent_config.config_map["non-elastic"].body = json.dumps({"logging_level": "trace"}).encode()
526+
agent_config.config_map["non-elastic"].content_type = "application/json"
527+
remote_config = opamp_pb2.AgentRemoteConfig(config=agent_config, config_hash=b"1234")
432528
message = opamp_pb2.ServerToAgent(remote_config=remote_config)
433529
opamp_handler(agent, client, message)
434530

435-
get_logger_mock.assert_not_called()
531+
config.update_loggers.assert_not_called()
436532

437533
client._update_remote_config_status.assert_called_once_with(
438534
remote_config_hash=b"1234", status=opamp_pb2.RemoteConfigStatuses_APPLIED, error_message=""
@@ -503,21 +599,20 @@ def test_fails_if_elastic_config_is_not_json(self, logger_mock, get_config_mock)
503599
client._build_full_state_message.assert_not_called()
504600

505601
@mock.patch("elasticotel.distro.config._get_config")
506-
@mock.patch.object(logging, "getLogger")
507-
def test_sets_matching_logging_level(self, get_logger_mock, get_config_mock):
508-
get_config_mock.return_value = Config()
602+
def test_sets_matching_logging_level(self, get_config_mock):
603+
config = Config()
604+
config.update_loggers = mock.Mock()
605+
get_config_mock.return_value = config
509606
agent = mock.Mock()
510607
client = mock.Mock()
511-
config = opamp_pb2.AgentConfigMap()
512-
config.config_map["elastic"].body = json.dumps({"logging_level": "trace"}).encode()
513-
config.config_map["elastic"].content_type = "application/json"
514-
remote_config = opamp_pb2.AgentRemoteConfig(config=config, config_hash=b"1234")
608+
agent_config = opamp_pb2.AgentConfigMap()
609+
agent_config.config_map["elastic"].body = json.dumps({"logging_level": "trace"}).encode()
610+
agent_config.config_map["elastic"].content_type = "application/json"
611+
remote_config = opamp_pb2.AgentRemoteConfig(config=agent_config, config_hash=b"1234")
515612
message = opamp_pb2.ServerToAgent(remote_config=remote_config)
516613
opamp_handler(agent, client, message)
517614

518-
get_logger_mock.assert_has_calls(
519-
[mock.call("opentelemetry"), mock.call().setLevel(5), mock.call("elasticotel"), mock.call().setLevel(5)]
520-
)
615+
config.update_loggers.assert_called_once_with(5)
521616

522617
client._update_remote_config_status.assert_called_once_with(
523618
remote_config_hash=b"1234", status=opamp_pb2.RemoteConfigStatuses_APPLIED, error_message=""
@@ -532,26 +627,20 @@ def test_sets_matching_logging_level(self, get_logger_mock, get_config_mock):
532627
client._build_full_state_message.assert_not_called()
533628

534629
@mock.patch("elasticotel.distro.config._get_config")
535-
@mock.patch.object(logging, "getLogger")
536-
def test_sets_logging_to_default_info_without_logging_level_entry_in_config(self, get_logger_mock, get_config_mock):
537-
get_config_mock.return_value = Config()
630+
def test_sets_logging_to_default_info_without_logging_level_entry_in_config(self, get_config_mock):
631+
config = Config()
632+
config.update_loggers = mock.Mock()
633+
get_config_mock.return_value = config
538634
agent = mock.Mock()
539635
client = mock.Mock()
540-
config = opamp_pb2.AgentConfigMap()
541-
config.config_map["elastic"].body = json.dumps({}).encode()
542-
config.config_map["elastic"].content_type = "application/json"
543-
remote_config = opamp_pb2.AgentRemoteConfig(config=config, config_hash=b"1234")
636+
agent_config = opamp_pb2.AgentConfigMap()
637+
agent_config.config_map["elastic"].body = json.dumps({}).encode()
638+
agent_config.config_map["elastic"].content_type = "application/json"
639+
remote_config = opamp_pb2.AgentRemoteConfig(config=agent_config, config_hash=b"1234")
544640
message = opamp_pb2.ServerToAgent(remote_config=remote_config)
545641
opamp_handler(agent, client, message)
546642

547-
get_logger_mock.assert_has_calls(
548-
[
549-
mock.call("opentelemetry"),
550-
mock.call().setLevel(logging.WARNING),
551-
mock.call("elasticotel"),
552-
mock.call().setLevel(logging.WARNING),
553-
]
554-
)
643+
config.update_loggers.assert_called_once_with(logging.WARNING)
555644

556645
client._update_remote_config_status.assert_called_once_with(
557646
remote_config_hash=b"1234", status=opamp_pb2.RemoteConfigStatuses_APPLIED, error_message=""

0 commit comments

Comments
 (0)