Добавлен OTLP-провайдер, реализовано отбрасывание метрик и трейсов в observability + добавлен перезапуск nginx при пересборке контейнеров (ошибка, когда докер меняет адреса сервисов)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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` оставьте
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user