Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

## Структура проекта

Expand Down
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator

Expand Down Expand Up @@ -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,
)

Expand Down
109 changes: 56 additions & 53 deletions app/observability/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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":
Expand Down Expand Up @@ -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__)
Expand Down
13 changes: 13 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -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={
Expand Down
34 changes: 34 additions & 0 deletions tests/test_observability.py
Original file line number Diff line number Diff line change
@@ -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)
Loading