Compare commits
11 Commits
98a2b8dc48
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a8ae199cc | ||
|
|
c0864021df | ||
|
|
910fc8d37f | ||
|
|
a7147b951e | ||
| 11b310d735 | |||
| 768d9d8849 | |||
| 3700fb1ccc | |||
| 592a06417c | |||
| 792b512df0 | |||
| fa76a55752 | |||
| de3bad0e1a |
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8001:8001"
|
||||
- "8001:8000"
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
APP_MODULE: "src.main:app"
|
||||
|
||||
@@ -17,4 +17,5 @@ dependencies = [
|
||||
"jinja2>=3.1.6",
|
||||
"aiosmtplib>=5.1.0",
|
||||
"aiohttp-socks>=0.11.0",
|
||||
"prometheus-client==0.23.1",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
73
src/infrastructure/metrics.py
Normal file
73
src/infrastructure/metrics.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
#docs
|
||||
Клиент с ID: {{id_client}}
|
||||
Отправил документы на проверку
|
||||
Клиент юр. лицо. отправил документы на проверку
|
||||
@@ -1,3 +1,3 @@
|
||||
#purchase
|
||||
Клиент с ID: {{id_client}}
|
||||
Сделал запрос на обмен на сумму {{amount}} USDT
|
||||
Сделал запрос на обмен на сумму {{amount}} RUB
|
||||
19
src/main.py
19
src/main.py
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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,10 +77,16 @@ async def consume_email_code(
|
||||
year=datetime.now().year,
|
||||
)
|
||||
|
||||
await sender.send(
|
||||
to=msg_body.payload.email,
|
||||
subject='Экса — код подтверждения',
|
||||
body=html,
|
||||
plain=text,
|
||||
inline_png=(EXA_LOGO_CID, logo_png) if logo_png else None,
|
||||
)
|
||||
observe_notify('email', msg_body.event, 'received')
|
||||
try:
|
||||
await sender.send(
|
||||
to=msg_body.payload.email,
|
||||
subject='Экса — код подтверждения',
|
||||
body=html,
|
||||
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')
|
||||
@@ -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
|
||||
)
|
||||
|
||||
async with ClientSession() as session:
|
||||
await sender.send(client=session, text=text)
|
||||
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
11
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user