[feat] add otel adapters

This commit is contained in:
Azamat 2026-03-20 13:38:57 +03:00
parent 1588efb9ca
commit 96af9c08f5
10 changed files with 321 additions and 8 deletions

View file

@ -3,7 +3,9 @@
COMPOSE ?= docker-compose
APP_API_TOKEN ?= local-api-token
APP_SIGNING_KEY ?= local-signing-key
APP_OTEL_EXPORTER_ENDPOINT ?= http://localhost:4318
APP_OTEL_LOGS_ENDPOINT ?= http://localhost:4318/v1/logs
APP_OTEL_METRICS_ENDPOINT ?= http://localhost:4318/v1/metrics
APP_OTEL_TRACES_ENDPOINT ?= http://localhost:4318/v1/traces
OTEL_METRIC_EXPORT_INTERVAL ?= 1000
help:
@ -38,7 +40,7 @@ run:
APP_API_TOKEN="$(APP_API_TOKEN)" APP_SIGNING_KEY="$(APP_SIGNING_KEY)" uv run main.py
run-otel:
APP_API_TOKEN="$(APP_API_TOKEN)" APP_SIGNING_KEY="$(APP_SIGNING_KEY)" APP_LOGGING_LEVEL=WARNING APP_OTEL_EXPORTER_ENDPOINT="$(APP_OTEL_EXPORTER_ENDPOINT)" OTEL_METRIC_EXPORT_INTERVAL="$(OTEL_METRIC_EXPORT_INTERVAL)" uv run main.py
APP_API_TOKEN="$(APP_API_TOKEN)" APP_SIGNING_KEY="$(APP_SIGNING_KEY)" APP_LOGGING_LEVEL=WARNING APP_OTEL_LOGS_ENDPOINT="$(APP_OTEL_LOGS_ENDPOINT)" APP_OTEL_METRICS_ENDPOINT="$(APP_OTEL_METRICS_ENDPOINT)" APP_OTEL_TRACES_ENDPOINT="$(APP_OTEL_TRACES_ENDPOINT)" OTEL_METRIC_EXPORT_INTERVAL="$(OTEL_METRIC_EXPORT_INTERVAL)" uv run main.py
compose-build:
$(COMPOSE) build

View file

@ -84,12 +84,26 @@ def load_config(
env_values,
'APP_OTEL_SERVICE_NAME',
),
exporter_endpoint=_yaml_or_env_str(
logs_endpoint=_yaml_or_env_str(
otel_section,
'exporter_endpoint',
'otel.exporter_endpoint',
'logs_endpoint',
'otel.logs_endpoint',
env_values,
'APP_OTEL_EXPORTER_ENDPOINT',
'APP_OTEL_LOGS_ENDPOINT',
),
metrics_endpoint=_yaml_or_env_str(
otel_section,
'metrics_endpoint',
'otel.metrics_endpoint',
env_values,
'APP_OTEL_METRICS_ENDPOINT',
),
traces_endpoint=_yaml_or_env_str(
otel_section,
'traces_endpoint',
'otel.traces_endpoint',
env_values,
'APP_OTEL_TRACES_ENDPOINT',
),
metric_export_interval=_yaml_or_env_int(
otel_section,

View file

@ -22,7 +22,9 @@ class LoggingConfig:
@dataclass(frozen=True, slots=True)
class OtelConfig:
service_name: str
exporter_endpoint: str
logs_endpoint: str
metrics_endpoint: str
traces_endpoint: str
metric_export_interval: int

0
adapter/otel/__init__.py Normal file
View file

85
adapter/otel/bootstrap.py Normal file
View file

@ -0,0 +1,85 @@
from dataclasses import dataclass
from opentelemetry import metrics, trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from adapter.config.model import AppConfig
from .logging import OtelLogger
from .metrics import OtelMetrics
from .tracing import OtelTracer
@dataclass(frozen=True, slots=True)
class OtelRuntime:
logger_provider: LoggerProvider
meter_provider: MeterProvider
tracer_provider: TracerProvider
logger: OtelLogger
metrics: OtelMetrics
tracer: OtelTracer
def shutdown(self) -> None:
self.meter_provider.shutdown()
self.tracer_provider.shutdown()
self.logger_provider.shutdown()
def setup_otel(config: AppConfig) -> OtelRuntime:
if config.otel.metric_export_interval <= 0:
raise ValueError('invalid metric export interval')
resource = Resource.create(
{
'service.name': config.otel.service_name,
'deployment.environment': config.app.env,
}
)
logger_provider = LoggerProvider(resource=resource, shutdown_on_exit=False)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(OTLPLogExporter(endpoint=config.otel.logs_endpoint))
)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=config.otel.metrics_endpoint),
export_interval_millis=config.otel.metric_export_interval,
)
meter_provider = MeterProvider(
metric_readers=[metric_reader],
resource=resource,
shutdown_on_exit=False,
)
tracer_provider = TracerProvider(resource=resource, shutdown_on_exit=False)
tracer_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=config.otel.traces_endpoint))
)
set_logger_provider(logger_provider)
metrics.set_meter_provider(meter_provider)
trace.set_tracer_provider(tracer_provider)
scope_name = config.app.name
logger = OtelLogger(logger_provider.get_logger(scope_name), config.logging.level)
metrics_adapter = OtelMetrics(meter_provider.get_meter(scope_name))
tracer = OtelTracer(tracer_provider.get_tracer(scope_name))
return OtelRuntime(
logger_provider=logger_provider,
meter_provider=meter_provider,
tracer_provider=tracer_provider,
logger=logger,
metrics=metrics_adapter,
tracer=tracer,
)

57
adapter/otel/logging.py Normal file
View file

@ -0,0 +1,57 @@
from opentelemetry._logs import Logger as OtelApiLogger
from opentelemetry._logs import SeverityNumber
from usecase.interface import Attrs
_LEVELS = {
'DEBUG': 10,
'INFO': 20,
'WARN': 30,
'WARNING': 30,
'ERROR': 40,
'FATAL': 50,
'CRITICAL': 50,
}
class OtelLogger:
def __init__(self, logger: OtelApiLogger, level: str) -> None:
self._logger = logger
self._threshold = _log_level_value(level)
def debug(self, message: str, attrs: Attrs | None = None) -> None:
self._emit('DEBUG', SeverityNumber.DEBUG, _LEVELS['DEBUG'], message, attrs)
def info(self, message: str, attrs: Attrs | None = None) -> None:
self._emit('INFO', SeverityNumber.INFO, _LEVELS['INFO'], message, attrs)
def warning(self, message: str, attrs: Attrs | None = None) -> None:
self._emit('WARN', SeverityNumber.WARN, _LEVELS['WARNING'], message, attrs)
def error(self, message: str, attrs: Attrs | None = None) -> None:
self._emit('ERROR', SeverityNumber.ERROR, _LEVELS['ERROR'], message, attrs)
def _emit(
self,
severity_text: str,
severity_number: SeverityNumber,
level: int,
message: str,
attrs: Attrs | None,
) -> None:
if level < self._threshold:
return
self._logger.emit(
severity_text=severity_text,
severity_number=severity_number,
body=message,
attributes=None if attrs is None else dict(attrs),
)
def _log_level_value(level: str) -> int:
normalized = level.strip().upper()
if normalized not in _LEVELS:
raise ValueError('invalid log level')
return _LEVELS[normalized]

59
adapter/otel/metrics.py Normal file
View file

@ -0,0 +1,59 @@
from threading import Lock
from opentelemetry.metrics import Counter, Histogram, Meter
from usecase.interface import Attrs
class OtelMetrics:
def __init__(self, meter: Meter) -> None:
self._meter = meter
self._lock = Lock()
self._counters: dict[str, Counter] = {}
self._histograms: dict[str, Histogram] = {}
def increment(
self,
name: str,
value: int = 1,
attrs: Attrs | None = None,
) -> None:
self._counter(name).add(
value,
attributes=None if attrs is None else dict(attrs),
)
def record(
self,
name: str,
value: float,
attrs: Attrs | None = None,
) -> None:
self._histogram(name).record(
value,
attributes=None if attrs is None else dict(attrs),
)
def _counter(self, name: str) -> Counter:
counter = self._counters.get(name)
if counter is not None:
return counter
with self._lock:
counter = self._counters.get(name)
if counter is None:
counter = self._meter.create_counter(name)
self._counters[name] = counter
return counter
def _histogram(self, name: str) -> Histogram:
histogram = self._histograms.get(name)
if histogram is not None:
return histogram
with self._lock:
histogram = self._histograms.get(name)
if histogram is None:
histogram = self._meter.create_histogram(name)
self._histograms[name] = histogram
return histogram

65
adapter/otel/tracing.py Normal file
View file

@ -0,0 +1,65 @@
from contextlib import AbstractContextManager
from types import TracebackType
from opentelemetry.trace import Span as OtelApiSpan
from opentelemetry.trace import Status, StatusCode
from opentelemetry.trace import Tracer as OtelApiTracer
from usecase.interface import Attrs, AttrValue
class OtelSpan:
def __init__(self, span: OtelApiSpan) -> None:
self._span = span
self._error_recorded = False
@property
def error_recorded(self) -> bool:
return self._error_recorded
def set_attribute(self, name: str, value: AttrValue) -> None:
self._span.set_attribute(name, value)
def record_error(self, error: Exception) -> None:
self._span.record_exception(error)
self._span.set_status(Status(StatusCode.ERROR, str(error)))
self._error_recorded = True
class OtelSpanContext:
def __init__(self, context_manager: AbstractContextManager[OtelApiSpan]) -> None:
self._context_manager = context_manager
self._span: OtelSpan | None = None
def __enter__(self) -> OtelSpan:
self._span = OtelSpan(self._context_manager.__enter__())
return self._span
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: TracebackType | None,
) -> bool | None:
if isinstance(exc, Exception) and self._span is not None:
if not self._span.error_recorded:
self._span.record_error(exc)
return self._context_manager.__exit__(exc_type, exc, traceback)
class OtelTracer:
def __init__(self, tracer: OtelApiTracer) -> None:
self._tracer = tracer
def start_span(
self,
name: str,
attrs: Attrs | None = None,
) -> OtelSpanContext:
context_manager = self._tracer.start_as_current_span(
name,
attributes=None if attrs is None else dict(attrs),
record_exception=False,
set_status_on_exception=True,
)
return OtelSpanContext(context_manager)

View file

@ -12,7 +12,9 @@ logging:
otel:
service_name: web-python-skelet
exporter_endpoint: http://localhost:4318
logs_endpoint: http://localhost:4318/v1/logs
metrics_endpoint: http://localhost:4318/v1/metrics
traces_endpoint: http://localhost:4318/v1/traces
metric_export_interval: 1000
security:

View file

@ -0,0 +1,27 @@
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
exporters:
debug:
verbosity: detailed
service:
pipelines:
logs:
receivers: [otlp]
processors: [batch]
exporters: [debug]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [debug]
traces:
receivers: [otlp]
processors: [batch]
exporters: [debug]