Skip to content

Commit cfef7e8

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 cfef7e8

2 files changed

Lines changed: 87 additions & 12 deletions

File tree

src/aignostics_foundry_core/otel.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@
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+
_RESERVED_LOG_RECORD_ATTRS = frozenset(
152+
logging.LogRecord(name="", level=0, pathname="", lineno=0, msg="", args=None, exc_info=None).__dict__
153+
) | {"message", "asctime"}
154+
148155

149156
class OTelSettings(OpaqueSettings):
150157
"""Configuration settings for OpenTelemetry integration.
@@ -579,18 +586,20 @@ def sink(message: Message) -> None:
579586
if record["exception"] is not None:
580587
exc = record["exception"]
581588
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-
)
589+
log_record = logging.LogRecord(
590+
name=record["name"] or "",
591+
level=_LOGURU_TO_STDLIB_LEVEL.get(record["level"].name, logging.INFO),
592+
pathname=record["file"].path,
593+
lineno=record["line"],
594+
msg=record["message"],
595+
args=None,
596+
exc_info=exc_info, # pyright: ignore[reportArgumentType]
597+
func=record["function"],
593598
)
599+
log_record.__dict__.update({
600+
key: value for key, value in record["extra"].items() if key not in _RESERVED_LOG_RECORD_ATTRS
601+
})
602+
handler.emit(log_record)
594603

595604
return sink
596605

tests/aignostics_foundry_core/otel_test.py

Lines changed: 67 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,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
457478
class 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
507573
class TestOtelLogSinkFilter:

0 commit comments

Comments
 (0)