diff --git a/Makefile b/Makefile index cd0920d..1c6eaf3 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/adapter/config/loader.py b/adapter/config/loader.py index 017e1be..c91b811 100644 --- a/adapter/config/loader.py +++ b/adapter/config/loader.py @@ -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, diff --git a/adapter/config/model.py b/adapter/config/model.py index 6b24288..dd8643c 100644 --- a/adapter/config/model.py +++ b/adapter/config/model.py @@ -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 diff --git a/adapter/otel/__init__.py b/adapter/otel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapter/otel/bootstrap.py b/adapter/otel/bootstrap.py new file mode 100644 index 0000000..cf332bd --- /dev/null +++ b/adapter/otel/bootstrap.py @@ -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, + ) diff --git a/adapter/otel/logging.py b/adapter/otel/logging.py new file mode 100644 index 0000000..2349261 --- /dev/null +++ b/adapter/otel/logging.py @@ -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] diff --git a/adapter/otel/metrics.py b/adapter/otel/metrics.py new file mode 100644 index 0000000..48d1278 --- /dev/null +++ b/adapter/otel/metrics.py @@ -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 diff --git a/adapter/otel/tracing.py b/adapter/otel/tracing.py new file mode 100644 index 0000000..1dddf3a --- /dev/null +++ b/adapter/otel/tracing.py @@ -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) diff --git a/config/app.yaml b/config/app.yaml index 4cb282c..79ad34e 100644 --- a/config/app.yaml +++ b/config/app.yaml @@ -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: diff --git a/config/otel-collector.yaml b/config/otel-collector.yaml new file mode 100644 index 0000000..fb1404e --- /dev/null +++ b/config/otel-collector.yaml @@ -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]