@@ -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,31 @@ 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+
469+ Returns the handler (to hand to the sink) and the exporter (to read back the
470+ emitted OTel ``LogRecord``s), so tests can assert on real OTLP output rather
471+ than a mock's call args.
472+ """
473+ from opentelemetry .sdk ._logs import LoggerProvider , LoggingHandler
474+ from opentelemetry .sdk ._logs .export import InMemoryLogRecordExporter , SimpleLogRecordProcessor
475+
476+ exporter = InMemoryLogRecordExporter ()
477+ provider = LoggerProvider ()
478+ provider .add_log_record_processor (SimpleLogRecordProcessor (exporter ))
479+ return LoggingHandler (level = logging .NOTSET , logger_provider = provider ), exporter
480+
481+
456482@pytest .mark .unit
457483class TestMakeOtelLogSink :
458484 """Behavioural tests for _make_otel_log_sink()."""
@@ -502,6 +528,51 @@ def test_sink_includes_exc_info_when_exception_present(self) -> None:
502528 assert record .exc_info is not None
503529 assert record .exc_info [1 ] is exc_value
504530
531+ def test_otel_log_sink_forwards_bound_extras (self ) -> None :
532+ """A field bound via logger.bind() lands as its own top-level OTLP attribute."""
533+ handler , exporter = _make_wired_otel_log_handler ()
534+ sink = _make_otel_log_sink (handler ) # pyright: ignore[reportArgumentType]
535+
536+ sink (_make_loguru_message (extra = {_BOUND_FIELD_KEY : _BOUND_FIELD_VALUE }))
537+
538+ finished = exporter .get_finished_logs () # pyright: ignore[reportAttributeAccessIssue]
539+ assert len (finished ) == 1
540+ attributes = finished [0 ].log_record .attributes
541+ assert attributes [_BOUND_FIELD_KEY ] == _BOUND_FIELD_VALUE
542+ assert "extra" not in attributes
543+
544+ def test_otel_log_sink_without_extras_still_emits (self ) -> None :
545+ """A record with no bound extras emits cleanly with its standard fields intact."""
546+ handler , exporter = _make_wired_otel_log_handler ()
547+ sink = _make_otel_log_sink (handler ) # pyright: ignore[reportArgumentType]
548+
549+ sink (_make_loguru_message (message = "no bound fields" ))
550+
551+ finished = exporter .get_finished_logs () # pyright: ignore[reportAttributeAccessIssue]
552+ assert len (finished ) == 1
553+ assert finished [0 ].log_record .body == "no bound fields"
554+
555+ def test_otel_log_sink_extra_does_not_clobber_reserved_fields (self ) -> None :
556+ """A bound key colliding with a reserved LogRecord attribute is ignored, not applied."""
557+ handler , exporter = _make_wired_otel_log_handler ()
558+ sink = _make_otel_log_sink (handler ) # pyright: ignore[reportArgumentType]
559+
560+ sink (
561+ _make_loguru_message (
562+ level_name = "WARNING" ,
563+ message = "real message" ,
564+ extra = {"message" : "hijacked" , "levelname" : "BOGUS" },
565+ )
566+ )
567+
568+ finished = exporter .get_finished_logs () # pyright: ignore[reportAttributeAccessIssue]
569+ assert len (finished ) == 1
570+ log_record = finished [0 ].log_record
571+ assert log_record .body == "real message"
572+ # OTel maps stdlib "WARNING" to the shortened "WARN"; had the bound "levelname"
573+ # been applied, severity_text would read "BOGUS" instead.
574+ assert log_record .severity_text == "WARN"
575+
505576
506577@pytest .mark .unit
507578class TestOtelLogSinkFilter :
0 commit comments