Connect structlog to OTLP processer.
This commit is contained in:
parent
721bc99b7a
commit
18916fd017
4 changed files with 136 additions and 13 deletions
|
|
@ -392,8 +392,9 @@ services:
|
||||||
# Use for production-grade visibility.
|
# Use for production-grade visibility.
|
||||||
#
|
#
|
||||||
# App services send OTLP traces/metrics/logs to the endpoint configured in
|
# App services send OTLP traces/metrics/logs to the endpoint configured in
|
||||||
# OTEL_EXPORTER_OTLP_ENDPOINT (.env). Point it at openobserve:5081 for the
|
# OTEL_EXPORTER_OTLP_ENDPOINT (.env). Point it at openobserve:5080/api/default
|
||||||
# lightweight profile, or at your own otel-collector/tempo for the obs profile.
|
# for the lightweight profile (the app uses OTLP/HTTP exporters), or at your
|
||||||
|
# own otel-collector/tempo for the obs profile.
|
||||||
|
|
||||||
# ── LIGHTWEIGHT OBSERVABILITY (profile: observer) ───────────────────────────
|
# ── LIGHTWEIGHT OBSERVABILITY (profile: observer) ───────────────────────────
|
||||||
openobserve:
|
openobserve:
|
||||||
|
|
|
||||||
|
|
@ -166,11 +166,15 @@ def configure_logging(
|
||||||
|
|
||||||
json_output=True (staging/prod) → JSON renderer; False (dev) → colored console.
|
json_output=True (staging/prod) → JSON renderer; False (dev) → colored console.
|
||||||
service and env are bound globally so every log line carries them.
|
service and env are bound globally so every log line carries them.
|
||||||
|
|
||||||
|
Structlog is routed through the stdlib logging tree so that OpenTelemetry
|
||||||
|
handlers attached later (see ``core/telemetry.py``) capture application
|
||||||
|
logs with their structured fields.
|
||||||
"""
|
"""
|
||||||
set_service(service)
|
set_service(service)
|
||||||
set_env(env)
|
set_env(env)
|
||||||
|
|
||||||
processors: list[structlog.types.Processor] = [
|
shared_processors: list[structlog.types.Processor] = [
|
||||||
structlog.contextvars.merge_contextvars,
|
structlog.contextvars.merge_contextvars,
|
||||||
structlog.processors.add_log_level,
|
structlog.processors.add_log_level,
|
||||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||||
|
|
@ -180,25 +184,26 @@ def configure_logging(
|
||||||
_format_exc_info,
|
_format_exc_info,
|
||||||
_filter_sensitive_keys,
|
_filter_sensitive_keys,
|
||||||
]
|
]
|
||||||
processors.append(
|
renderer: structlog.types.Processor = (
|
||||||
structlog.processors.JSONRenderer(sort_keys=True)
|
structlog.processors.JSONRenderer(sort_keys=True)
|
||||||
if json_output
|
if json_output
|
||||||
else structlog.dev.ConsoleRenderer(colors=True, pad_event=False)
|
else structlog.dev.ConsoleRenderer(colors=True, pad_event=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
structlog.configure(
|
structlog.configure(
|
||||||
processors=processors,
|
processors=shared_processors + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
|
||||||
wrapper_class=structlog.make_filtering_bound_logger(_level_to_int(level)),
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
context_class=dict,
|
context_class=dict,
|
||||||
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
cache_logger_on_first_use=True,
|
cache_logger_on_first_use=True,
|
||||||
)
|
)
|
||||||
# Route stdlib logging through structlog so third-party libs match our format.
|
# Route stdlib logging through structlog so third-party libs match our format,
|
||||||
|
# and structlog events reach any OTEL handlers attached to the root logger.
|
||||||
stdlib_handler = logging.StreamHandler(sys.stderr)
|
stdlib_handler = logging.StreamHandler(sys.stderr)
|
||||||
stdlib_handler.setFormatter(
|
stdlib_handler.setFormatter(
|
||||||
structlog.stdlib.ProcessorFormatter(
|
structlog.stdlib.ProcessorFormatter(
|
||||||
processor=processors[-1],
|
processor=renderer,
|
||||||
foreign_pre_chain=processors[:-1],
|
foreign_pre_chain=shared_processors,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
root_logger = logging.getLogger()
|
root_logger = logging.getLogger()
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,46 @@ def setup_telemetry(service_name: str | None = None) -> None:
|
||||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||||
from opentelemetry.trace import set_tracer_provider
|
from opentelemetry.trace import set_tracer_provider
|
||||||
|
|
||||||
|
class StructlogOTLPLogHandler(LoggingHandler):
|
||||||
|
"""OTEL handler that unwraps structlog event dicts.
|
||||||
|
|
||||||
|
``core/logging.py`` routes structlog through stdlib logging; the
|
||||||
|
resulting ``LogRecord`` carries the event dictionary in
|
||||||
|
``record.msg``. This handler extracts the ``event`` as the log body and
|
||||||
|
promotes the remaining fields to OTEL attributes so OpenObserve can
|
||||||
|
index them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_STRUCTLOG_INTERNAL_KEYS = frozenset({"_logger", "_name"})
|
||||||
|
|
||||||
|
def _translate(self, record: logging.LogRecord) -> Any:
|
||||||
|
# Detect structlog-wrapped records produced by
|
||||||
|
# ``ProcessorFormatter.wrap_for_formatter``.
|
||||||
|
if (
|
||||||
|
getattr(record, "_logger", None) is not None
|
||||||
|
and getattr(record, "_name", None) is not None
|
||||||
|
and isinstance(record.msg, (tuple, list))
|
||||||
|
and len(record.msg) == 1
|
||||||
|
and isinstance(record.msg[0], dict)
|
||||||
|
):
|
||||||
|
event_dict = record.msg[0]
|
||||||
|
# Work on a copy so the original record (used by stderr handler)
|
||||||
|
# is not mutated.
|
||||||
|
patched = logging.makeLogRecord(record.__dict__)
|
||||||
|
patched.msg = event_dict.get("event", "")
|
||||||
|
patched.args = ()
|
||||||
|
# Drop structlog internal bookkeeping copied from the original
|
||||||
|
# record so it does not leak into OTEL attributes.
|
||||||
|
for key in self._STRUCTLOG_INTERNAL_KEYS:
|
||||||
|
patched.__dict__.pop(key, None)
|
||||||
|
for key, value in event_dict.items():
|
||||||
|
if key in self._STRUCTLOG_INTERNAL_KEYS:
|
||||||
|
continue
|
||||||
|
if key not in patched.__dict__:
|
||||||
|
setattr(patched, key, value)
|
||||||
|
return super()._translate(patched)
|
||||||
|
return super()._translate(record)
|
||||||
|
|
||||||
resource = Resource.create({"service.name": service_name or settings.otel_service_name})
|
resource = Resource.create({"service.name": service_name or settings.otel_service_name})
|
||||||
|
|
||||||
# Traces
|
# Traces
|
||||||
|
|
@ -63,8 +103,9 @@ def setup_telemetry(service_name: str | None = None) -> None:
|
||||||
set_tracer_provider(trace_provider)
|
set_tracer_provider(trace_provider)
|
||||||
_trace_provider = trace_provider
|
_trace_provider = trace_provider
|
||||||
|
|
||||||
# Logs (stdlib logging → OTLP). Structlog's PrintLogger still writes to stderr;
|
# Logs (stdlib logging → OTLP). ``configure_logging`` routes structlog
|
||||||
# this captures stdlib/third-party logs and anything routed through the root logger.
|
# through the stdlib logging tree; this handler unwraps the structured
|
||||||
|
# event dicts so OpenObserve receives the message body and fields.
|
||||||
log_provider = LoggerProvider(resource=resource)
|
log_provider = LoggerProvider(resource=resource)
|
||||||
log_provider.add_log_record_processor(
|
log_provider.add_log_record_processor(
|
||||||
BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint))
|
BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint))
|
||||||
|
|
@ -72,7 +113,7 @@ def setup_telemetry(service_name: str | None = None) -> None:
|
||||||
set_logger_provider(log_provider)
|
set_logger_provider(log_provider)
|
||||||
_log_provider = log_provider
|
_log_provider = log_provider
|
||||||
|
|
||||||
otel_log_handler = LoggingHandler(logger_provider=log_provider)
|
otel_log_handler = StructlogOTLPLogHandler(logger_provider=log_provider)
|
||||||
otel_log_handler.setLevel(logging.INFO)
|
otel_log_handler.setLevel(logging.INFO)
|
||||||
logging.getLogger().addHandler(otel_log_handler)
|
logging.getLogger().addHandler(otel_log_handler)
|
||||||
|
|
||||||
|
|
|
||||||
76
tests/unit/test_telemetry.py
Normal file
76
tests/unit/test_telemetry.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Unit tests for OpenTelemetry integration.
|
||||||
|
|
||||||
|
Covers the structlog-aware OTEL log handler that unwraps event dicts so
|
||||||
|
OpenObserve receives structured attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from contract_check.core.logging import configure_logging, get_logger
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_logging():
|
||||||
|
"""Configure logging before each test and clean handlers after."""
|
||||||
|
configure_logging("INFO", json_output=True, service="test-service", env="test")
|
||||||
|
yield
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.handlers.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_structlog_otlp_handler_unwraps_event_dict(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""A structlog-wrapped record is converted to body + attributes."""
|
||||||
|
from contract_check.core.config import get_settings
|
||||||
|
from contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
get_settings(), "otel_exporter_otlp_endpoint", "http://localhost:9999/v1/logs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# setup_telemetry defines the handler class when OTEL is imported.
|
||||||
|
setup_telemetry("test")
|
||||||
|
root = logging.getLogger()
|
||||||
|
try:
|
||||||
|
otel_handler = next(
|
||||||
|
h for h in root.handlers if type(h).__name__ == "StructlogOTLPLogHandler"
|
||||||
|
)
|
||||||
|
|
||||||
|
record = logging.LogRecord(
|
||||||
|
name="test.logger",
|
||||||
|
level=logging.INFO,
|
||||||
|
pathname="test.py",
|
||||||
|
lineno=5,
|
||||||
|
msg=({"event": "hello_openobserve", "foo": "bar", "count": 42},),
|
||||||
|
args=(),
|
||||||
|
exc_info=None,
|
||||||
|
)
|
||||||
|
record._logger = logging.getLogger("test.logger") # type: ignore[attr-defined]
|
||||||
|
record._name = "info" # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
otel_record = otel_handler._translate(record)
|
||||||
|
|
||||||
|
assert otel_record.body == "hello_openobserve"
|
||||||
|
attrs = {k: v for k, v in otel_record.attributes.items() if not k.startswith("code.")}
|
||||||
|
assert attrs["foo"] == "bar"
|
||||||
|
assert attrs["count"] == 42
|
||||||
|
assert "_logger" not in attrs
|
||||||
|
assert "_name" not in attrs
|
||||||
|
finally:
|
||||||
|
shutdown_telemetry()
|
||||||
|
root.handlers = [h for h in root.handlers if type(h).__name__ != "StructlogOTLPLogHandler"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_structlog_logs_reach_root_handlers() -> None:
|
||||||
|
"""After configure_logging, structlog events propagate to stdlib handlers."""
|
||||||
|
root = logging.getLogger()
|
||||||
|
assert (
|
||||||
|
any(type(h).__name__ in ("StreamHandler", "ProcessorFormatter") for h in root.handlers)
|
||||||
|
or root.handlers
|
||||||
|
)
|
||||||
|
|
||||||
|
log = get_logger("test")
|
||||||
|
log.info("smoke_test", answer=42)
|
||||||
Loading…
Add table
Reference in a new issue