[feat] add otel adapters
This commit is contained in:
parent
1588efb9ca
commit
96af9c08f5
10 changed files with 321 additions and 8 deletions
6
Makefile
6
Makefile
|
|
@ -3,7 +3,9 @@
|
||||||
COMPOSE ?= docker-compose
|
COMPOSE ?= docker-compose
|
||||||
APP_API_TOKEN ?= local-api-token
|
APP_API_TOKEN ?= local-api-token
|
||||||
APP_SIGNING_KEY ?= local-signing-key
|
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
|
OTEL_METRIC_EXPORT_INTERVAL ?= 1000
|
||||||
|
|
||||||
help:
|
help:
|
||||||
|
|
@ -38,7 +40,7 @@ run:
|
||||||
APP_API_TOKEN="$(APP_API_TOKEN)" APP_SIGNING_KEY="$(APP_SIGNING_KEY)" uv run main.py
|
APP_API_TOKEN="$(APP_API_TOKEN)" APP_SIGNING_KEY="$(APP_SIGNING_KEY)" uv run main.py
|
||||||
|
|
||||||
run-otel:
|
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:
|
||||||
$(COMPOSE) build
|
$(COMPOSE) build
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,26 @@ def load_config(
|
||||||
env_values,
|
env_values,
|
||||||
'APP_OTEL_SERVICE_NAME',
|
'APP_OTEL_SERVICE_NAME',
|
||||||
),
|
),
|
||||||
exporter_endpoint=_yaml_or_env_str(
|
logs_endpoint=_yaml_or_env_str(
|
||||||
otel_section,
|
otel_section,
|
||||||
'exporter_endpoint',
|
'logs_endpoint',
|
||||||
'otel.exporter_endpoint',
|
'otel.logs_endpoint',
|
||||||
env_values,
|
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(
|
metric_export_interval=_yaml_or_env_int(
|
||||||
otel_section,
|
otel_section,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ class LoggingConfig:
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class OtelConfig:
|
class OtelConfig:
|
||||||
service_name: str
|
service_name: str
|
||||||
exporter_endpoint: str
|
logs_endpoint: str
|
||||||
|
metrics_endpoint: str
|
||||||
|
traces_endpoint: str
|
||||||
metric_export_interval: int
|
metric_export_interval: int
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
0
adapter/otel/__init__.py
Normal file
0
adapter/otel/__init__.py
Normal file
85
adapter/otel/bootstrap.py
Normal file
85
adapter/otel/bootstrap.py
Normal 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
57
adapter/otel/logging.py
Normal 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
59
adapter/otel/metrics.py
Normal 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
65
adapter/otel/tracing.py
Normal 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)
|
||||||
|
|
@ -12,7 +12,9 @@ logging:
|
||||||
|
|
||||||
otel:
|
otel:
|
||||||
service_name: web-python-skelet
|
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
|
metric_export_interval: 1000
|
||||||
|
|
||||||
security:
|
security:
|
||||||
|
|
|
||||||
27
config/otel-collector.yaml
Normal file
27
config/otel-collector.yaml
Normal 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]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue