From fb553dbb8d1c4035c5d210f9a37a92607dc7b217 Mon Sep 17 00:00:00 2001 From: justgithubaccount <86586195+justgithubaccount@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:27:20 +0000 Subject: [PATCH] fix: honor OpenTelemetry export configuration --- README.md | 4 +- app/main.py | 3 +- app/observability/tracing.py | 109 ++++++++++++++++++----------------- tests/test_api.py | 13 +++++ tests/test_observability.py | 34 +++++++++++ 5 files changed, 108 insertions(+), 55 deletions(-) create mode 100644 tests/test_observability.py diff --git a/README.md b/README.md index 9d1cfbd..022f0bd 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,9 @@ cp .env.example .env | `NOTION_TOKEN` | Notion API token (опционально) | - | | `NOTION_PAGE_ID` | Notion page ID (опционально) | - | | `OTEL_SERVICE_NAME` | Имя сервиса для трейсинга | `chat-api` | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint | `http://localhost:4318` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP endpoint; export is disabled when empty | - | +| `OTEL_TRACES_EXPORTER` | Set to `otlp` to export traces | `none` | +| `OTEL_LOGS_EXPORTER` | Set to `otlp` to export logs | `none` | ## Структура проекта diff --git a/app/main.py b/app/main.py index 2bfa974..861adbb 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,4 @@ +import os from contextlib import asynccontextmanager from typing import AsyncGenerator @@ -36,7 +37,7 @@ def create_app() -> FastAPI: app = FastAPI( title="Chat Microservice", - version="0.1.0", + version=os.getenv("APP_VERSION", "0.1.0"), lifespan=lifespan, ) diff --git a/app/observability/tracing.py b/app/observability/tracing.py index 19a1123..c110fc5 100644 --- a/app/observability/tracing.py +++ b/app/observability/tracing.py @@ -33,6 +33,19 @@ def setup_tracing(app) -> None: Настройка OpenTelemetry для traces и logs согласно CNCF практикам. Production-ready конфигурация для Kubernetes. """ + sdk_disabled = os.getenv("OTEL_SDK_DISABLED", "false").strip().lower() in { + "1", + "true", + "yes", + } + otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip().rstrip("/") + traces_enabled = os.getenv("OTEL_TRACES_EXPORTER", "none").strip().lower() == "otlp" + logs_enabled = os.getenv("OTEL_LOGS_EXPORTER", "none").strip().lower() == "otlp" + + if sdk_disabled or not otlp_endpoint or not (traces_enabled or logs_enabled): + logging.getLogger(__name__).info("OpenTelemetry export disabled") + return + # Resource с метаданными сервиса и Kubernetes resource = Resource(attributes={ "service.name": os.getenv("OTEL_SERVICE_NAME", "chat-api"), @@ -48,64 +61,53 @@ def setup_tracing(app) -> None: "k8s.pod.uid": os.getenv("K8S_POD_UID", "unknown"), }) - # OTLP endpoint. По умолчанию localhost, чтобы сервис поднимался вне кластера; - # в кластере адрес коллектора приходит через OTEL_EXPORTER_OTLP_ENDPOINT. - otlp_endpoint = os.getenv( - "OTEL_EXPORTER_OTLP_ENDPOINT", - "http://localhost:4318" - ) - # Для HTTP протокола добавляем правильные пути traces_endpoint = f"{otlp_endpoint}/v1/traces" if not otlp_endpoint.endswith("/v1/traces") else otlp_endpoint logs_endpoint = f"{otlp_endpoint}/v1/logs" if not otlp_endpoint.endswith("/v1/logs") else otlp_endpoint # === TRACES === - provider = TracerProvider(resource=resource) - - # Создаем exporter без deprecated параметров - otlp_trace_exporter = OTLPSpanExporter( - endpoint=traces_endpoint, - # Headers для авторизации если нужно - headers={"X-Scope-OrgID": os.getenv("GRAFANA_TENANT_ID", "1")} if os.getenv("GRAFANA_TENANT_ID") else None - ) + provider = None + if traces_enabled: + provider = TracerProvider(resource=resource) + + otlp_trace_exporter = OTLPSpanExporter( + endpoint=traces_endpoint, + headers={"X-Scope-OrgID": os.getenv("GRAFANA_TENANT_ID", "1")} + if os.getenv("GRAFANA_TENANT_ID") + else None, + ) - # BatchSpanProcessor для производительности - trace_processor = BatchSpanProcessor( - otlp_trace_exporter, - max_queue_size=2048, - max_export_batch_size=512, - schedule_delay_millis=5000, - ) - provider.add_span_processor(trace_processor) - trace.set_tracer_provider(provider) + trace_processor = BatchSpanProcessor( + otlp_trace_exporter, + max_queue_size=2048, + max_export_batch_size=512, + schedule_delay_millis=5000, + ) + provider.add_span_processor(trace_processor) + trace.set_tracer_provider(provider) # === LOGS === - log_provider = LoggerProvider(resource=resource) - - otlp_log_exporter = OTLPLogExporter( - endpoint=logs_endpoint, - headers={"X-Scope-OrgID": os.getenv("GRAFANA_TENANT_ID", "1")} if os.getenv("GRAFANA_TENANT_ID") else None - ) - - log_processor = BatchLogRecordProcessor( - otlp_log_exporter, - max_queue_size=2048, - max_export_batch_size=512, - schedule_delay_millis=5000, - ) - log_provider.add_log_record_processor(log_processor) - set_logger_provider(log_provider) - - # Подключаем OpenTelemetry handler к root logger - handler = LoggingHandler( - level=logging.INFO, - logger_provider=log_provider - ) - - # Настройка логирования root_logger = logging.getLogger() root_logger.setLevel(logging.INFO) - root_logger.addHandler(handler) + if logs_enabled: + log_provider = LoggerProvider(resource=resource) + + otlp_log_exporter = OTLPLogExporter( + endpoint=logs_endpoint, + headers={"X-Scope-OrgID": os.getenv("GRAFANA_TENANT_ID", "1")} + if os.getenv("GRAFANA_TENANT_ID") + else None, + ) + + log_processor = BatchLogRecordProcessor( + otlp_log_exporter, + max_queue_size=2048, + max_export_batch_size=512, + schedule_delay_millis=5000, + ) + log_provider.add_log_record_processor(log_processor) + set_logger_provider(log_provider) + root_logger.addHandler(LoggingHandler(level=logging.INFO, logger_provider=log_provider)) # JSON logging для production if os.getenv("ENVIRONMENT") == "production": @@ -144,11 +146,12 @@ def format(self, record: logging.LogRecord) -> str: root_logger.addHandler(console_handler) # FastAPI instrumentation - FastAPIInstrumentor().instrument_app( - app, - tracer_provider=provider, - server_request_hook=server_request_hook - ) + if provider is not None: + FastAPIInstrumentor().instrument_app( + app, + tracer_provider=provider, + server_request_hook=server_request_hook, + ) # Log успешной инициализации logger = logging.getLogger(__name__) diff --git a/tests/test_api.py b/tests/test_api.py index 1801470..1cb8c00 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,9 @@ """API endpoint tests.""" +from fastapi.testclient import TestClient + +from app.main import create_app + def test_health_endpoint(client): """Test GET /health returns ok.""" @@ -18,6 +22,15 @@ def test_root_endpoint(client): assert "version" in data +def test_root_endpoint_uses_deployed_app_version(monkeypatch): + monkeypatch.setenv("APP_VERSION", "1.2.3") + + with TestClient(create_app()) as versioned_client: + response = versioned_client.get("/") + + assert response.json()["version"] == "1.2.3" + + def test_chat_endpoint(client, mock_openrouter): """Test POST /api/v1/chat with mocked LLM.""" response = client.post("/api/v1/chat", json={ diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 0000000..c3e6688 --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,34 @@ +"""Behavioral tests for OpenTelemetry setup.""" + +from fastapi import FastAPI + +from app.observability import tracing + + +def _unexpected_exporter(*args, **kwargs): + raise AssertionError("disabled telemetry attempted to create an OTLP exporter") + + +def test_setup_tracing_respects_disabled_sdk(monkeypatch): + """OTEL_SDK_DISABLED must leave the application uninstrumented.""" + monkeypatch.setenv("OTEL_SDK_DISABLED", "true") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318") + monkeypatch.setenv("OTEL_TRACES_EXPORTER", "otlp") + monkeypatch.setattr(tracing, "OTLPSpanExporter", _unexpected_exporter) + monkeypatch.setattr(tracing, "OTLPLogExporter", _unexpected_exporter) + + app = FastAPI() + tracing.setup_tracing(app) + + +def test_setup_tracing_respects_disabled_exporters(monkeypatch): + """Disabled exporters must not create telemetry middleware.""" + monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") + monkeypatch.setenv("OTEL_TRACES_EXPORTER", "none") + monkeypatch.setenv("OTEL_LOGS_EXPORTER", "none") + monkeypatch.setattr(tracing, "OTLPSpanExporter", _unexpected_exporter) + monkeypatch.setattr(tracing, "OTLPLogExporter", _unexpected_exporter) + + app = FastAPI() + tracing.setup_tracing(app)