@@ -434,8 +434,13 @@ def _make_loguru_message(
434434 level_name : str = "INFO" ,
435435 message : str = "hello" ,
436436 exception : object = None ,
437+ extra : dict [str , object ] | None = None ,
437438) -> MagicMock :
438- """Build a minimal fake loguru Message with a `.record` dict for sink tests."""
439+ """Build a minimal fake loguru Message with a `.record` dict for sink tests.
440+
441+ Real loguru records always carry an ``extra`` dict (empty unless fields are
442+ bound via ``logger.bind(...)``), so it is always present here too.
443+ """
439444 level = MagicMock ()
440445 level .name = level_name
441446 file_ = MagicMock ()
@@ -449,10 +454,26 @@ def _make_loguru_message(
449454 "message" : message ,
450455 "function" : "my_func" ,
451456 "exception" : exception ,
457+ "extra" : extra if extra is not None else {},
452458 }
453459 return msg
454460
455461
462+ _BOUND_FIELD_KEY = "job_id"
463+ _BOUND_FIELD_VALUE = "abc"
464+
465+
466+ def _make_wired_otel_log_handler () -> tuple [object , object ]:
467+ """Build a real OTel LoggingHandler wired to an in-memory exporter."""
468+ from opentelemetry .sdk ._logs import LoggerProvider , LoggingHandler
469+ from opentelemetry .sdk ._logs .export import InMemoryLogRecordExporter , SimpleLogRecordProcessor
470+
471+ exporter = InMemoryLogRecordExporter ()
472+ provider = LoggerProvider ()
473+ provider .add_log_record_processor (SimpleLogRecordProcessor (exporter ))
474+ return LoggingHandler (level = logging .NOTSET , logger_provider = provider ), exporter
475+
476+
456477@pytest .mark .unit
457478class TestMakeOtelLogSink :
458479 """Behavioural tests for _make_otel_log_sink()."""
@@ -502,6 +523,51 @@ def test_sink_includes_exc_info_when_exception_present(self) -> None:
502523 assert record .exc_info is not None
503524 assert record .exc_info [1 ] is exc_value
504525
526+ def test_otel_log_sink_forwards_bound_extras (self ) -> None :
527+ """A field bound via logger.bind() lands as its own top-level OTLP attribute."""
528+ handler , exporter = _make_wired_otel_log_handler ()
529+ sink = _make_otel_log_sink (handler ) # pyright: ignore[reportArgumentType]
530+
531+ sink (_make_loguru_message (extra = {_BOUND_FIELD_KEY : _BOUND_FIELD_VALUE }))
532+
533+ finished = exporter .get_finished_logs () # pyright: ignore[reportAttributeAccessIssue]
534+ assert len (finished ) == 1
535+ attributes = finished [0 ].log_record .attributes
536+ assert attributes [_BOUND_FIELD_KEY ] == _BOUND_FIELD_VALUE
537+ assert "extra" not in attributes
538+
539+ def test_otel_log_sink_without_extras_still_emits (self ) -> None :
540+ """A record with no bound extras emits cleanly with its standard fields intact."""
541+ handler , exporter = _make_wired_otel_log_handler ()
542+ sink = _make_otel_log_sink (handler ) # pyright: ignore[reportArgumentType]
543+
544+ sink (_make_loguru_message (message = "no bound fields" ))
545+
546+ finished = exporter .get_finished_logs () # pyright: ignore[reportAttributeAccessIssue]
547+ assert len (finished ) == 1
548+ assert finished [0 ].log_record .body == "no bound fields"
549+
550+ def test_otel_log_sink_extra_does_not_clobber_reserved_fields (self ) -> None :
551+ """A bound key colliding with a reserved LogRecord attribute is ignored, not applied."""
552+ handler , exporter = _make_wired_otel_log_handler ()
553+ sink = _make_otel_log_sink (handler ) # pyright: ignore[reportArgumentType]
554+
555+ sink (
556+ _make_loguru_message (
557+ level_name = "WARNING" ,
558+ message = "real message" ,
559+ extra = {"message" : "hijacked" , "levelname" : "BOGUS" },
560+ )
561+ )
562+
563+ finished = exporter .get_finished_logs () # pyright: ignore[reportAttributeAccessIssue]
564+ assert len (finished ) == 1
565+ log_record = finished [0 ].log_record
566+ assert log_record .body == "real message"
567+ # OTel maps stdlib "WARNING" to the shortened "WARN"; had the bound "levelname"
568+ # been applied, severity_text would read "BOGUS" instead.
569+ assert log_record .severity_text == "WARN"
570+
505571
506572@pytest .mark .unit
507573class TestOtelLogSinkFilter :
0 commit comments