Skip to content

Commit 3928bbf

Browse files
olivermeyerclaude
andcommitted
feat(otel): forward loguru extras to OTLP logs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2e35622 commit 3928bbf

2 files changed

Lines changed: 103 additions & 12 deletions

File tree

‎src/aignostics_foundry_core/otel.py‎

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@
145145
"CRITICAL": logging.CRITICAL,
146146
}
147147

148+
# Attribute names the stdlib logging framework owns on every LogRecord, plus the two it
149+
# fills in later (``message`` via ``getMessage()``, ``asctime`` via a formatter). Computed
150+
# from a real LogRecord so it tracks whatever this Python version actually populates.
151+
# Loguru bound extras (``logger.bind(...)``) are flattened onto the LogRecord in the OTLP
152+
# sink so OTel's LoggingHandler maps each as its own log attribute; any bound key colliding
153+
# with one of these is skipped so it can't corrupt the record's standard fields.
154+
_RESERVED_LOG_RECORD_ATTRS = frozenset(
155+
logging.LogRecord(name="", level=0, pathname="", lineno=0, msg="", args=None, exc_info=None).__dict__
156+
) | {"message", "asctime"}
157+
148158

149159
class OTelSettings(OpaqueSettings):
150160
"""Configuration settings for OpenTelemetry integration.
@@ -566,6 +576,14 @@ def _make_otel_log_sink(handler: LoggingHandler) -> Callable[[Message], None]:
566576
expects, reusing the SDK's own conversion/trace-correlation logic instead
567577
of reimplementing it.
568578
579+
Fields bound via ``logger.bind(...)`` (loguru's ``record["extra"]``) are
580+
flattened onto the ``LogRecord``'s ``__dict__`` — OTel's ``LoggingHandler``
581+
turns every non-reserved ``LogRecord`` attribute into its own OTLP log
582+
attribute, so each bound field lands as a first-class attribute rather than
583+
a nested ``extra`` dict. Reserved ``LogRecord`` attributes are never
584+
overwritten, so a colliding bound key can't corrupt the record's standard
585+
fields. Records without bound extras are unaffected.
586+
569587
Args:
570588
handler: The OTel ``LoggingHandler`` to forward converted records to.
571589
@@ -579,18 +597,20 @@ def sink(message: Message) -> None:
579597
if record["exception"] is not None:
580598
exc = record["exception"]
581599
exc_info = (exc.type, exc.value, exc.traceback)
582-
handler.emit(
583-
logging.LogRecord(
584-
name=record["name"] or "",
585-
level=_LOGURU_TO_STDLIB_LEVEL.get(record["level"].name, logging.INFO),
586-
pathname=record["file"].path,
587-
lineno=record["line"],
588-
msg=record["message"],
589-
args=None,
590-
exc_info=exc_info, # pyright: ignore[reportArgumentType]
591-
func=record["function"],
592-
)
600+
log_record = logging.LogRecord(
601+
name=record["name"] or "",
602+
level=_LOGURU_TO_STDLIB_LEVEL.get(record["level"].name, logging.INFO),
603+
pathname=record["file"].path,
604+
lineno=record["line"],
605+
msg=record["message"],
606+
args=None,
607+
exc_info=exc_info, # pyright: ignore[reportArgumentType]
608+
func=record["function"],
593609
)
610+
log_record.__dict__.update({
611+
key: value for key, value in record["extra"].items() if key not in _RESERVED_LOG_RECORD_ATTRS
612+
})
613+
handler.emit(log_record)
594614

595615
return sink
596616

‎tests/aignostics_foundry_core/otel_test.py‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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
457483
class 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
507578
class TestOtelLogSinkFilter:

0 commit comments

Comments
 (0)