Добавлен OTLP-провайдер, реализовано отбрасывание метрик и трейсов в observability + добавлен перезапуск nginx при пересборке контейнеров (ошибка, когда докер меняет адреса сервисов)

This commit is contained in:
mi
2026-07-29 15:15:40 +03:00
parent 3ed7239efa
commit 41e19005fb
43 changed files with 3016 additions and 83 deletions
+9 -3
View File
@@ -174,12 +174,18 @@ SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=change-me
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_SERVICE_NAME_API=api-backend
OTEL_SERVICE_NAME_SMS_API=sms-service
OTEL_SERVICE_NAME_SMS_WORKER=sms-worker
SMS_METRICS_PORT=9464
#Если есть внешний OTLP-сервис, замените (Точный формат авторизации зависит от провайдера):
#Если внешнего OTLP-сервиса пока нет, otlp.example.invalid:4317 можно временно оставить, но Collector будет постоянно пытаться подключиться, писать предупреждения и накапливать очередь.
OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
OTEL_REMOTE_AUTH_HEADER=change-me
# Сейчас соответствуют 10% трассировок и очереди 10000
# Также обнаружена особенность проекта: OTEL_TRACES_SAMPLER_ARG и OTEL_QUEUE_SIZE сейчас фактически не подставляются в конфигурацию — там жёстко установлены 10% и 10000. Поэтому менять эти две переменные пока бессмысленно.
OTEL_TRACES_SAMPLER_ARG=0.10
# true только для plaintext OTLP внутри доверенной приватной сети (например self-hosted SigNoz).
OTEL_REMOTE_TLS_INSECURE=false
# SDK отправляет все spans локальному Collector; решение о хранении принимает tail_sampling.
OTEL_TRACES_SAMPLER=always_on
# OTEL_QUEUE_SIZE пока документирует целевую ёмкость, конфигурация Collector закреплена в YAML.
OTEL_QUEUE_SIZE=10000
+43 -12
View File
@@ -50,6 +50,7 @@ from app.integrations import (
S3Client,
SafetyClient,
)
from app.metrics import AUTH_BOOTSTRAP, HTTP_DURATION, HTTP_REQUESTS, RATE_LIMIT_DECISIONS
from app.notification_routes import router as notification_router
from app.notification_service import synchronize_source_tokens
from app.realtime import RealtimeFanout
@@ -86,6 +87,7 @@ from app.services import (
start_session,
)
from app.settings import get_settings
from app.telemetry import add_trace_context, current_trace_id, init_telemetry, instrument_fastapi
def configure_logging(level: str) -> None:
@@ -93,6 +95,7 @@ def configure_logging(level: str) -> None:
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
add_trace_context,
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
@@ -113,6 +116,7 @@ async def refresh_settings_cache(app: FastAPI) -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
telemetry = init_telemetry()
configure_logging(settings.log_level)
app.state.settings = settings
app.state.db = Database(settings.database_url)
@@ -138,14 +142,18 @@ async def lifespan(app: FastAPI):
await app.state.jwks.refresh()
except Exception:
structlog.get_logger().warning("jwks.warmup_failed")
yield
settings_task.cancel()
with suppress(asyncio.CancelledError):
await settings_task
await app.state.http.aclose()
await app.state.redis.aclose()
await app.state.redis_rt.aclose()
await app.state.db.close()
try:
yield
finally:
settings_task.cancel()
with suppress(asyncio.CancelledError):
await settings_task
await app.state.http.aclose()
await app.state.redis.aclose()
await app.state.redis_rt.aclose()
await app.state.db.close()
if telemetry:
telemetry.shutdown()
app = FastAPI(
@@ -221,7 +229,7 @@ async def request_context(request: Request, call_next: Any) -> Response:
except ValueError:
request_id = str(uuid.uuid4())
request.state.request_id = request_id
request.state.trace_id = request_trace_id(request)
request.state.trace_id = current_trace_id() or request_trace_id(request)
request.state.user_agent_hash = user_agent_hash(request)
request.state.started_at = time.monotonic()
structlog.contextvars.clear_contextvars()
@@ -230,7 +238,6 @@ async def request_context(request: Request, call_next: Any) -> Response:
trace_id=request.state.trace_id,
ux_session_id=request.headers.get("X-Ux-Session-Id"),
method=request.method,
route=request.url.path,
**{"service.name": "api-backend"},
)
origin = request.headers.get("Origin")
@@ -252,10 +259,21 @@ async def request_context(request: Request, call_next: Any) -> Response:
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = response.headers.get("Cache-Control", "no-store")
duration_seconds = time.monotonic() - request.state.started_at
route = getattr(request.scope.get("route"), "path", "unmatched")
metric_attributes = {
"service.name": "api-backend",
"http.route": route,
"http.request.method": request.method,
"http.response.status_class": f"{response.status_code // 100}xx",
}
HTTP_REQUESTS.add(1, metric_attributes)
HTTP_DURATION.record(duration_seconds, metric_attributes)
log.info(
"request.complete",
route=route,
status_code=response.status_code,
duration_ms=round((time.monotonic() - request.state.started_at) * 1000, 2),
duration_ms=round(duration_seconds * 1000, 2),
)
return response
@@ -396,17 +414,21 @@ async def enforce_limit(
retry_after = await request.app.state.rate_limiter.consume(key, limit, window)
except DependencyFailure:
if fail_closed:
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "dependency_error"})
raise DomainError(
"dependency_unavailable", 503, "Rate limit service is unavailable"
) from None
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "bypass"})
return
if retry_after:
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "denied"})
raise DomainError(
"rate_limit_exceeded",
429,
"Rate limit exceeded",
{"retry_after": retry_after},
)
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "allowed"})
@app.get("/health/live", tags=["health"])
@@ -561,7 +583,13 @@ async def content(
async def auth_bootstrap(
body: BootstrapRequest, request: Request, db: Session, auth: PrincipalDep, settings: SnapshotDep
):
return await bootstrap(db, auth, body, settings, audit_context(request))
try:
result = await bootstrap(db, auth, body, settings, audit_context(request))
except Exception:
AUTH_BOOTSTRAP.add(1, {"outcome": "error"})
raise
AUTH_BOOTSTRAP.add(1, {"outcome": "success"})
return result
@app.post("/api/v1/consents", status_code=201, tags=["auth"])
@@ -1106,6 +1134,9 @@ async def realtime(websocket: WebSocket):
return
instrument_fastapi(app)
def run() -> None:
settings = get_settings()
uvicorn.run(
@@ -0,0 +1,21 @@
from opentelemetry import metrics
meter = metrics.get_meter("han.api")
HTTP_REQUESTS = meter.create_counter(
"han_http_requests_total",
description="Completed HTTP requests",
)
HTTP_DURATION = meter.create_histogram(
"han_http_request_duration_seconds",
unit="s",
description="HTTP request duration",
)
AUTH_BOOTSTRAP = meter.create_counter(
"han_auth_bootstrap_total",
description="Authentication bootstrap outcomes",
)
RATE_LIMIT_DECISIONS = meter.create_counter(
"han_rate_limit_decisions_total",
description="Rate-limit decisions",
)
@@ -0,0 +1,111 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from fastapi import FastAPI
from opentelemetry import metrics, trace
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.propagate import set_global_textmap
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 opentelemetry.sdk.trace.sampling import ALWAYS_ON
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
@dataclass(slots=True)
class TelemetryRuntime:
tracer_provider: TracerProvider
meter_provider: MeterProvider
def shutdown(self) -> None:
self.meter_provider.shutdown()
self.tracer_provider.shutdown()
_runtime: TelemetryRuntime | None = None
def _resource(service_name: str) -> Resource:
return Resource.create(
{
"service.name": service_name,
"service.namespace": "han-chat",
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
"deployment.environment": os.getenv("APP_ENV", "production-like"),
}
)
def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
global _runtime
if _runtime is not None:
return _runtime
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
if not endpoint:
return None
resource = _resource(service_name or os.getenv("OTEL_SERVICE_NAME", "api-backend"))
insecure = endpoint.startswith("http://")
set_global_textmap(TraceContextTextMapPropagator())
tracer_provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3),
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512,
export_timeout_millis=3000,
)
)
trace.set_tracer_provider(tracer_provider)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
export_interval_millis=30000,
export_timeout_millis=3000,
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
HTTPXClientInstrumentor().instrument()
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
RedisInstrumentor().instrument()
BotocoreInstrumentor().instrument()
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
return _runtime
def instrument_fastapi(app: FastAPI) -> None:
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/health/live,/health/ready,/nginx-health/live",
)
def add_trace_context(
_logger: Any,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
context = trace.get_current_span().get_span_context()
if context.is_valid:
event_dict["trace_id"] = format(context.trace_id, "032x")
event_dict["span_id"] = format(context.span_id, "016x")
return event_dict
def current_trace_id() -> str | None:
context = trace.get_current_span().get_span_context()
return format(context.trace_id, "032x") if context.is_valid else None
@@ -9,6 +9,14 @@ dependencies = [
"boto3>=1.39,<2",
"fastapi>=0.116,<1",
"httpx>=0.28,<1",
"opentelemetry-api>=1.44,<2",
"opentelemetry-exporter-otlp-proto-grpc>=1.44,<2",
"opentelemetry-instrumentation-botocore>=0.65b0,<1",
"opentelemetry-instrumentation-fastapi>=0.65b0,<1",
"opentelemetry-instrumentation-httpx>=0.65b0,<1",
"opentelemetry-instrumentation-redis>=0.65b0,<1",
"opentelemetry-instrumentation-sqlalchemy>=0.65b0,<1",
"opentelemetry-sdk>=1.44,<2",
"phonenumbers>=9,<10",
"prometheus-client>=0.22,<1",
"pydantic-settings>=2.10,<3",
@@ -0,0 +1,25 @@
from opentelemetry.sdk.trace import TracerProvider
from app import telemetry
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.setattr(telemetry, "_runtime", None)
assert telemetry.init_telemetry("test-service") is None
def test_structlog_processor_adds_active_trace_context_only() -> None:
provider = TracerProvider()
tracer = provider.get_tracer("test")
event = {"event": "safe", "request_id": "request-1"}
with tracer.start_as_current_span("operation"):
result = telemetry.add_trace_context(None, "info", event)
assert result["event"] == "safe"
assert result["request_id"] == "request-1"
assert len(result["trace_id"]) == 32
assert len(result["span_id"]) == 16
assert set(result) == {"event", "request_id", "trace_id", "span_id"}
@@ -535,6 +535,33 @@ docker compose --env-file .env logs --tail=200 <SERVICE_NAME>
docker inspect "$(docker compose --env-file .env ps -q <SERVICE_NAME>)"
```
### 12.1. Повторная раскатка upstream при уже работающем nginx
Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После
`up --build`, `pull`, rollback или `--force-recreate` контейнер может получить
новый IP, а работающий nginx продолжит использовать старый и вернёт `502
Connection refused`.
После пересоздания `api-backend`, `keycloak`, `sms-service` или
`bitrix-local-app` обязательно выполните:
```sh
docker compose --env-file .env up -d --wait \
api-backend keycloak sms-service bitrix-local-app
docker compose --env-file .env exec -T nginx \
nginx -t -c /tmp/nginx.conf
docker compose --env-file .env kill -s HUP nginx
curl -fsS "https://${PUBLIC_HOST}/api/v1/public/app-config" | jq
curl -fsS \
"https://${PUBLIC_HOST}/auth/realms/han-chat/.well-known/openid-configuration" |
jq
```
Не используйте bare-команды `nginx -t` и `nginx -s reload`: рабочая
конфигурация находится в `/tmp/nginx.conf`, PID — в `/tmp/nginx.pid`, а
контейнер использует read-only filesystem.
## 13. Первоначальный выпуск TLS-сертификата
Для ACME требуется работающий nginx по HTTP. В `.env` оставьте
+10
View File
@@ -148,9 +148,19 @@ docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
notification-expire-worker notification-draft-cleanup-worker
docker compose up -d bitrix-local-app bitrix-sync
docker compose up -d nginx
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
docker compose kill -s HUP nginx
docker compose ps
```
Nginx resolves Docker upstream names when its configuration is loaded. After
recreating `api-backend`, `keycloak`, `sms-service`, or `bitrix-local-app`,
wait for readiness, validate the active `/tmp/nginx.conf`, and signal the
master process with `HUP` as shown above. Do not use bare `nginx -t` or
`nginx -s reload`: they target the default config/PID under read-only
`/var/run`, not the running Nginx instance.
- [ ] No restart loop/OOM; critical readiness is green.
- [ ] `notification-expire-worker` runs daily closure with an advisory lock; `notification-draft-cleanup-worker` removes expired drafts/S3 objects. Both entrypoints exist in the installed image.
- [ ] Only documented Bitrix not-installed/sync-stub degradation remains.
+10
View File
@@ -170,9 +170,19 @@ docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
notification-expire-worker notification-draft-cleanup-worker
docker compose up -d bitrix-local-app bitrix-sync
docker compose up -d nginx
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
docker compose kill -s HUP nginx
docker compose ps
```
Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После
любого пересоздания `api-backend`, `keycloak`, `sms-service` или
`bitrix-local-app` дождитесь их readiness, проверьте именно рабочий
`/tmp/nginx.conf` и отправьте master-процессу `HUP`, как показано выше.
Обычные `nginx -t` и `nginx -s reload` использовать нельзя: они обращаются к
дефолтному config/PID в read-only `/var/run` и не перезагружают рабочий Nginx.
- [ ] Нет циклических перезапусков и OOM; критические readiness-проверки успешны.
- [ ] `notification-expire-worker` выполняет ежедневное закрытие с advisory lock; `notification-draft-cleanup-worker` очищает просроченные drafts/S3. Оба entrypoint присутствуют в установленном образе.
- [ ] Сохраняется только документированная деградация: Bitrix не установлен и bitrix-sync работает как заглушка.
@@ -0,0 +1,445 @@
#!/usr/bin/env bash
# Создаёт 9 персональных уведомлений всех видов контура P для одного user_id.
# Запускать на ВМ из каталога backend: /opt/han-chat/backend
#
# cd /opt/han-chat/backend
# sed -i 's/\r$//' deployment/scripts/seed-personal-notifications-test.sh
# chmod +x deployment/scripts/seed-personal-notifications-test.sh
# ./deployment/scripts/seed-personal-notifications-test.sh
#
# Токен берётся из .env (NOTIFICATIONS_TOKEN_PRODUCER_TEST) — тот же, что у api-backend.
# При старте api-backend синхронизирует hash токена в notification_sources.
set -euo pipefail
cd "$(dirname "$0")/../.."
ENV_FILE="${ENV_FILE:-.env}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Не найден $ENV_FILE. Запускайте из каталога backend." >&2
exit 1
fi
env_value() {
python3 - "$ENV_FILE" "$1" <<'PY'
import sys
from pathlib import Path
path, wanted = sys.argv[1:]
for raw in Path(path).read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
if key.strip() == wanted:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
print(value)
break
else:
raise SystemExit(f"missing environment variable: {wanted}")
PY
}
trim_token() {
printf '%s' "$1" | tr -d '\r\n\t '
}
TOKEN="$(trim_token "${NOTIFICATIONS_TOKEN_PRODUCER_TEST:-}")"
if [[ -z "$TOKEN" ]]; then
TOKEN="$(trim_token "$(env_value NOTIFICATIONS_TOKEN_PRODUCER_TEST 2>/dev/null || true)")"
fi
if [[ -z "$TOKEN" ]]; then
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (из .env не найден): " TOKEN
echo
TOKEN="$(trim_token "$TOKEN")"
fi
read -rp "USER_ID клиента: " USER_ID
USER_ID="$(trim_token "$USER_ID")"
if [[ -z "${TOKEN}" || -z "${USER_ID}" ]]; then
echo "TOKEN и USER_ID обязательны." >&2
exit 1
fi
echo "Токен: ${#TOKEN} символов (первые 8: ${TOKEN:0:8}…)"
PAYLOAD_DIR="$(mktemp -d)"
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
BASE_TS="$(date +%s)"
RUN_ID="manual-test-${BASE_TS}"
NOW="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
DT_URGENT="$(date -u -d "${NOW} +0 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+0S +"%Y-%m-%dT%H:%M:%SZ")"
DT_PAYMENT="$(date -u -d "${NOW} - 60 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-60S +"%Y-%m-%dT%H:%M:%SZ")"
DT_DOCS_REQ="$(date -u -d "${NOW} - 120 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-120S +"%Y-%m-%dT%H:%M:%SZ")"
DT_DOCS_READY="$(date -u -d "${NOW} - 180 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-180S +"%Y-%m-%dT%H:%M:%SZ")"
DT_STATUS="$(date -u -d "${NOW} - 240 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-240S +"%Y-%m-%dT%H:%M:%SZ")"
DT_REMINDER="$(date -u -d "${NOW} - 300 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-300S +"%Y-%m-%dT%H:%M:%SZ")"
DT_NEWS="$(date -u -d "${NOW} - 360 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-360S +"%Y-%m-%dT%H:%M:%SZ")"
DT_PROMO="$(date -u -d "${NOW} - 420 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-420S +"%Y-%m-%dT%H:%M:%SZ")"
DT_ADS="$(date -u -d "${NOW} - 480 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-480S +"%Y-%m-%dT%H:%M:%SZ")"
DEADLINE_URGENT="$(date -u -d "${NOW} + 2 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+2d +"%Y-%m-%dT%H:%M:%SZ")"
DEADLINE_DOCS="$(date -u -d "${NOW} + 5 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+5d +"%Y-%m-%dT%H:%M:%SZ")"
DEADLINE_REMINDER="$(date -u -d "${NOW} + 1 day" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+1d +"%Y-%m-%dT%H:%M:%SZ")"
EXPIRE_PAYMENT="$(date -u -d "${NOW} + 3 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+3d +"%Y-%m-%dT%H:%M:%SZ")"
write_payload() {
local name="$1"
cat > "${PAYLOAD_DIR}/${name}.json"
}
create_notification() {
local label="$1"
local payload_file="${PAYLOAD_DIR}/${label}.json"
if [[ ! -f "$payload_file" ]]; then
echo "Нет payload: ${payload_file}" >&2
return 1
fi
echo
echo "========== ${label} =========="
docker run --rm \
--network han-chat-backend \
-v "${payload_file}:/payload.json:ro" \
curlimages/curl:latest \
-sS -i \
-X POST \
'http://api-backend:8000/internal/notifications/v1/notifications' \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json; charset=utf-8' \
--data-binary @/payload.json
}
prepare_docs_in_s3() {
echo >&2
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
docker compose --env-file "$ENV_FILE" exec -T \
-e "USER_ID=${USER_ID}" \
-e "RUN_ID=${RUN_ID}" \
api-backend python3 - <<'PY'
import hashlib
import os
import uuid
import boto3
from botocore.client import Config
from app.settings import Settings
USER_ID = os.environ["USER_ID"]
settings = Settings()
client = boto3.client(
"s3",
endpoint_url=str(settings.selectel_s3_endpoint_url),
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}),
)
bucket = settings.selectel_s3_bucket_documents
fixtures = [
{
"prefix": "CONTRACT",
"title": "Уведомление о постановке на миграционный учёт.pdf",
"body": b"""%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj
xref
0 4
trailer<</Size 4/Root 1 0 R>>
startxref
100
%%EOF
""",
},
{
"prefix": "RESULTS",
"title": "Справка о соблюдении миграционного законодательства.pdf",
"body": b"""%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj
xref
0 4
trailer<</Size 4/Root 1 0 R>>
startxref
100
%%EOF
""",
},
]
lines: list[str] = []
for item in fixtures:
doc_id = str(uuid.uuid4())
key = f"documents/users/{USER_ID}/{doc_id}"
body = item["body"]
checksum = hashlib.sha256(body).hexdigest()
client.put_object(
Bucket=bucket,
Key=key,
Body=body,
ContentType="application/pdf",
)
p = item["prefix"]
lines.append(
f"{p}_KEY={key}\n"
f"{p}_TITLE={item['title']}\n"
f"{p}_SIZE={len(body)}\n"
f"{p}_SHA256={checksum}"
)
print("\n".join(lines))
PY
}
echo "Run ID: ${RUN_ID}"
echo "User ID: ${USER_ID}"
read -rp "Подготовить документы в S3 для docs_ready? [Y/n]: " PREP_DOCS
PREP_DOCS="${PREP_DOCS:-Y}"
CONTRACT_KEY=""
CONTRACT_TITLE=""
CONTRACT_SIZE=""
CONTRACT_SHA256=""
RESULTS_KEY=""
RESULTS_TITLE=""
RESULTS_SIZE=""
RESULTS_SHA256=""
if [[ "${PREP_DOCS^^}" != "N" ]]; then
PREP_OUT="$(prepare_docs_in_s3)"
echo "${PREP_OUT}"
while IFS= read -r line; do
[[ "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || continue
declare "${BASH_REMATCH[1]}=${BASH_REMATCH[2]}"
done <<< "${PREP_OUT}"
fi
# --- payloads ---
write_payload urgent <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "urgent",
"source": "producer_test",
"external_id": "${RUN_ID}-urgent",
"notification_datetime": "${DT_URGENT}",
"header": "Истекает срок постановки на учёт",
"text": "По данным сервиса, срок уведомления о месте пребывания истекает 29 июля — просрочка влечёт административную ответственность.",
"priority_override": 1,
"details": {
"deadline": "${DEADLINE_URGENT}",
"details_header": "Срочно: соблюдение сроков миграционного учёта",
"details_text": "Федеральный закон № 109-ФЗ обязывает иностранного гражданина в течение 7 рабочих дней с даты въезда подать уведомление о прибытии в место пребывания (если иное не предусмотрено для вашего правового статуса).\n\nПо имеющимся данным крайний срок для вашего случая — 29.07.2026. Нарушение сроков может повлечь штраф от 2 000 до 5 000 ₽ и, при повторном нарушении, более серьёзные последствия, включая административное выдворение.\n\nЕсли уведомление уже подано — отметьте «Готово»; если нужна помощь — напишите оператору в чат.",
"todo_header": "Что проверить сейчас",
"todo_plan": [
{"number": 1, "text": "Сверьте дату въезда и адрес фактического проживания в анкете личного кабинета."},
{"number": 2, "text": "Подготовьте копии паспорта, миграционной карты и документа о праве пребывания (виза, РВП, ВНЖ, патент)."},
{"number": 3, "text": "Нажмите «Готово», если уведомление уже подано через МВД или принимающую сторону."},
{"number": 4, "text": "При сомнениях выберите «Сделаю позже» и задайте вопрос оператору — укажите город и тип документа."}
]
}
}
JSON
write_payload payment_pending <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "payment_pending",
"source": "producer_test",
"external_id": "${RUN_ID}-payment_pending",
"notification_datetime": "${DT_PAYMENT}",
"header": "Оплата сопровождения по миграционному учёту",
"text": "Счёт №МИГ-2026-0718 на 18 500 ₽ за подготовку пакета и подачу уведомления — оплатите до 30 июля.",
"price": "18500.00",
"old_price": "22000.00",
"date_expired": "${EXPIRE_PAYMENT}",
"payment_url": "https://pay.han0107.ru/checkout/test-${RUN_ID}"
}
JSON
write_payload docs_required <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "docs_required",
"source": "producer_test",
"external_id": "${RUN_ID}-docs_required",
"notification_datetime": "${DT_DOCS_REQ}",
"header": "Загрузите документы для миграционного учёта",
"text": "Для проверки соблюдения миграционного законодательства нужен комплект документов — загрузите до 01 августа.",
"details": {
"deadline": "${DEADLINE_DOCS}",
"details_header": "Документы для постановки на миграционный учёт",
"details_text": "Специалист проверит комплект в течение 1 рабочего дня после отправки. Принимаются чёткие фото или сканы в JPG, PNG, PDF. Каждый файл — до 10 МБ, не более 10 файлов за одну отправку.\n\nВсе документы должны быть действительными на дату проверки. Если какого-то документа пока нет (например, договор найма ещё не подписан), загрузите доступные — оператор подскажет порядок действий и допустимые альтернативы.",
"todo_header": "Необходимый пакет",
"todo_plan": [
{"number": 1, "text": "Паспорт: страница с фото, действующая виза или иной документ на право пребывания."},
{"number": 2, "text": "Миграционная карта с отметкой о въезде (обе стороны)."},
{"number": 3, "text": "Документ о месте пребывания: договор найма, свидетельство собственности или письмо принимающей стороны."},
{"number": 4, "text": "При трудовой деятельности — копия патента или разрешения на работу (если применимо)."},
{"number": 5, "text": "Нажмите «Отправить документы», когда все файлы приложены."}
],
"send_documents": true
}
}
JSON
if [[ -n "${CONTRACT_KEY}" && -n "${RESULTS_KEY}" ]]; then
write_payload docs_ready <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "docs_ready",
"source": "producer_test",
"external_id": "${RUN_ID}-docs_ready",
"notification_datetime": "${DT_DOCS_READY}",
"header": "Миграционные документы готовы",
"text": "Уведомление о постановке на учёт и справка о соблюдении требований закона сформированы — скачайте в деталях.",
"details": {
"details_header": "Документы по миграционному учёту",
"details_text": "Документы подготовлены на основании переданных вами данных и проверены специалистом. Сохраните копии на устройство — они могут понадобиться при проверке или продлении статуса пребывания.\n\nСсылки на скачивание действуют ограниченное время. После первого скачивания карточка скроется с главной, но останется в Центре уведомлений до нажатия «Понятно».",
"documents": [
{
"object_key": "${CONTRACT_KEY}",
"title": "${CONTRACT_TITLE}",
"mime_type": "application/pdf",
"size_bytes": ${CONTRACT_SIZE},
"checksum_sha256": "${CONTRACT_SHA256}"
},
{
"object_key": "${RESULTS_KEY}",
"title": "${RESULTS_TITLE}",
"mime_type": "application/pdf",
"size_bytes": ${RESULTS_SIZE},
"checksum_sha256": "${RESULTS_SHA256}"
}
]
}
}
JSON
fi
write_payload status_changed <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "status_changed",
"source": "producer_test",
"external_id": "${RUN_ID}-status_changed",
"notification_datetime": "${DT_STATUS}",
"header": "Статус миграционной заявки обновлён",
"text": "Заявка №МГ-8841 переведена на этап «Проверка комплектности документов».",
"details": {
"details_header": "Ход рассмотрения заявки №МГ-8841",
"details_text": "27.07.2026 в 11:42 специалист принял ваш пакет документов в работу. Сейчас выполняется проверка на соответствие требованиям миграционного законодательства РФ: сроки пребывания, адрес учёта, основания для трудовой деятельности.\n\nОжидаемое время на этом этапе: 1–2 рабочих дня. При выявлении недостающих документов вы получите отдельное уведомление с перечнем. При успешной проверке будет сформировано уведомление о постановке на учёт.",
"todo_header": "Этапы обработки",
"todo_plan": [
{"number": 1, "text": "Документы получены — выполнено 25.07.2026"},
{"number": 2, "text": "Первичная верификация — выполнено 27.07.2026"},
{"number": 3, "text": "Проверка комплектности и сроков — в работе"},
{"number": 4, "text": "Формирование уведомления / рекомендаций — ожидает"}
]
}
}
JSON
write_payload reminder <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "reminder",
"source": "producer_test",
"external_id": "${RUN_ID}-reminder",
"notification_datetime": "${DT_REMINDER}",
"header": "Напоминание: продление патента",
"text": "28 июля истекает срок действия патента — подайте заявление на продление заранее.",
"details": {
"deadline": "${DEADLINE_REMINDER}",
"details_header": "Сроки продления документа на право работы",
"details_text": "Патент на работу необходимо продлевать заблаговременно — подача заявления рекомендуется не позднее чем за 10–15 рабочих дней до даты окончания действия. Просрочка означает прекращение права на трудовую деятельность и риск штрафа по ст. 18.15 КоАП РФ.\n\nДля продления потребуются: действующий патент, чеки об оплате авансовых платежей по НДФЛ, полис ДМС, сертификат о знании русского языка (если срок действия истекает), договор найма или иной документ о месте пребывания.",
"todo_header": "Чек-лист перед подачей",
"todo_plan": [
{"number": 1, "text": "Проверьте дату окончания патента в личном кабинете или на бланке документа."},
{"number": 2, "text": "Убедитесь, что авансовые платежи по НДФЛ оплачены без просрочки."},
{"number": 3, "text": "Подготовьте сканы документов — при необходимости загрузите через уведомление «Требуются документы»."},
{"number": 4, "text": "При вопросах напишите оператору — укажите регион и номер патента."}
]
}
}
JSON
write_payload news <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "news",
"source": "producer_test",
"external_id": "${RUN_ID}-news",
"notification_datetime": "${DT_NEWS}",
"header": "Изменения в правилах миграционного учёта",
"text": "С 1 августа 2026 уточнены сроки подачи уведомлений при смене адреса пребывания.",
"details": {
"details_header": "Что изменилось для иностранных граждан",
"details_text": "С 01.08.2026 при смене адреса фактического проживания в том же субъекте РФ уведомление необходимо подать в течение 3 рабочих дней (ранее — 7). При переезде в другой регион срок остаётся 7 рабочих дней с даты регистрации по новому адресу.\n\nСервис HAN напомнит о приближающихся сроках через Центр уведомлений. Рекомендуем заранее подготовить копии договора найма и отметку о регистрации — это ускорит проверку оператором.\n\nПодробности процедуры — в чате с оператором или на официальном портале МВД России."
}
}
JSON
write_payload promo_personal <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "promo_personal",
"source": "producer_test",
"external_id": "${RUN_ID}-promo_personal",
"notification_datetime": "${DT_PROMO}",
"header": "Скидка 15% на годовое сопровождение",
"text": "Персональное предложение: контроль сроков патента, учёта и уведомлений — до конца месяца.",
"price": "15725.00",
"old_price": "18500.00",
"date_expired": "${EXPIRE_PAYMENT}",
"chat_message_text": "Здравствуйте! Хочу подключить годовое сопровождение по соблюдению миграционного законодательства со скидкой 15%. Подскажите, что входит в пакет и как оформить."
}
JSON
write_payload ads_personal <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "ads_personal",
"source": "producer_test",
"external_id": "${RUN_ID}-ads_personal",
"notification_datetime": "${DT_ADS}",
"header": "Бесплатная проверка миграционного статуса",
"text": "15 минут с экспертом: оценим риски и составим чек-лист обязательных действий.",
"chat_message_text": "Здравствуйте! Хочу воспользоваться бесплатной проверкой миграционного статуса. Подскажите, как записаться и какие документы подготовить к консультации."
}
JSON
# --- отправка ---
create_notification urgent
create_notification payment_pending
create_notification docs_required
if [[ -f "${PAYLOAD_DIR}/docs_ready.json" ]]; then
create_notification docs_ready
else
echo
echo "========== docs_ready — ПРОПУЩЕН =========="
fi
create_notification status_changed
create_notification reminder
create_notification news
create_notification promo_personal
create_notification ads_personal
echo
echo "========== Готово =========="
echo "External ID prefix: ${RUN_ID}-*"
echo
echo "Если видите 401: проверьте NOTIFICATIONS_TOKEN_PRODUCER_TEST в .env и перезапустите api-backend."
echo " grep NOTIFICATIONS_TOKEN_PRODUCER_TEST .env"
echo " docker compose --env-file .env up -d --force-recreate api-backend"
@@ -9,5 +9,5 @@ flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit
docker compose --profile certbot run --rm certbot renew \
--webroot -w /var/www/certbot --quiet
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
docker compose exec -T nginx nginx -s reload
docker compose kill -s HUP nginx
echo "{\"event\":\"tls.renew.completed\",\"timestamp\":\"$(date -u +%FT%TZ)\"}"
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -Eeuo pipefail
NETWORK="${OBSERVABILITY_NETWORK:-han-chat-observability}"
COLLECTOR_SERVICE="${COLLECTOR_SERVICE:-otel-collector}"
errors=0
ok() { printf 'OK %s\n' "$*"; }
fail() { printf 'FAIL %s\n' "$*" >&2; errors=$((errors + 1)); }
collector_id="$(docker compose ps -q "$COLLECTOR_SERVICE" 2>/dev/null || true)"
if [[ -n "$collector_id" ]] &&
[[ "$(docker inspect --format '{{.State.Status}}' "$collector_id")" == running ]]; then
ok "Collector service is running"
else
fail "Collector service '$COLLECTOR_SERVICE' is not running"
fi
for service in sms-service sms-worker; do
if docker compose exec -T "$service" python - <<'PY' >/dev/null 2>&1
import os
import urllib.request
port = os.environ.get("SMS_METRICS_PORT", "9464") if "worker" in os.environ.get("OTEL_SERVICE_NAME", "") else "8080"
urllib.request.urlopen(f"http://127.0.0.1:{port}/metrics", timeout=3).read(1024)
PY
then
ok "${service} metrics endpoint"
else
fail "${service} metrics endpoint"
fi
done
docker run --rm --network "$NETWORK" \
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
--service han-chat-e2e-canary --traces 100 --rate 20 >/dev/null \
&& ok "100 canary traces submitted" \
|| fail "telemetrygen failed"
bad_logs="$(
docker compose logs --since=10m "$COLLECTOR_SERVICE" 2>&1 |
grep -Ei 'queue is full|connection refused|tls:|Unauthenticated|Permanent error' || true
)"
if [[ -z "$bad_logs" ]]; then
ok "No exporter/queue errors in last 10 minutes"
else
fail "Collector reports exporter/queue errors"
printf '%s\n' "$bad_logs" >&2
fi
if ((errors)); then
printf 'Observability verification failed: %d check(s)\n' "$errors" >&2
exit 1
fi
cat <<'EOF'
Локальный канал исправен. В SigNoz проверьте за последние 15 минут:
service.name = han-chat-e2e-canary
service.namespace = han-chat
Затем выполните synthetic API request и проверьте общий trace между
api-backend и dependency spans, service.version и deployment.environment.
EOF
@@ -9,6 +9,8 @@ import type { NotificationItem } from "../types";
import { ErrorNotice, Loading } from "../ui";
import { NotificationCard } from "./NotificationCard";
const CARD_GAP = spacing.sm;
export function NotificationCarousel({
authenticated,
autoplay = false,
@@ -27,6 +29,7 @@ export function NotificationCarousel({
const [hiddenGuestIds, setHiddenGuestIds] = useState<Set<string>>(() => new Set());
const window = useWindowDimensions();
const cardWidth = Math.max(0, Math.min(window.width, layout.maxWidth) - spacing.lg * 2);
const pageWidth = cardWidth + CARD_GAP;
const catalog = useQuery({
queryKey: notificationKeys.catalog,
queryFn: notificationApi.catalog,
@@ -57,6 +60,12 @@ export function NotificationCarousel({
list.current?.scrollToIndex({ index: next, animated: true });
};
const syncActiveIndex = (offset: number) => {
if (pageWidth <= 0) return;
const next = Math.min(data.length - 1, Math.max(0, Math.round(offset / pageWidth)));
setActiveIndex((current) => current === next ? current : next);
};
useEffect(() => {
if (!autoplay || data.length < 2) return;
const timer = setInterval(() => {
@@ -98,17 +107,15 @@ export function NotificationCarousel({
horizontal
data={data}
decelerationRate="fast"
pagingEnabled
snapToInterval={cardWidth}
disableIntervalMomentum
snapToInterval={pageWidth}
snapToAlignment="start"
getItemLayout={(_, index) => ({ length: cardWidth, offset: cardWidth * index, index })}
getItemLayout={(_, index) => ({ length: pageWidth, offset: pageWidth * index, index })}
ItemSeparatorComponent={() => <View style={local.separator} />}
keyExtractor={(item) => item.id}
style={local.carousel}
onMomentumScrollEnd={(event) => {
if (cardWidth > 0) {
setActiveIndex(Math.min(data.length - 1, Math.round(event.nativeEvent.contentOffset.x / cardWidth)));
}
}}
onScroll={(event) => syncActiveIndex(event.nativeEvent.contentOffset.x)}
scrollEventThrottle={16}
renderItem={({ item }) => (
<NotificationCard
disabled={hide.isPending}
@@ -139,6 +146,7 @@ export function NotificationCarousel({
const local = StyleSheet.create({
section: { paddingVertical: spacing.md },
carousel: { marginHorizontal: spacing.lg },
separator: { width: CARD_GAP },
state: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
error: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
});
@@ -48,7 +48,9 @@ export function QuickActions({ phone, authenticated = false }: { phone?: string
staleTime: 30_000,
refetchInterval: 30_000,
});
const hasUnreadMessages = (dialogs.data?.items ?? []).some((dialog) => (dialog.unread_count ?? 0) > 0);
const hasUnreadMessages = (dialogs.data?.items ?? []).some(
(dialog) => (dialog.unread_count ?? 0) > 0 || dialog.status === "waiting_for_client",
);
const call = () => {
if (phone) void Linking.openURL(`tel:${phone}`);
@@ -19,6 +19,10 @@ x-api-runtime: &api-runtime
environment:
<<: *no-sms-secrets
NOTIFICATIONS_TOKEN_PRODUCER_TEST: ${NOTIFICATIONS_TOKEN_PRODUCER_TEST:?NOTIFICATIONS_TOKEN_PRODUCER_TEST is required}
APP_ENV: ${APP_ENV:-production-like}
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_API:-api-backend}
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
@@ -42,7 +46,10 @@ x-sms-runtime: &sms-runtime
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
APP_ENV: ${APP_ENV:-production-like}
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_SMS_API:-sms-service}
volumes:
- type: bind
source: ${PG_CA_HOST_PATH}
@@ -152,7 +159,12 @@ services:
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
APP_ENV: ${APP_ENV:-production-like}
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_SMS_WORKER:-sms-worker}
SMS_METRICS_PORT: ${SMS_METRICS_PORT:-9464}
expose: ["9464"]
networks: [backend, observability, egress]
depends_on:
sms-service: {condition: service_healthy}
@@ -18,12 +18,13 @@ services:
APP_ENV: ${APP_ENV:-production-like}
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
OTEL_REMOTE_ENDPOINT: ${OTEL_REMOTE_ENDPOINT}
OTEL_REMOTE_AUTH_HEADER: ${OTEL_REMOTE_AUTH_HEADER}
OTEL_REMOTE_AUTH_HEADER: ${OTEL_REMOTE_AUTH_HEADER:-}
OTEL_REMOTE_TLS_INSECURE: ${OTEL_REMOTE_TLS_INSECURE:-false}
expose: ["4317", "4318", "13133", "8888"]
volumes:
- ./otel-collector.yaml:/etc/otelcol/config.yaml:ro
- otel-queue:/var/lib/otelcol/queue
networks: [observability, backend, egress]
networks: [observability, egress]
depends_on:
otel-queue-init: {condition: service_completed_successfully}
healthcheck:
@@ -16,6 +16,33 @@ receivers:
- job_name: otel-collector
static_configs:
- targets: ["127.0.0.1:8888"]
- job_name: sms-service
scrape_interval: 15s
scrape_timeout: 10s
metrics_path: /metrics
static_configs:
- targets: ["sms-service:8080"]
labels: {service_name: sms-service}
- job_name: sms-worker
scrape_interval: 15s
scrape_timeout: 10s
metrics_path: /metrics
static_configs:
- targets: ["sms-worker:9464"]
labels: {service_name: sms-worker}
- job_name: keycloak
scrape_interval: 30s
scrape_timeout: 15s
metrics_path: /auth/metrics
static_configs:
- targets: ["keycloak:9000"]
labels: {service_name: keycloak}
metric_relabel_configs:
- action: keep
source_labels: [__name__]
regex: "(keycloak_.*|jvm_.*|process_.*|system_.*|agroal_.*)"
- action: labeldrop
regex: "(client_id|uri|session_id|user_id|phone|email)"
processors:
memory_limiter:
@@ -33,19 +60,37 @@ processors:
- {key: http.request.header.cookie, action: delete}
- {key: http.response.header.set-cookie, action: delete}
- {key: url.query, action: delete}
- {key: url.full, action: delete}
- {key: http.target, action: delete}
- {key: http.request.body, action: delete}
- {key: db.statement, action: delete}
- {key: db.query.text, action: delete}
- {key: enduser.id, action: delete}
- {key: user.phone, action: delete}
- {key: user.email, action: delete}
- {key: messaging.message.body, action: delete}
- {key: aws.s3.key, action: delete}
- {key: s3.object.key, action: delete}
filter/noise:
error_mode: ignore
traces:
span:
- 'attributes["http.route"] == "/health/live"'
- 'attributes["http.route"] == "/nginx-health/live"'
probabilistic_sampler:
sampling_percentage: 10
tail_sampling:
decision_wait: 10s
num_traces: 10000
expected_new_traces_per_sec: 50
policies:
- name: errors
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow
type: latency
latency: {threshold_ms: 2000}
- name: successful-sample
type: probabilistic
probabilistic: {sampling_percentage: 10}
batch:
timeout: 5s
send_batch_size: 1024
@@ -57,7 +102,7 @@ exporters:
headers:
authorization: "${env:OTEL_REMOTE_AUTH_HEADER}"
tls:
insecure: false
insecure: "${env:OTEL_REMOTE_TLS_INSECURE}"
sending_queue:
enabled: true
num_consumers: 4
@@ -74,7 +119,7 @@ service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resource/common, attributes/redact, filter/noise, probabilistic_sampler, batch]
processors: [memory_limiter, resource/common, attributes/redact, filter/noise, tail_sampling, batch]
exporters: [otlp/remote]
metrics:
receivers: [otlp, prometheus]
+13 -3
View File
@@ -31,6 +31,7 @@ from app.service import (
read_message,
)
from app.settings import get_settings
from app.telemetry import add_trace_context, init_telemetry, instrument_fastapi
log = structlog.get_logger()
@@ -40,6 +41,7 @@ def configure_logging(level: str) -> None:
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
add_trace_context,
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
@@ -50,11 +52,16 @@ def configure_logging(level: str) -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
telemetry = init_telemetry()
configure_logging(settings.log_level)
app.state.settings = settings
app.state.db = Database(settings.database_url)
yield
await app.state.db.close()
try:
yield
finally:
await app.state.db.close()
if telemetry:
telemetry.shutdown()
app = FastAPI(
@@ -77,7 +84,6 @@ async def request_context(request: Request, call_next: Any) -> Response:
structlog.contextvars.bind_contextvars(
request_id=request_id,
method=request.method,
route=request.url.path,
**{"service.name": "sms-service"},
)
response = await call_next(request)
@@ -86,6 +92,7 @@ async def request_context(request: Request, call_next: Any) -> Response:
response.headers["Cache-Control"] = "no-store"
log.info(
"request.complete",
route=getattr(request.scope.get("route"), "path", request.url.path),
status_code=response.status_code,
duration_ms=round((time.monotonic() - started) * 1000, 2),
)
@@ -312,6 +319,9 @@ def datetime_now():
return datetime.now(UTC)
instrument_fastapi(app)
def run() -> None:
settings = get_settings()
uvicorn.run(
@@ -18,6 +18,7 @@ class Settings(BaseSettings):
callback_password: SecretStr = Field(alias="IDGTL_SMS_CALLBACK_PASSWORD")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
api_port: int = Field(default=8080, alias="SMS_API_PORT", ge=1, le=65535)
metrics_port: int = Field(default=9464, alias="SMS_METRICS_PORT", ge=1, le=65535)
@field_validator(
"service_token",
@@ -0,0 +1,102 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from fastapi import FastAPI
from opentelemetry import metrics, trace
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.propagate import set_global_textmap
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 opentelemetry.sdk.trace.sampling import ALWAYS_ON
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
@dataclass(slots=True)
class TelemetryRuntime:
tracer_provider: TracerProvider
meter_provider: MeterProvider
def shutdown(self) -> None:
self.meter_provider.shutdown()
self.tracer_provider.shutdown()
_runtime: TelemetryRuntime | None = None
def _resource(service_name: str) -> Resource:
return Resource.create(
{
"service.name": service_name,
"service.namespace": "han-chat",
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
"deployment.environment": os.getenv("APP_ENV", "production-like"),
}
)
def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
global _runtime
if _runtime is not None:
return _runtime
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
if not endpoint:
return None
resource = _resource(service_name or os.getenv("OTEL_SERVICE_NAME", "sms-service"))
insecure = endpoint.startswith("http://")
set_global_textmap(TraceContextTextMapPropagator())
tracer_provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3),
max_queue_size=2048,
schedule_delay_millis=5000,
max_export_batch_size=512,
export_timeout_millis=3000,
)
)
trace.set_tracer_provider(tracer_provider)
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
export_interval_millis=30000,
export_timeout_millis=3000,
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
HTTPXClientInstrumentor().instrument()
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
return _runtime
def instrument_fastapi(app: FastAPI) -> None:
FastAPIInstrumentor.instrument_app(
app,
excluded_urls="/health/live,/health/ready",
)
def add_trace_context(
_logger: Any,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
context = trace.get_current_span().get_span_context()
if context.is_valid:
event_dict["trace_id"] = format(context.trace_id, "032x")
event_dict["span_id"] = format(context.span_id, "016x")
return event_dict
+35 -5
View File
@@ -9,6 +9,8 @@ from datetime import UTC, datetime, timedelta
import httpx
import structlog
from opentelemetry import trace
from prometheus_client import start_http_server
from sqlalchemy import and_, func, or_, select, update
from app.db import Database, SendStatus, SmsOutboundMessage
@@ -23,6 +25,7 @@ from app.metrics import (
from app.provider import IdgtlClient, IdgtlConfig
from app.service import RuntimeSettings, load_runtime_settings
from app.settings import Settings, get_settings
from app.telemetry import add_trace_context, init_telemetry
log = structlog.get_logger()
MAX_CONNECT_ATTEMPTS = 3
@@ -32,6 +35,8 @@ def configure_logging(level: str) -> None:
logging.basicConfig(level=level, format="%(message)s")
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
add_trace_context,
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.JSONRenderer(),
@@ -169,7 +174,14 @@ async def update_queue_metrics(db: Database) -> None:
async def worker_loop(stop: asyncio.Event) -> None:
settings = get_settings()
telemetry = init_telemetry("sms-worker")
configure_logging(settings.log_level)
structlog.contextvars.bind_contextvars(**{"service.name": "sms-worker"})
metrics_server, metrics_thread = start_http_server(
settings.metrics_port,
addr="0.0.0.0", # noqa: S104 - internal Docker-network listener
)
tracer = trace.get_tracer("han.sms.worker")
db = Database(settings.database_url)
async with httpx.AsyncClient() as http:
try:
@@ -179,16 +191,30 @@ async def worker_loop(stop: asyncio.Event) -> None:
async with db.sessions() as session:
runtime = await load_runtime_settings(session)
SETTINGS_VALID.set(1)
claim_started_ns = time.time_ns()
message = await lease_message(db, runtime)
claim_finished_ns = time.time_ns()
if message is None:
await update_queue_metrics(db)
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
continue
client = IdgtlClient(http, provider_config(settings, runtime))
started = time.monotonic()
result = await client.send(message)
PROVIDER_LATENCY.labels("idgtl").observe(time.monotonic() - started)
await save_result(db, message.id, result, message.attempt_count)
process_span = tracer.start_span("sms.process", start_time=claim_started_ns)
with trace.use_span(process_span, end_on_exit=True):
claim_span = tracer.start_span("sms.claim", start_time=claim_started_ns)
claim_span.set_attribute("sms.claimed", True)
claim_span.end(end_time=claim_finished_ns)
span = trace.get_current_span()
span.set_attribute("messaging.operation.name", "send")
span.set_attribute("messaging.system", "idgtl")
span.set_attribute("sms.attempt", message.attempt_count)
client = IdgtlClient(http, provider_config(settings, runtime))
with tracer.start_as_current_span("sms.provider"):
started = time.monotonic()
result = await client.send(message)
PROVIDER_LATENCY.labels("idgtl").observe(time.monotonic() - started)
span.set_attribute("sms.outcome", result.send_status.value)
with tracer.start_as_current_span("sms.save_result"):
await save_result(db, message.id, result, message.attempt_count)
except TimeoutError:
continue
except Exception:
@@ -200,6 +226,10 @@ async def worker_loop(stop: asyncio.Event) -> None:
pass
finally:
await db.close()
metrics_server.shutdown()
metrics_thread.join(timeout=5)
if telemetry:
telemetry.shutdown()
def run() -> None:
@@ -8,6 +8,12 @@ dependencies = [
"asyncpg>=0.30,<1",
"fastapi>=0.116,<1",
"httpx>=0.28,<1",
"opentelemetry-api>=1.44,<2",
"opentelemetry-exporter-otlp-proto-grpc>=1.44,<2",
"opentelemetry-instrumentation-fastapi>=0.65b0,<1",
"opentelemetry-instrumentation-httpx>=0.65b0,<1",
"opentelemetry-instrumentation-sqlalchemy>=0.65b0,<1",
"opentelemetry-sdk>=1.44,<2",
"phonenumbers>=9,<10",
"prometheus-client>=0.22,<1",
"pydantic-settings>=2.10,<3",
@@ -0,0 +1,21 @@
from opentelemetry.sdk.trace import TracerProvider
from app import telemetry
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.setattr(telemetry, "_runtime", None)
assert telemetry.init_telemetry("sms-test") is None
def test_structlog_processor_adds_active_trace_context() -> None:
tracer = TracerProvider().get_tracer("test")
with tracer.start_as_current_span("sms.operation"):
result = telemetry.add_trace_context(None, "info", {"event": "sms.safe"})
assert result["event"] == "sms.safe"
assert len(result["trace_id"]) == 32
assert len(result["span_id"]) == 16
+29
View File
@@ -140,12 +140,41 @@ class InfrastructureConfigTests(unittest.TestCase):
"http.request.header.authorization",
"http.request.header.cookie",
"url.query",
"url.full",
"db.statement",
"db.query.text",
"messaging.message.body",
"aws.s3.key",
):
self.assertIn(forbidden_attribute, config)
self.assertIn("storage: file_storage", config)
self.assertIn("retry_on_failure:", config)
self.assertIn('insecure: "${env:OTEL_REMOTE_TLS_INSECURE}"', config)
self.assertIn("tail_sampling:", config)
self.assertNotIn("probabilistic_sampler:", config)
self.assertIn('targets: ["sms-service:8080"]', config)
self.assertIn('targets: ["sms-worker:9464"]', config)
self.assertIn('targets: ["keycloak:9000"]', config)
self.assertIn("metric_relabel_configs:", config)
compose = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("OTEL_REMOTE_TLS_INSECURE: ${OTEL_REMOTE_TLS_INSECURE:-false}", compose)
self.assertIn("networks: [observability, egress]", compose)
self.assertNotIn("networks: [observability, backend, egress]", compose)
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
self.assertIn("OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_API:-api-backend}", application)
self.assertIn("OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_SMS_API:-sms-service}", application)
self.assertIn("OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_SMS_WORKER:-sms-worker}", application)
self.assertIn('expose: ["9464"]', application)
for service_main in (
ROOT / "api-backend/app/main.py",
ROOT / "sms-service/app/main.py",
):
source = service_main.read_text(encoding="utf-8")
self.assertNotIn("route=request.url.path", source)
self.assertIn('getattr(request.scope.get("route"), "path"', source)
worker = (ROOT / "sms-service/app/worker.py").read_text(encoding="utf-8")
for span_name in ("sms.claim", "sms.process", "sms.provider", "sms.save_result"):
self.assertIn(f'"{span_name}"', worker)
def test_settings_cli_and_workers_are_deployable(self) -> None:
cli = ROOT / "api-backend/app/cli"