11 Commits

Author SHA1 Message Date
Codex
0a8ae199cc Fix metrics Docker startup command 2026-07-02 13:16:42 +03:00
Codex
c0864021df Harden notify Prometheus metrics 2026-07-02 13:13:09 +03:00
Codex
910fc8d37f Add notify business metrics 2026-07-01 18:47:26 +03:00
Codex
a7147b951e Add healthcheck endpoint 2026-07-01 12:21:51 +03:00
11b310d735 fix: remove unused notify urls 2026-06-28 14:12:55 +03:00
768d9d8849 fix: change docs upload template and handler 2026-06-25 22:57:16 +03:00
3700fb1ccc fix: change amount field in notify 2026-06-25 22:36:07 +03:00
592a06417c feat: add health check endpoint 2026-06-16 11:44:50 +03:00
792b512df0 refactor: change title in swagger 2026-06-15 14:07:48 +03:00
fa76a55752 refactor: change currency in template 2026-06-15 13:25:44 +03:00
de3bad0e1a refactor: some little changes 2026-06-10 20:30:18 +03:00
12 changed files with 137 additions and 35 deletions

View File

@@ -21,8 +21,9 @@ COPY --from=builder /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app
PYTHONPATH=/app \
PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_multiproc
EXPOSE 8000
CMD ["sh", "-c", "granian --interface asgi ${APP_MODULE:-src.main:app} --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8000} --workers ${APP_WORKERS:-3} --loop uvloop"]
CMD mkdir -p "$PROMETHEUS_MULTIPROC_DIR" && rm -f "$PROMETHEUS_MULTIPROC_DIR"/* && exec granian --interface asgi ${APP_MODULE:-src.main:app} --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8000} --workers ${APP_WORKERS:-1} --loop uvloop

View File

@@ -5,7 +5,7 @@ services:
context: .
dockerfile: Dockerfile
ports:
- "8001:8001"
- "8001:8000"
environment:
PYTHONUNBUFFERED: "1"
APP_MODULE: "src.main:app"

View File

@@ -17,4 +17,5 @@ dependencies = [
"jinja2>=3.1.6",
"aiosmtplib>=5.1.0",
"aiohttp-socks>=0.11.0",
"prometheus-client==0.23.1",
]

View File

@@ -119,18 +119,6 @@ class Settings(BaseSettings):
data['TELEGRAM_PROXY_URL'] = _vault_key_get(telegram, 'PROXY_URL', data.get('TELEGRAM_PROXY_URL'))
return data
@property
def DATABASE_URL(self) -> str:
return (
f"postgresql+asyncpg://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}"
f"@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}"
)
@property
def REDIS_URL(self) -> str:
auth = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
return f"redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
@property
def RABBIT_URL(self) -> str:
vhost = "%2F" if self.RABBIT_VHOST == "/" else self.RABBIT_VHOST.lstrip("/")
@@ -138,7 +126,7 @@ class Settings(BaseSettings):
@property
def EXCLUDED_PATHS(self) -> List[str]:
return ["/docs", "/redoc", "/openapi.json", "/ping", "/health"]
return ["/docs", "/redoc", "/openapi.json", "/ping", "/health", '/healthcheck']
@lru_cache(maxsize=1)

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
import os
import time
from typing import Any
from fastapi import Request, Response
from prometheus_client import CollectorRegistry, Counter, Histogram, CONTENT_TYPE_LATEST, generate_latest, multiprocess
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response as StarletteResponse
SERVICE_NAME = "notify"
LATENCY_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
HTTP_REQUESTS = Counter("notify_http_requests_total", "Total HTTP requests handled by notify service.", ("service", "method", "route", "status_code"))
HTTP_LATENCY = Histogram("notify_http_request_duration_seconds", "HTTP request duration in seconds for notify service.", ("service", "method", "route"), buckets=LATENCY_BUCKETS)
NOTIFY_EVENTS = Counter("notify_events_total", "Notify events by channel, event and result.", ("service", "channel", "event", "result"))
def _safe_label(value: Any) -> str:
text = str(value or "unknown").strip().lower().replace(" ", "_")
if not text or len(text) > 80:
return "unknown"
return text
def observe_notify(channel: str, event: str, result: str) -> None:
NOTIFY_EVENTS.labels(SERVICE_NAME, _safe_label(channel), _safe_label(event), _safe_label(result)).inc()
def _route_template(request: Request) -> str:
route = request.scope.get("route")
path = getattr(route, "path", None)
return str(path) if path else "__unmatched__"
class PrometheusMetricsMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> StarletteResponse:
if request.url.path == "/metrics":
return await call_next(request)
started = time.perf_counter()
status_code = "500"
try:
response = await call_next(request)
status_code = str(response.status_code)
return response
finally:
elapsed = time.perf_counter() - started
route = _route_template(request)
HTTP_REQUESTS.labels(SERVICE_NAME, request.method, route, status_code).inc()
HTTP_LATENCY.labels(SERVICE_NAME, request.method, route).observe(elapsed)
def _labels(labels: dict[str, str]) -> str:
escaped = {key: value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") for key, value in labels.items()}
return ",".join(f'{key}="{value}"' for key, value in escaped.items())
def _sample(name: str, value: int | float, labels: dict[str, str] | None = None) -> str:
return f"{name}{{{_labels(labels)}}} {value}" if labels else f"{name} {value}"
def _client_metrics() -> bytes:
if os.environ.get("PROMETHEUS_MULTIPROC_DIR"):
registry = CollectorRegistry()
multiprocess.MultiProcessCollector(registry)
return generate_latest(registry)
return generate_latest()
async def metrics_response() -> Response:
body = _client_metrics().decode("utf-8") + _sample("notify_metrics_scrape_success", 1, {"service": SERVICE_NAME}) + "\n"
return Response(body, media_type=CONTENT_TYPE_LATEST)

View File

@@ -10,6 +10,7 @@ class NotifySender(ISenderBot):
url = f'https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage'
payload = {
'chat_id': settings.TELEGRAM_CHAT_ID,
'message_thread_id': settings.TELEGRAM_MESSAGE_THREAD_ID,
'text': text,
}
await client.post(url, proxy=settings.TELEGRAM_PROXY_URL, json=payload)

View File

@@ -1,3 +1,2 @@
#docs
Клиент с ID: {{id_client}}
Отправил документы на проверку
Клиент юр. лицо. отправил документы на проверку

View File

@@ -1,3 +1,3 @@
#purchase
Клиент с ID: {{id_client}}
Сделал запрос на обмен на сумму {{amount}} USDT
Сделал запрос на обмен на сумму {{amount}} RUB

View File

@@ -2,13 +2,14 @@ from __future__ import annotations
from contextlib import asynccontextmanager
import secrets
from typing import AsyncGenerator
from fastapi import Depends, FastAPI, status, APIRouter
from fastapi import Depends, FastAPI, Response, status, APIRouter
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.utils import generate_instance_id
from src.infrastructure.logger import logger
from src.infrastructure.metrics import PrometheusMetricsMiddleware, metrics_response
from src.infrastructure.config import settings
from src.presentation.handlers import application_exception_handler, unhandled_exception_handler
from src.presentation.messaging.code import code_broker
@@ -45,7 +46,7 @@ app: FastAPI = FastAPI(
redoc_url=None,
docs_url=None,
lifespan=lifespan,
title='Bitforce. Notify Service',
title='Elcsa. Notify Service',
version='1.0.0',
description='',
license_info={
@@ -57,6 +58,8 @@ app: FastAPI = FastAPI(
app.add_exception_handler(ApplicationException, application_exception_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)
app.add_middleware(PrometheusMetricsMiddleware)
v1_router = APIRouter(prefix='/v1')
app.include_router(v1_router)
app.include_router(code_broker)
@@ -97,9 +100,21 @@ async def custom_redoc_html(_credentials: HTTPBasicCredentials = Depends(verify_
)
@app.get('/metrics', include_in_schema=False)
async def metrics() -> Response:
return await metrics_response()
@app.post('/ping')
async def ping() -> dict[str, str]:
return {
'message': 'pong',
'status': 'ok',
}
@app.get('/healthcheck')
@app.get('/health')
async def health() -> dict[str, str]:
return {
'status': 'ok',
}

View File

@@ -9,6 +9,7 @@ from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, EmailStr, Field
from src.infrastructure.rabbit.broker import email_code_queue
from src.infrastructure.metrics import observe_notify
code_broker = RabbitRouter(settings.RABBIT_URL)
@@ -76,6 +77,8 @@ async def consume_email_code(
year=datetime.now().year,
)
observe_notify('email', msg_body.event, 'received')
try:
await sender.send(
to=msg_body.payload.email,
subject='Экса — код подтверждения',
@@ -83,3 +86,7 @@ async def consume_email_code(
plain=text,
inline_png=(EXA_LOGO_CID, logo_png) if logo_png else None,
)
except Exception:
observe_notify('email', msg_body.event, 'error')
raise
observe_notify('email', msg_body.event, 'sent')

View File

@@ -10,6 +10,7 @@ from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel
from src.infrastructure.rabbit.broker import telegram_notify_queue
from src.infrastructure.metrics import observe_notify
notify_broker = RabbitRouter(settings.RABBIT_URL)
@@ -22,8 +23,8 @@ class Metadata(BaseModel):
class Payload(BaseModel):
id_client: str
usdt_amount: Optional[Decimal] = None
id_client: Optional[str] = None
rub_amount: Optional[Decimal] = None
class NotifyCreated(BaseModel):
@@ -47,7 +48,7 @@ async def consume_notify(
logger.info(
f"received event={msg_body.event} "
f"id_client={msg_body.payload.id_client} "
f"id_client={msg_body.payload.id_client if msg_body.payload.id_client else 'New Client'} "
f"trace_id={trace_id}"
)
@@ -55,15 +56,20 @@ async def consume_notify(
text = renderer.render(
'purchase_notify.txt',
id_client=msg_body.payload.id_client,
amount=msg_body.payload.usdt_amount,
amount=msg_body.payload.rub_amount,
trace_id=trace_id,
)
elif msg_body.event == 'docs_upload':
text = renderer.render(
'docs_notify.txt',
id_client=msg_body.payload.id_client,
trace_id=trace_id
)
observe_notify('telegram', msg_body.event, 'received')
try:
async with ClientSession() as session:
await sender.send(client=session, text=text)
except Exception:
observe_notify('telegram', msg_body.event, 'error')
raise
observe_notify('telegram', msg_body.event, 'sent')

11
uv.lock generated
View File

@@ -765,6 +765,7 @@ dependencies = [
{ name = "granian" },
{ name = "hvac" },
{ name = "jinja2" },
{ name = "prometheus-client" },
{ name = "pydantic-settings" },
{ name = "python-ulid" },
{ name = "uvloop", marker = "sys_platform != 'win32'" },
@@ -782,6 +783,7 @@ requires-dist = [
{ name = "granian", specifier = ">=2.6.1" },
{ name = "hvac", specifier = ">=2.4.0" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "prometheus-client", specifier = "==0.23.1" },
{ name = "pydantic-settings", specifier = ">=2.13.1" },
{ name = "python-ulid", specifier = ">=3.1.0" },
{ name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" },
@@ -796,6 +798,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848, upload-time = "2024-01-12T20:37:21.359Z" },
]
[[package]]
name = "prometheus-client"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" },
]
[[package]]
name = "propcache"
version = "0.4.1"