283 lines
9.7 KiB
Python
283 lines
9.7 KiB
Python
"""
|
|
Unit tests for the OTel log bridge wired up in
|
|
``omnigent.runtime.telemetry``.
|
|
|
|
Exercises ``_init_otel_logs`` and verifies that log records emitted
|
|
inside an active span carry the span's trace_id and span_id once the
|
|
bridge is installed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
from collections.abc import Iterator
|
|
|
|
import pytest
|
|
from opentelemetry import trace as otel_trace
|
|
from opentelemetry._logs import set_logger_provider
|
|
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
|
from opentelemetry.sdk._logs.export import (
|
|
InMemoryLogRecordExporter,
|
|
SimpleLogRecordProcessor,
|
|
)
|
|
from opentelemetry.sdk.trace import TracerProvider
|
|
|
|
from omnigent.runtime import telemetry
|
|
|
|
_BRIDGE_NAME = "omnigent-otel-log-bridge"
|
|
|
|
|
|
def _remove_bridge_handlers() -> None:
|
|
"""
|
|
Strip any leftover OTel log bridge handlers from the root logger.
|
|
|
|
The root logger is process-global. Without an explicit cleanup
|
|
step, handlers attached by one test leak into the next. Each
|
|
handler's provider is shut down so its background batch flush
|
|
thread stops before pytest exits.
|
|
|
|
:returns: ``None``.
|
|
"""
|
|
root_logger = logging.getLogger()
|
|
for handler in list(root_logger.handlers):
|
|
if handler.get_name() != _BRIDGE_NAME:
|
|
continue
|
|
provider = getattr(handler, "_logger_provider", None)
|
|
root_logger.removeHandler(handler)
|
|
if provider is not None and hasattr(provider, "shutdown"):
|
|
with contextlib.suppress(Exception):
|
|
provider.shutdown()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _opt_in_telemetry(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
|
"""
|
|
Telemetry is opt-in (``OMNIGENT_TELEMETRY_ENABLED``, off by default);
|
|
these log-bridge tests opt in for every test, and reset global tracing
|
|
state afterward so init()/enable_tracing() can't leak into other suites.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
monkeypatch.setenv("OMNIGENT_TELEMETRY_ENABLED", "true")
|
|
try:
|
|
yield
|
|
finally:
|
|
from omnigent.inner.tracing import disable_tracing
|
|
|
|
disable_tracing()
|
|
telemetry._initialized = False
|
|
|
|
|
|
@pytest.fixture
|
|
def reset_log_state(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
|
"""
|
|
Reset the telemetry log bridge state between tests.
|
|
|
|
Removes any handler the test installed on the root logger and
|
|
clears the module-level ``_logs_initialized`` guard so the next
|
|
test starts fresh.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
monkeypatch.setattr(telemetry, "_logs_initialized", False)
|
|
_remove_bridge_handlers()
|
|
yield
|
|
_remove_bridge_handlers()
|
|
monkeypatch.setattr(telemetry, "_logs_initialized", False)
|
|
|
|
|
|
# ── _logs_exporter_name ─────────────────────────────────
|
|
|
|
|
|
def test_logs_exporter_name_otlp_from_endpoint(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
When only ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set, the helper
|
|
returns ``"otlp"`` so logs ride the same OTLP path as traces.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
monkeypatch.delenv("OTEL_LOGS_EXPORTER", raising=False)
|
|
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
|
|
assert telemetry._logs_exporter_name() == "otlp"
|
|
|
|
|
|
def test_logs_exporter_name_none_when_unset(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
With no endpoint and no explicit exporter, logs default off.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
monkeypatch.delenv("OTEL_LOGS_EXPORTER", raising=False)
|
|
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
|
assert telemetry._logs_exporter_name() == "none"
|
|
|
|
|
|
def test_logs_exporter_name_explicit_none_wins(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Operators can pin ``OTEL_LOGS_EXPORTER=none`` even when an OTLP
|
|
endpoint is configured for traces.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
"""
|
|
monkeypatch.setenv("OTEL_LOGS_EXPORTER", "none")
|
|
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
|
|
assert telemetry._logs_exporter_name() == "none"
|
|
|
|
|
|
# ── _init_otel_logs ─────────────────────────────────────
|
|
|
|
|
|
def test_init_otel_logs_attaches_handler_with_endpoint(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
reset_log_state: None,
|
|
) -> None:
|
|
"""
|
|
With an OTLP endpoint set, ``_init_otel_logs`` installs a
|
|
``LoggingHandler`` on the root logger so logs flow into the OTel
|
|
bridge.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param reset_log_state: Bridge state reset fixture.
|
|
"""
|
|
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
|
|
monkeypatch.delenv("OTEL_LOGS_EXPORTER", raising=False)
|
|
|
|
telemetry._init_otel_logs()
|
|
|
|
root_logger = logging.getLogger()
|
|
bridge_handlers = [
|
|
handler for handler in root_logger.handlers if handler.get_name() == _BRIDGE_NAME
|
|
]
|
|
assert len(bridge_handlers) == 1, (
|
|
f"expected exactly one OTel log bridge handler, got {len(bridge_handlers)}"
|
|
)
|
|
assert isinstance(bridge_handlers[0], LoggingHandler)
|
|
|
|
|
|
def test_init_otel_logs_noop_without_endpoint(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
reset_log_state: None,
|
|
) -> None:
|
|
"""
|
|
With no endpoint and no explicit exporter, no handler is
|
|
attached. Operators who never opt in pay no overhead.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param reset_log_state: Bridge state reset fixture.
|
|
"""
|
|
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
|
monkeypatch.delenv("OTEL_LOGS_EXPORTER", raising=False)
|
|
|
|
telemetry._init_otel_logs()
|
|
|
|
root_logger = logging.getLogger()
|
|
bridge_handlers = [
|
|
handler for handler in root_logger.handlers if handler.get_name() == _BRIDGE_NAME
|
|
]
|
|
assert bridge_handlers == [], (
|
|
"expected no OTel log bridge handler when no endpoint is configured"
|
|
)
|
|
|
|
|
|
def test_init_otel_logs_idempotent_via_init(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
reset_log_state: None,
|
|
) -> None:
|
|
"""
|
|
Calling :func:`telemetry.init` twice does not stack a second
|
|
OTel log bridge handler on the root logger.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param reset_log_state: Bridge state reset fixture.
|
|
"""
|
|
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
|
|
monkeypatch.delenv("OTEL_LOGS_EXPORTER", raising=False)
|
|
monkeypatch.setenv("OTEL_METRICS_EXPORTER", "none")
|
|
monkeypatch.setattr(telemetry, "_initialized", False)
|
|
monkeypatch.setattr(telemetry, "_metrics_initialized", False)
|
|
|
|
telemetry.init()
|
|
# Reset the one-shot guard so init() runs again and we can
|
|
# verify the bridge does not double-attach.
|
|
monkeypatch.setattr(telemetry, "_initialized", False)
|
|
monkeypatch.setattr(telemetry, "_logs_initialized", False)
|
|
telemetry.init()
|
|
|
|
root_logger = logging.getLogger()
|
|
bridge_handlers = [
|
|
handler for handler in root_logger.handlers if handler.get_name() == _BRIDGE_NAME
|
|
]
|
|
assert len(bridge_handlers) == 1, (
|
|
f"expected exactly one OTel log bridge handler after two init() "
|
|
f"calls, got {len(bridge_handlers)}"
|
|
)
|
|
|
|
|
|
# ── trace_id / span_id propagation ──────────────────────
|
|
|
|
|
|
def test_log_emitted_in_span_carries_trace_and_span_ids(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
reset_log_state: None,
|
|
) -> None:
|
|
"""
|
|
A log record emitted inside an active span carries the span's
|
|
trace_id and span_id on the exported ``LogRecord``.
|
|
|
|
Uses an ``InMemoryLogRecordExporter`` wired through a fresh
|
|
``LoggerProvider`` so the test never touches the network and
|
|
can assert directly on emitted records.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param reset_log_state: Bridge state reset fixture.
|
|
"""
|
|
# Fresh OTel tracer provider so we control the trace context.
|
|
tracer_provider = TracerProvider()
|
|
otel_trace._TRACER_PROVIDER = tracer_provider # type: ignore[attr-defined]
|
|
otel_trace._TRACER_PROVIDER_SET_ONCE._done = True # type: ignore[attr-defined]
|
|
|
|
log_exporter = InMemoryLogRecordExporter()
|
|
log_provider = LoggerProvider()
|
|
log_provider.add_log_record_processor(SimpleLogRecordProcessor(log_exporter))
|
|
set_logger_provider(log_provider)
|
|
|
|
handler = LoggingHandler(logger_provider=log_provider, level=logging.DEBUG)
|
|
handler.set_name(_BRIDGE_NAME)
|
|
root_logger = logging.getLogger()
|
|
root_logger.addHandler(handler)
|
|
previous_level = root_logger.level
|
|
root_logger.setLevel(logging.DEBUG)
|
|
|
|
try:
|
|
tracer = otel_trace.get_tracer("tests.runtime.telemetry_logs")
|
|
with tracer.start_as_current_span("test-span") as span:
|
|
expected_trace_id = span.get_span_context().trace_id
|
|
expected_span_id = span.get_span_context().span_id
|
|
logging.getLogger("omnigent.test").info("hello from inside the span")
|
|
finally:
|
|
root_logger.setLevel(previous_level)
|
|
|
|
records = log_exporter.get_finished_logs()
|
|
matched = [
|
|
record for record in records if record.log_record.body == "hello from inside the span"
|
|
]
|
|
assert len(matched) == 1, (
|
|
f"expected one matching log record, got {len(matched)} (total records: {len(records)})"
|
|
)
|
|
log_record = matched[0].log_record
|
|
assert log_record.trace_id == expected_trace_id, (
|
|
f"log trace_id {log_record.trace_id:032x} does not match "
|
|
f"span trace_id {expected_trace_id:032x}"
|
|
)
|
|
assert log_record.span_id == expected_span_id, (
|
|
f"log span_id {log_record.span_id:016x} does not match "
|
|
f"span span_id {expected_span_id:016x}"
|
|
)
|