diff --git a/.changelog/4864.fixed b/.changelog/4864.fixed new file mode 100644 index 0000000000..aa2fd1607e --- /dev/null +++ b/.changelog/4864.fixed @@ -0,0 +1 @@ +`opentelemetry-instrumentation-logging`: Promote otel.event.name to LogRecord.event_name in _translate diff --git a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/handler.py b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/handler.py index f2d1439fea..781960e53d 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/handler.py +++ b/instrumentation/opentelemetry-instrumentation-logging/src/opentelemetry/instrumentation/logging/handler.py @@ -9,7 +9,7 @@ import traceback from contextvars import ContextVar from time import time_ns -from typing import Callable, Mapping +from typing import Callable from opentelemetry._logs import ( LoggerProvider, @@ -20,8 +20,14 @@ ) from opentelemetry.context import get_current from opentelemetry.instrumentation.log_utils import std_to_otel -from opentelemetry.semconv._incubating.attributes import code_attributes -from opentelemetry.semconv.attributes import exception_attributes +from opentelemetry.semconv._incubating.attributes import ( + event_attributes, +) +from opentelemetry.semconv.attributes import ( + code_attributes, + exception_attributes, + otel_attributes, +) from opentelemetry.util.types import AnyValue _internal_logger = logging.getLogger(__name__ + ".internal") @@ -132,11 +138,26 @@ def __init__( def _get_attributes( self, record: logging.LogRecord - ) -> Mapping[str, AnyValue]: + ) -> tuple[dict[str, AnyValue], str | None]: attributes = { k: v for k, v in vars(record).items() if k not in _RESERVED_ATTRS } + # Promote otel.event.name (stable) or event.name (deprecated) to the + # first-class LogRecord.event_name field instead of leaving it as a + # plain attribute. otel.event.name takes precedence; event.name is a + # deprecated fallback per the OTel semantic conventions. + # Both keys are always popped so neither leaks into attributes. + event_name: str | None = attributes.pop( + otel_attributes.OTEL_EVENT_NAME, None + ) + # TODO: Remove the deprecated branch path before marking logs stable + deprecated_event_name: str | None = attributes.pop( + event_attributes.EVENT_NAME, None + ) + if event_name is None: + event_name = deprecated_event_name + if self._log_code_attributes: # Add standard code attributes for logs. attributes[code_attributes.CODE_FILE_PATH] = record.pathname @@ -158,7 +179,7 @@ def _get_attributes( attributes[exception_attributes.EXCEPTION_STACKTRACE] = ( "".join(traceback.format_exception(*record.exc_info)) ) - return attributes + return attributes, event_name def _translate(self, record: logging.LogRecord) -> LogRecord: timestamp = int(record.created * 1e9) @@ -168,7 +189,7 @@ def _translate(self, record: logging.LogRecord) -> LogRecord: body = self.format(record) else: body = record.getMessage() - attributes = self._get_attributes(record) + attributes, event_name = self._get_attributes(record) # Map Python log level names to OTel severity text as defined in # https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#displaying-severity @@ -188,6 +209,7 @@ def _translate(self, record: logging.LogRecord) -> LogRecord: severity_number=severity_number, body=body, attributes=attributes, + event_name=event_name, ) def emit(self, record: logging.LogRecord) -> None: diff --git a/instrumentation/opentelemetry-instrumentation-logging/tests/test_handler.py b/instrumentation/opentelemetry-instrumentation-logging/tests/test_handler.py index 349efd1aae..1d2d718ead 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/tests/test_handler.py +++ b/instrumentation/opentelemetry-instrumentation-logging/tests/test_handler.py @@ -621,6 +621,92 @@ def test_logging_handler_without_env_var_uses_default_limit(self): f"Should have 22 dropped attributes, got {record.dropped_attributes}", ) + # --- event_name promotion tests (issue #4743) --- + + def test_otel_event_name_promoted_to_event_name_field(self): + """otel.event.name in extra is promoted to LogRecord.event_name, not left as an attribute.""" + processor, logger, handler = set_up_test_logging(logging.WARNING) + + with self.assertLogs(level=logging.WARNING): + logger.warning( + "something happened", + extra={"otel.event.name": "my.event"}, + ) + + record = processor.get_log_record(0) + self.assertEqual(record.log_record.event_name, "my.event") + self.assertNotIn("otel.event.name", record.log_record.attributes) + + logger.removeHandler(handler) + + def test_deprecated_event_name_promoted_as_fallback(self): + """event.name (deprecated) is promoted to LogRecord.event_name when otel.event.name is absent.""" + processor, logger, handler = set_up_test_logging(logging.WARNING) + + with self.assertLogs(level=logging.WARNING): + logger.warning( + "something happened", + extra={"event.name": "legacy.event"}, + ) + + record = processor.get_log_record(0) + self.assertEqual(record.log_record.event_name, "legacy.event") + self.assertNotIn("event.name", record.log_record.attributes) + + logger.removeHandler(handler) + + def test_otel_event_name_takes_precedence_over_deprecated_event_name(self): + """otel.event.name wins over event.name when both are present.""" + processor, logger, handler = set_up_test_logging(logging.WARNING) + + with self.assertLogs(level=logging.WARNING): + logger.warning( + "something happened", + extra={ + "otel.event.name": "stable.event", + "event.name": "legacy.event", + }, + ) + + record = processor.get_log_record(0) + self.assertEqual(record.log_record.event_name, "stable.event") + self.assertNotIn("otel.event.name", record.log_record.attributes) + self.assertNotIn("event.name", record.log_record.attributes) + + logger.removeHandler(handler) + + def test_event_name_is_none_when_not_provided(self): + """event_name is None when neither otel.event.name nor event.name is passed.""" + processor, logger, handler = set_up_test_logging(logging.WARNING) + + with self.assertLogs(level=logging.WARNING): + logger.warning("plain log, no event name") + + record = processor.get_log_record(0) + self.assertIsNone(record.log_record.event_name) + + logger.removeHandler(handler) + + def test_other_extra_attributes_unaffected_by_event_name_promotion(self): + """Unrelated extra attributes still land in the attributes dict normally.""" + processor, logger, handler = set_up_test_logging(logging.WARNING) + + with self.assertLogs(level=logging.WARNING): + logger.warning( + "something happened", + extra={ + "otel.event.name": "my.event", + "http.status_code": 200, + }, + ) + + record = processor.get_log_record(0) + self.assertEqual(record.log_record.event_name, "my.event") + self.assertEqual(record.log_record.attributes["http.status_code"], 200) + self.assertNotIn("otel.event.name", record.log_record.attributes) + + logger.removeHandler(handler) + # pylint: disable=invalid-name class SetupLoggingHandlerTestCase(unittest.TestCase):