Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
DEAL_PG_HOST=localhost
DEAL_PG_PORT=5433
DEAL_PG_DB=deal
DEAL_PG_USER=deal
DEAL_PG_PASSWORD=deal_dev_password
+62
View File
@@ -0,0 +1,62 @@
# deploy/.env.prod.example — шаблон production-окружения «Дейла» (compose.prod.yml, Ruling 9; Task 14).
#
# Использование:
# cp deploy/.env.prod.example deploy/.env.prod
# # заполнить ВСЕ секреты (пустое значение = config упадёт с подсказкой — fail-fast, Ruling 9)
# docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml config # проверка
# docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build
# # + наблюдаемость (профиль): ... up -d --build --profile observability
#
# Пароли/токены/ключи НЕ имеют значений-дефолтов (генерация: openssl rand -base64 32 и т.п.).
# DEAL_OPERATOR_* можно оставить пустыми: оператор тогда НЕ создаётся при старте (Ruling 1) —
# заведёте позже env + рестартом core.
# ── Обязательные секреты (пусто → docker compose config: ошибка с именем переменной) ──
# Пароль Postgres (пользователь/БД фиксированы: deal/deal).
DEAL_PG_PASSWORD=
# Ключ AES-256-GCM шифрования секретов настроек core (32 байта, base64): openssl rand -base64 32.
# Альтернатива — файл data/encryption.key на volume deal_api_data (см. техдок §13.4a).
DEAL_ENCRYPTION_KEY=
# Общий service-token процессов (gRPC-metadata; один на весь стек).
DEAL_SERVICE_TOKEN=
# Ключ AES-256-GCM файлов сессий telegram-service (32 байта, base64): openssl rand -base64 32.
DEAL_TELEGRAM_SESSION_KEY=
# Креды MinIO (root; S3 API и консоль). Бакет deal-files создаётся лениво (Ruling 4).
MINIO_ROOT_USER=
MINIO_ROOT_PASSWORD=
# Origin фронта для CORS/Origin-проверки core (Ruling 9/10). При статике за caddy тот же домен:
DEAL_ALLOWED_ORIGINS=https://deal.example
# ── Оператор (Ruling 1): пусто = bootstrap пропущен (стартовый warning), заводится позже env+рестарт ──
DEAL_OPERATOR_LOGIN=
DEAL_OPERATOR_PASSWORD=
# ── mTLS внутреннего gRPC (Ruling 6; Task 13/14) ──
# 0/пусто — plaintext + service-token внутри изолированной сети; 1 — TLS + клиентский сертификат.
# Сертификаты: запустите scripts/mtls-certs.sh (deploy/certs/*) и задайте пароль PFX:
DEAL_MTLS_ENABLED=0
DEAL_MTLS_CERT_PASSWORD=
# При DEAL_MTLS_ENABLED=1 схемы внутренних endpoint'ов обязаны стать https:// (иначе рукопожатие
# падает — код схему сам не переключает, см. task-13-report). Порт/имя менять не нужно:
DEAL_ML_ENDPOINT=http://ml-service:5103
DEAL_AI_ENDPOINT=http://ai-service:5102
DEAL_TELEGRAM_ENDPOINT=http://telegram-service:5101
DEAL_CORE_INGRESS=http://core:5082
# ── Необязательные (дефолты в compose.prod.yml) ──
# Каталог сертификатов mTLS на хосте (монтируется в /etc/deal/certs контейнеров):
# DEAL_CERTS_DIR=./certs
# Дефолт-бюджет ИИ нового тенанта (токенов/месяц; константа кода — 10 000 000):
# DEAL_DEFAULT_AI_BUDGET=
# ── Observability-профиль (только при --profile observability; см. compose.prod.yml) ──
# Пароль admin'а Grafana. Задать ПЕРЕД подъёмом профиля (пусто — grafana не получит пароль):
DEAL_GRAFANA_ADMIN_PASSWORD=
# DEAL_GRAFANA_ADMIN_USER=admin
+48
View File
@@ -0,0 +1,48 @@
# Caddyfile «Дейла» — PROD edge (compose.prod.yml, Ruling 9/10(3), план Task 14).
#
# Единственная точка входа наружу: :80/:443. Статика фронта (src/frontend/dist — собирается отдельно:
# cd src/frontend && npm run build
# ) + reverse_proxy /api/* → core:5080 (HTTP внутри compose-сети). TLS — «tls internal»: Caddy отдаёт
# самоподписанный сертификат своего локального CA. Это ПЛЕЙСХОЛДЕР первичного подъёма:
# * реальный домен: замените адрес сайта на https://deal.example.com и УБЕРИТЕ «tls internal» —
# Caddy выпустит сертификат Let's Encrypt автоматически (нужны публичные 80/443);
# * сайт за Cloudflare: вместо этого — origin-сертификат Cloudflare (см. техдок §10);
# * доступ по IP без домена: самоподписанный сертификат неизбежен — наружу лучше ничего не открывать,
# работать по SSH-туннелю.
#
# CSP/HSTS сознательно НЕ включены (Ruling 10(3)): CSP для Vue требует nonce-механику (инлайн-стили
# карточек, SSE) и включается точечно под реальный фронт; HSTS — только после реального TLS-сертификата.
# Заготовки обеих директив — закомментированы ниже в header-блоках.
https://deal.example {
# Плейсхолдер TLS (см. шапку): реальный домен → удалить эту директиву (Caddy сам выпустит Let's Encrypt).
tls internal
# API core: весь путь /api/* уходит на core:5080 БЕЗ перезаписи (контракт /api неизменен, api-map §3).
# SSE (/api/events), файлы и QR-SVG проходят reverse_proxy потоково (буферизации нет).
handle /api/* {
header {
# Security-заголовки ответов API (Ruling 10(3); CSP/HSTS — заготовки, см. шапку).
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
# Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
reverse_proxy core:5080
}
# Статика Vue (src/frontend/dist смонтирована в /srv). SPA-fallback: неизвестный путь отдаёт
# index.html — маршруты фронта обрабатывает сам Vue (история браузера).
handle {
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
# Strict-Transport-Security "max-age=31536000; includeSubDomains"
# Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-{NONCE}'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'"
}
root * /srv
try_files {path} /index.html
file_server
}
}
+213
View File
@@ -0,0 +1,213 @@
# compose.dev.yml — dev-стек Дейла (этап 6, Task 20; Ruling 12).
#
# Полный стек: postgres + minio (хранилища), три автономных сервиса этапа 6
# (telegram-service :5101, ai-service :5102, ml-service :5103) и core (deal-api:
# HTTP :5080 + gRPC-ингресс :5082).
#
# Режимы интеграций core — флагами SERVICES__{ML,AI,TELEGRAM}__USELOCAL (Ruling 6):
# * этот файл — сквозной gRPC-режим ВСЕГО стека «по-настоящему»: UseLocal=false заданы env
# core-сервиса ниже, интеграции ходят в ml/ai/telegram-сервисы этой же compose-сети
# (MlOutboxFlushScheduler → TrainBatch, GrpcAiClassifier/GrpcAiTools, GrpcTelegramClient).
# Подъём всего стека одной командой: docker compose -f deploy/compose.dev.yml up -d --build
# (см. также scripts/dev-smoke.sh — сквозная проверка с авто-очисткой).
# * Local-заглушки (дефолт КОДА — appsettings.json, UseLocal:true) сохраняются для запуска core
# с хоста БЕЗ сервисов (обычная dev-разработка, curl-приёмки этапов 2–5): поднимаются только
# postgres/minio, core — dotnet run из src/core/Deal.Api. Код-дефолт не меняется — режим
# переключает этот compose-файл (env ниже).
#
# Секреты dev (переопределяются .env/экспортом, шаблон ${VAR:-default}):
# DEAL_SERVICE_TOKEN — единый service-token всех процессов (metadata gRPC, Ruling 1/2/13)
# DEAL_ENCRYPTION_KEY — ключ AES-256-GCM шифрования секретов настроек core (32 байта base64)
# DEAL_TELEGRAM_SESSION_KEY — ключ AES-256-GCM файлов сессий telegram-service (32 байта base64)
# минио/БД creds — dev-значения ниже (в проде — этап 7, compose-prod + mTLS).
#
# Core в этом файле — «весь стек в docker» (эндпоинты — имена compose-сети). Альтернатива — core
# с хоста (Ruling 12): поднимаются только postgres/minio и (по желанию) сервисы, core запускается
# dotnet run, и сервисы ходят в ингресс хоста через DEAL_CORE_INGRESS=http://host.docker.internal:5082
# (см. env telegram-service ниже).
services:
postgres:
image: postgres:16-alpine
container_name: deal-postgres
environment:
POSTGRES_DB: deal
POSTGRES_USER: deal
POSTGRES_PASSWORD: deal_dev_password
ports:
- "5433:5432" # 5432 может быть занят LeadRadar-стеком
volumes:
- deal_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U deal -d deal"]
interval: 5s
timeout: 3s
retries: 10
# MinIO для файлов проектных карточек (Ruling 4, план Task 6; образца-прототипа object_store.py).
# Опционально: API работает и без него — LocalFileStorage (data/attachments) выбирается, пока MinIO не
# сконфигурирован (Storage__Minio__* / DEAL_MINIO_*). Бакет deal-files создаётся приложением лениво при
# первом upload (Ruling 4: EnsureBucket на первом put), отдельный init-контейнер не нужен.
# Консоль: http://localhost:9001 (root deal_minio / deal_minio_secret).
# Если 9000/9001 заняты LeadRadar-minio — поменяйте на 9100/9101 (и endpoint в конфигурации API).
minio:
image: minio/minio
container_name: deal-minio
environment:
MINIO_ROOT_USER: deal_minio
MINIO_ROOT_PASSWORD: deal_minio_secret
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web-консоль
volumes:
- deal_minio_data:/data
command: server /data --console-address ":9001"
# core (deal-api) — HTTP :5080 + gRPC-ингресс telegram-service :5082 (план Task 12/20, Ruling 7/12).
# Здесь core поднимается в сквозном gRPC-режиме (UseLocal=false ниже): сервисы этой же сети
# вызываются по-настоящему; MlOutbox копится в БД и выгружается MlOutboxFlushScheduler (10 с).
# Local-режим (дефолт appsettings.json) остаётся для запуска core с хоста без сервисов.
core:
build:
context: ..
dockerfile: src/core/Deal.Api/Dockerfile
container_name: deal-core
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://0.0.0.0:5080
GRPC_INGRESS_PORT: "5082"
ConnectionStrings__DealPostgres: Host=postgres;Port=5432;Database=deal;Username=deal;Password=deal_dev_password
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
DEAL_ENCRYPTION_KEY: ${DEAL_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
# gRPC-режим всего стека (Ruling 6): Local-заглушки кода (appsettings default true)
# переключаются на gRPC-клиентов сервисов compose-сети. Хост-запуск core без compose
# остаётся на Local (код-дефолт не менялся).
Services__Ml__UseLocal: "false"
Services__Ai__UseLocal: "false"
Services__Telegram__UseLocal: "false"
Services__Ml__Endpoint: http://ml-service:5103
Services__Ai__Endpoint: http://ai-service:5102
Services__Telegram__Endpoint: http://telegram-service:5101
Storage__Minio__Endpoint: minio:9000
Storage__Minio__AccessKey: deal_minio
Storage__Minio__SecretKey: deal_minio_secret
Storage__Minio__Bucket: deal-files
Storage__Minio__Secure: "false"
ports:
- "5080:5080"
- "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети)
volumes:
- deal_api_data:/app/data # data/encryption.key + data/attachments (Local-фолбэк файлов)
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5082"]
interval: 10s
timeout: 3s
retries: 10
start_period: 15s
# telegram-service — gRPC-сервер Telegram (этап 6, Ruling 12; план Task 2). Отдельный процесс,
# отдельное sln (src/telegram-service/Deal.Telegram.sln). Команды ядра приходят на localhost:5101
# (SERVICES__Telegram__Endpoint — задачи 9–12), исходящий поток — в core-ингресс :5082
# (SERVICES__CORE__INGRESS, Ruling 7; внутри compose — http://core:5082; при core на хосте задайте
# DEAL_CORE_INGRESS=http://host.docker.internal:5082). Plaintext gRPC без mTLS (Ruling 2 — dev):
# защита — общий service-token (DEAL_SERVICE_TOKEN, env хоста и контейнеров). Сессии тенантов —
# файлы /data/sessions (Ruling 3), шифруются AES-256-GCM ключом DEAL_TELEGRAM_SESSION_KEY
# (fail-closed: без ключа сервис не стартует). Контекст сборки — корень репозитория: csproj
# ссылается на src/contracts/Deal.Proto.csproj (общий проект кодогенерации, Task 1).
telegram-service:
build:
context: ..
dockerfile: src/telegram-service/Deal.Telegram/Dockerfile
container_name: deal-telegram-service
environment:
GRPC_PORT: "5101"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=}
DEAL_TELEGRAM_SESSION_DIR: /data/sessions
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
ports:
- "5101:5101"
volumes:
- deal_tg_sessions:/data/sessions
healthcheck:
test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5101"]
interval: 5s
timeout: 3s
retries: 10
# ai-service — gRPC-сервер ИИ-фасада (этап 6, Ruling 12; план Task 4). Отдельный процесс,
# отдельное sln (src/ai-service/Deal.Ai.sln). Промпты/классификацию/фильтр/ключи ядро шлёт на
# localhost:5102 (SERVICES__Ai__Endpoint — задачи 7/8/15). Plaintext gRPC без mTLS (Ruling 2 —
# dev): защита — общий service-token (DEAL_SERVICE_TOKEN, env хоста и контейнеров). Сервис без БД
# (Ruling 5): конфиг провайдера (id/base/model/apiKey/api_style) приходит в теле каждого запроса
# от ядра, персистентных файлов нет — volume не нужен. Контекст сборки — корень репозитория:
# csproj ссылается на src/contracts/Deal.Proto.csproj (общий проект кодогенерации, Task 1).
ai-service:
build:
context: ..
dockerfile: src/ai-service/Deal.Ai/Dockerfile
container_name: deal-ai-service
environment:
GRPC_PORT: "5102"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
ports:
- "5102:5102"
healthcheck:
test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5102"]
interval: 5s
timeout: 3s
retries: 10
# ml-service — gRPC-сервер ML (этап 6, Ruling 12; план Task 3). Отдельный процесс, отдельное sln
# (src/ml-service/Deal.Ml.sln). Обучение и предсказания ядро шлёт на localhost:5103
# (SERVICES__Ml__Endpoint — задачи 5/6/16). Plaintext gRPC без mTLS (Ruling 2 — dev): защита —
# общий service-token (DEAL_SERVICE_TOKEN, env хоста и контейнеров). Модели тенантов — файлы
# SQLite /data/ml/<tenantId>.sqlite (Ruling 4). Контекст сборки — корень репозитория: csproj
# ссылается на src/contracts/Deal.Proto.csproj (общий проект кодогенерации, Task 1).
ml-service:
build:
context: ..
dockerfile: src/ml-service/Deal.Ml/Dockerfile
container_name: deal-ml-service
environment:
GRPC_PORT: "5103"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token}
DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/<tenantId>.sqlite на volume deal_ml_data (Ruling 4/12)
ports:
- "5103:5103"
volumes:
- deal_ml_data:/data/ml
healthcheck:
test: ["CMD", "/bin/grpc_health_probe", "-addr=localhost:5103"]
interval: 5s
timeout: 3s
retries: 10
# Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
prometheus:
image: prom/prometheus:v3.5.0
container_name: deal-prometheus
profiles: ["observability"]
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
ports:
- "9090:9090"
volumes:
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
- deal_prometheus_data:/prometheus
volumes:
deal_pgdata:
deal_minio_data:
deal_tg_sessions:
deal_ml_data:
deal_api_data:
deal_prometheus_data:
+324
View File
@@ -0,0 +1,324 @@
# compose.prod.yml — PROD-стек «Дейла» (этап 7, Ruling 6/7/9; план Task 14).
#
# ⚠ НЕ поднимать на dev-машине вместо compose.dev.yml: это production-конфигурация с
# fail-fast на секреты (см. ниже) и без dev-дефолтов.
#
# Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443):
# postgres, minio — хранилища БЕЗ host-портов (volume'ы);
# core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102),
# ml-service (:5103) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже);
# caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core.
# loki/promtail/grafana/prometheus — observability (Ruling 7; метрики — этап 12, пакет A): ПРОФИЛЬ
# `observability` — поднимается только: docker compose --profile observability up -d
# (или ... up -d --profile observability). Prometheus scrape'ит /metrics
# (порт 9464) всех 4 процессов; Grafana — логи (Loki) и метрики (Prometheus).
#
# Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod,
# заполните значения и запускайте с --env-file:
# cp deploy/.env.prod.example deploy/.env.prod # и заполнить (без дефолтных паролей!)
# docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build
# docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml \
# --profile observability up -d --build # + Loki/Promtail/Grafana/Prometheus
# Проверка без движка: docker compose --env-file ... -f deploy/compose.prod.yml config
# Ключевые обязательные (без значения `config` упадёт с подсказкой): DEAL_PG_PASSWORD,
# DEAL_ENCRYPTION_KEY, DEAL_SERVICE_TOKEN, DEAL_TELEGRAM_SESSION_KEY, MINIO_ROOT_USER,
# MINIO_ROOT_PASSWORD, DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD (пустые — оператор не создаётся,
# см. Ruling 1), DEAL_ALLOWED_ORIGINS.
#
# PROD-флаги кода (Ruling 5/9/10): RateLimit__Enabled=true (лимиты/защита входа), куки
# Cookies__Secure/OperatorCookies__Secure=true (TLS терминирует caddy), ForwardedHeaders__Enabled=true
# (доверенный прокси — caddy; KnownNetworks — приватные docker-сети хоста: core наружу не публикуется,
# единственный вход в него — caddy), Security__AllowedOrigins — origin фронта.
#
# mTLS внутреннего gRPC (Ruling 6, Task 13/14): DEAL_MTLS_ENABLED=0 — plaintext + service-token
# (как dev, но внутри изолированной сети); DEAL_MTLS_ENABLED=1 — Kestrel сервисов/ингресса требует
# клиентский сертификат. Сертификаты: deploy/certs (scripts/mtls-certs.sh) монтируются в
# /etc/deal/certs. ВАЖНО при включении mTLS:
# * схемы внутренних endpoint'ов поменять на https:// (DEAL_ML_ENDPOINT/DEAL_AI_ENDPOINT/
# DEAL_TELEGRAM_ENDPOINT/DEAL_CORE_INGRESS в .env.prod);
# * healthcheck'и ниже сами переключаются на TLS-пробу grpc_health_probe (клиентский сертификат из
# /etc/deal/certs; PEM-артефакты deal-client.crt/.key генерирует scripts/mtls-certs.sh);
# * DEAL_MTLS_CERT_PASSWORD — пароль PFX (серверных и клиентского).
#
# Логи (Ruling 7): процессы пишут Serilog JSON в stdout (docker-логи) + rolling-файл
# data/logs/deal-<процесс>.json в контейнере. У core /app/data — volume deal_api_data (файлы-логи
# переживают пересоздание); у сервисов канал наблюдаемости — docker-логи → promtail (файлы сервисов
# эфемерны). Уровень — env DEAL_LOG_LEVEL.
#
# Frontend: статика монтируется из ../src/frontend/dist (СОБРАТЬ ДО ПОДЪЁМА: cd src/frontend &&
# npm run build). Конфигурация caddy — deploy/caddy/Caddyfile (см. его шапку про домен/TLS).
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: deal
POSTGRES_USER: deal
POSTGRES_PASSWORD: ${DEAL_PG_PASSWORD:?DEAL_PG_PASSWORD не задан (пароль Postgres deal)}
volumes:
- deal_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U deal -d deal"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
# MinIO (файлы проектных карточек; бакет deal-files создаётся лениво при первом upload, Ruling 4).
# БЕЗ host-портов: наружу файлы ходят только через core→caddy. Healthcheck нет: minio-образ не несёт
# curl/mc; на core падение minio = фолбэк LocalFileStorage (данные — в volume, подъём лечит).
minio:
# Тег зафиксирован (как остальные образы); при обновлении — свежий RELEASE-тег из quay.io/minio/minio.
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
volumes:
- deal_minio_data:/data
command: server /data
restart: unless-stopped
# core (deal-api): HTTP :5080 + gRPC-ингресс telegram-service :5082. Сквозной gRPC-режим
# (UseLocal=false), как compose.dev.yml; rate limiting/куки-Secure/прокси-заголовки — PROD (см. шапку).
core:
build:
context: ..
dockerfile: src/core/Deal.Api/Dockerfile
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://0.0.0.0:5080
GRPC_INGRESS_PORT: "5082"
ConnectionStrings__DealPostgres: "Host=postgres;Port=5432;Database=deal;Username=deal;Password=${DEAL_PG_PASSWORD:?DEAL_PG_PASSWORD не задан}"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан (service-token gRPC)}
DEAL_ENCRYPTION_KEY: ${DEAL_ENCRYPTION_KEY:?DEAL_ENCRYPTION_KEY не задан (AES-256-GCM, 32 байта base64)}
DEAL_OPERATOR_LOGIN: ${DEAL_OPERATOR_LOGIN:-}
DEAL_OPERATOR_PASSWORD: ${DEAL_OPERATOR_PASSWORD:-}
DEAL_DEFAULT_AI_BUDGET: ${DEAL_DEFAULT_AI_BUDGET:-}
# Безопасность PROD (Rulings 5/9/10): лимиты вкл., куки Secure, доверенный прокси (caddy).
RateLimit__Enabled: "true"
Cookies__Secure: "true"
OperatorCookies__Secure: "true"
ForwardedHeaders__Enabled: "true"
# Доверенные подсети прокси: приватные docker-сети хоста (caddy — единственный вход в core,
# host-портов у core нет). Свою подсеть можно уточнить: docker network inspect <сеть>.
ForwardedHeaders__KnownNetworks__0: 172.16.0.0/12
Security__AllowedOrigins: ${DEAL_ALLOWED_ORIGINS:?DEAL_ALLOWED_ORIGINS не задан (origin фронта, напр. https://deal.example)}
# Интеграции — gRPC-режим всего стека (как dev); схемы endpoint'ов — http:// (mTLS off) либо
# https:// при DEAL_MTLS_ENABLED=1 (см. .env.prod.example).
Services__Ml__UseLocal: "false"
Services__Ml__Endpoint: ${DEAL_ML_ENDPOINT:-http://ml-service:5103}
Services__Ai__UseLocal: "false"
Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102}
Services__Telegram__UseLocal: "false"
Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101}
# Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true
# + endpoint https и сертификаты).
Storage__Minio__Endpoint: minio:9000
Storage__Minio__AccessKey: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан}
Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан}
Storage__Minio__Bucket: deal-files
Storage__Minio__Secure: "false"
# mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже).
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/core-server.pfx
DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx
DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
volumes:
# data/: ключ шифрования (или DEAL_ENCRYPTION_KEY), LocalFileStorage-фолбэк, логи data/logs.
- deal_api_data:/app/data
# deploy/certs (scripts/mtls-certs.sh): нужны только при DEAL_MTLS_ENABLED=1.
- ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro
depends_on:
postgres:
condition: service_healthy
healthcheck:
# gRPC-health ингресса :5082 (health освобождён от service-token). Под mTLS — TLS-проба
# с клиентским сертификатом deal-client и CA (PEM-артефакты генерирует scripts/mtls-certs.sh).
test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5082 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5082; fi"]
interval: 10s
timeout: 3s
retries: 10
start_period: 15s
restart: unless-stopped
# telegram-service — gRPC-сервер Telegram (:5101; plaintext либо mTLS — как core). Сессии —
# /data/sessions (volume deal_tg_sessions, AES-GCM ключом DEAL_TELEGRAM_SESSION_KEY, fail-closed).
telegram-service:
build:
context: ..
dockerfile: src/telegram-service/Deal.Telegram/Dockerfile
environment:
GRPC_PORT: "5101"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)}
DEAL_TELEGRAM_SESSION_DIR: /data/sessions
SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082}
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/telegram-service-server.pfx
DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx
DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
volumes:
- deal_tg_sessions:/data/sessions
- ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro
healthcheck:
test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5101 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5101; fi"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
# ai-service — gRPC-сервер ИИ-фасада (:5102). Без БД и volume'ов (конфиг провайдера — в теле RPC).
ai-service:
build:
context: ..
dockerfile: src/ai-service/Deal.Ai/Dockerfile
environment:
GRPC_PORT: "5102"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx
DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx
DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
volumes:
- ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro
healthcheck:
test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5102 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5102; fi"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
# ml-service — gRPC-сервер ML (:5103). Модели — /data/ml (volume deal_ml_data, SQLite на тенанта).
ml-service:
build:
context: ..
dockerfile: src/ml-service/Deal.Ml/Dockerfile
environment:
GRPC_PORT: "5103"
DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан}
DEAL_ML_DATA_DIR: /data/ml
DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0}
DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem
DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx
DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx
DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-}
volumes:
- deal_ml_data:/data/ml
- ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro
healthcheck:
test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5103 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5103; fi"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
# caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен,
# реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up).
caddy:
image: caddy:2.9.1
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ../src/frontend/dist:/srv:ro
# /data — локальный CA и сертификаты Caddy (переживают пересоздание контейнера).
- deal_caddy_data:/data
- deal_caddy_config:/config
depends_on:
core:
condition: service_healthy
restart: unless-stopped
# ── Observability (Ruling 7/9; метрики — этап 12, пакет A) — ПРОФИЛЬ: --profile observability ──
# Логи: docker-логи (stdout контейнеров) → promtail → loki → grafana (127.0.0.1:3001).
# Метрики: /metrics процессов (:9464) → prometheus (127.0.0.1:9090) → grafana (датасорс uid
# `prometheus`). Доступ — оператору по SSH-туннелю; наружу grafana/prometheus НЕ публикуются.
# Версии образов — фиксированные (при подъёме обновите до актуальных patch-релизов).
loki:
image: grafana/loki:3.4.2
profiles: ["observability"]
command: -config.file=/etc/loki/loki.yml
volumes:
- ./observability/loki.yml:/etc/loki/loki.yml:ro
- deal_loki_data:/loki
restart: unless-stopped
# Prometheus — сбор метрик Deal-процессов (этап 12, пакет A): scrape /metrics каждого процесса
# (:9464, HTTP/1.1) внутри compose-сети. Конфиг — deploy/observability/prometheus.yml (таргеты
# core/telegram-service/ai-service/ml-service). UI — только loopback 127.0.0.1:9090 (оператору по
# SSH-туннелю, как Grafana); наружу порт не публикуется. Retention 15 суток (volume deal_prometheus_data).
prometheus:
image: prom/prometheus:v3.5.0
profiles: ["observability"]
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
ports:
# Только loopback хоста — наружу не публикуется (Ruling 9).
- "127.0.0.1:9090:9090"
volumes:
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
- deal_prometheus_data:/prometheus
restart: unless-stopped
promtail:
image: grafana/promtail:3.4.2
profiles: ["observability"]
command: -config.file=/etc/promtail/promtail.yml
volumes:
- ./observability/promtail.yml:/etc/promtail/promtail.yml:ro
# docker.sock — чтение docker-логов всех контейнеров (docker_sd). Rootless-докер: путь сокета
# свой (напр. /run/user/<uid>/docker.sock) — поправьте монтирование и promtail.yml.
- /var/run/docker.sock:/var/run/docker.sock:ro
- deal_promtail_data:/var/lib/promtail
depends_on:
loki:
condition: service_started
restart: unless-stopped
grafana:
image: grafana/grafana:11.5.2
profiles: ["observability"]
environment:
# Пароль задайте в .env.prod перед подъёмом профиля (пусто — grafana не получит admin-пароль).
GF_SECURITY_ADMIN_USER: ${DEAL_GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${DEAL_GRAFANA_ADMIN_PASSWORD:-}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
ports:
# Только loopback хоста — оператору по SSH-туннелю (Ruling 9); наружу не публикуется.
- "127.0.0.1:3001:3000"
volumes:
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
- ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
- deal_grafana_data:/var/lib/grafana
depends_on:
loki:
condition: service_started
prometheus:
condition: service_started
restart: unless-stopped
volumes:
deal_pgdata:
deal_minio_data:
deal_tg_sessions:
deal_ml_data:
deal_api_data:
deal_caddy_data:
deal_caddy_config:
deal_loki_data:
deal_promtail_data:
deal_grafana_data:
deal_prometheus_data:
@@ -0,0 +1,388 @@
{
"annotations": {
"list": []
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Успешные входы (HTTP 200) по access-логу core (HttpAccessLogMiddleware: поля Path/StatusCode). Тенант — POST /api/auth/login, оператор — POST /api/operator/auth/login.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum(count_over_time({service=\"core\"} | json | Path=\"/api/auth/login\" | StatusCode=200 [1m]))",
"legendFormat": "тенант",
"refId": "A"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum(count_over_time({service=\"core\"} | json | Path=\"/api/operator/auth/login\" | StatusCode=200 [1m]))",
"legendFormat": "оператор",
"refId": "B"
}
],
"title": "Успешные входы, /мин",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Неудачные входы по HTTP-коду ответа: 401 — неверные учётные данные, 403 — тенант приостановлен, 429 — сработал LoginAttemptGuard (5 неудач ip|login за 15 мин). По access-логу core: поля Path/StatusCode.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum by (StatusCode) (count_over_time({service=\"core\"} | json | Path=~\"/api/auth/login|/api/operator/auth/login\" | StatusCode >= 400 [1m]))",
"legendFormat": "HTTP {{StatusCode}}",
"refId": "A"
}
],
"title": "Неудачные входы по коду ответа, /мин",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Выходы из системы (POST /api/auth/logout — тенант, POST /api/operator/auth/logout — оператор) по access-логу core. Ручка всегда отвечает 200.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum by (Path) (count_over_time({service=\"core\"} | json | Path=~\"/api/auth/logout|/api/operator/auth/logout\" [1m]))",
"legendFormat": "{{Path}}",
"refId": "A"
}
],
"title": "Выходы (logout), /мин",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Активация приглашений (POST /api/join) по коду ответа: 200 — успешная активация, 4xx — неверный/просроченный код, занятый email или приостановленный тенант.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum by (StatusCode) (count_over_time({service=\"core\"} | json | Path=\"/api/join\" [1m]))",
"legendFormat": "HTTP {{StatusCode}}",
"refId": "A"
}
],
"title": "Активация инвайтов (/api/join), /мин",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Успешные входы тенанта и оператора за последний час.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum(count_over_time({service=\"core\"} | json | Path=~\"/api/auth/login|/api/operator/auth/login\" | StatusCode=200 [1h]))",
"legendFormat": "успешных входов",
"refId": "A"
}
],
"title": "Успешные входы за 1ч",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Неудачные входы тенанта и оператора за последний час; >0 — повод посмотреть Explore (текст ответа) и аудит-ленту оператора.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 6,
"y": 16
},
"id": 6,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum(count_over_time({service=\"core\"} | json | Path=~\"/api/auth/login|/api/operator/auth/login\" | StatusCode >= 400 [1h]))",
"legendFormat": "неудачных входов",
"refId": "A"
}
],
"title": "Неудачные входы за 1ч",
"type": "stat"
},
{
"gridPos": {
"h": 5,
"w": 12,
"x": 12,
"y": 16
},
"id": 7,
"options": {
"content": "**Кто именно входил — в аудите, не в логах.** Access-лог core не пишет идентичность (login/tenant/operator) — только метод/путь/код, поэтому панели выше различают лишь контур (тенант vs оператор) по префиксу пути.\n\nПолная лента входов/выходов/неудач с актором, тенантом и IP — в PostgreSQL (`public.audit_log`, append-only, события `tenant_login_ok`/`tenant_login_failed`/`operator_login_*`/`*_logout`/`invite_activated`) через операторские ручки `GET /api/operator/audit` или экран «Аудит» в оператор-консоли.",
"mode": "markdown"
},
"title": "Где искать актора",
"type": "text"
}
],
"refresh": "1m",
"schemaVersion": 39,
"tags": [
"deal"
],
"templating": {
"list": []
},
"time": {
"from": "now-24h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Deal-Auth",
"uid": "deal-auth",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,410 @@
{
"annotations": {
"list": []
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "HTTP-ответы 5xx по путям (access-лог core, поле StatusCode). Ручка, отдающая 5xx, обычно соответствует исключению ниже (в той же панели логов).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum by (Path) (count_over_time({service=\"core\"} | json | StatusCode >= 500 [1m]))",
"legendFormat": "{{Path}}",
"refId": "A"
}
],
"title": "HTTP 5xx по путям (core), /мин",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Строки с необработанным исключением (Serilog-поле \"@x\") по deal-процессам. Каждое такое событие пишется access-логом/аудитом и означает баг либо недоступную зависимость.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum by (service) (count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"\\\"@x\\\":\\\"\" [1m]))",
"legendFormat": "{{service}}",
"refId": "A"
}
],
"title": "Исключения по сервисам, /мин",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Строки уровня Error/Fatal (Serilog-поле @l) по deal-процессам. Дополняет панель исключений: ловит и логированные, но не брошенные ошибки.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 20,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum by (service) (count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [1m]))",
"legendFormat": "{{service}}",
"refId": "A"
}
],
"title": "Error/Fatal по сервисам, /мин",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "gRPC-вызовы с не-OK статусом (access-лог RpcCallLoggingInterceptor: поля RpcMethod/GrpcStatus). Cancelled/DeadlineExceeded — клиентские отмены, Unavailable/Unknown — сбои. gRPC-health не логируется.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum by (service) (count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |= \"gRPC \" | json | GrpcStatus != \"OK\" [1m]))",
"legendFormat": "{{service}}",
"refId": "A"
}
],
"title": "gRPC-вызовы с ошибкой по сервисам, /мин",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Суммарно Error/Fatal по всем deal-процессам за 5 минут; >0 — повод открыть панель логов ниже.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 12,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum(count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [5m]))",
"legendFormat": "ошибок за 5м",
"refId": "A"
}
],
"title": "Error/Fatal за 5 мин (все сервисы)",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "HTTP 5xx (core) за последний час.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 12,
"x": 12,
"y": 16
},
"id": 6,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum(count_over_time({service=\"core\"} | json | StatusCode >= 500 [1h]))",
"legendFormat": "HTTP 5xx за 1ч",
"refId": "A"
}
],
"title": "HTTP 5xx за 1 час (core)",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Строки Error/Fatal и записи с исключением (\"@x\") по всем deal-процессам. Разверните строку для текста исключения и стека.",
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 21
},
"id": 7,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "{service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\"",
"refId": "A"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "{service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"\\\"@x\\\":\\\"\"",
"refId": "B"
}
],
"title": "Лента ошибок и исключений",
"type": "logs"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [
"deal"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Deal-Errors",
"uid": "deal-errors",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,228 @@
{
"annotations": {
"list": []
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Строк логов в минуту по контейнерам deal-процессов (core/telegram-service/ai-service/ml-service). Контейнер без логов = нет данных (сервис молчит или лежит).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 15,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "count_over_time({service=\"core\"}[1m])",
"legendFormat": "core",
"refId": "A"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "count_over_time({service=\"telegram-service\"}[1m])",
"legendFormat": "telegram-service",
"refId": "B"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "count_over_time({service=\"ai-service\"}[1m])",
"legendFormat": "ai-service",
"refId": "C"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "count_over_time({service=\"ml-service\"}[1m])",
"legendFormat": "ml-service",
"refId": "D"
}
],
"title": "Активность логов deal-процессов (строк/мин)",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Строки уровня Error/Fatal (Serilog CompactJsonFormatter: \"@l\":\"Error\"/\"Fatal\") по deal-процессам. Поиск по логам детально — Explore с датасорсом Loki.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [1m])",
"legendFormat": "{{service}}",
"refId": "A"
}
],
"title": "Ошибки и фатальные по deal-процессам (строк/мин)",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Суммарно Error/Fatal по всем deal-процессам за 5 минут; >0 — повод открыть Explore и посмотреть текст ошибки.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 24,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum(count_over_time({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"@l\\\":\\\"(Error|Fatal)\" [5m]))",
"legendFormat": "ошибок за 5м",
"refId": "A"
}
],
"title": "Ошибки за 5 минут (все deal-процессы)",
"type": "stat"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [
"deal"
],
"templating": {
"list": []
},
"time": {
"from": "now-15m",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Deal-Health",
"uid": "deal-health",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,303 @@
{
"annotations": {
"list": []
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Живая лента логов deal-процессов: фильтры по сервису и уровню сверху. Разверните строку — детали Serilog-события (@mt, свойства, исключение). Query-строка/тела запросов в логи не пишутся (Ruling 13).",
"gridPos": {
"h": 12,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "{service=~\"$service\", level=~\"$level\"}",
"refId": "A"
}
],
"title": "Логи deal-процессов",
"type": "logs"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Объём логов в разрезе уровня Serilog (поле @l, поднято в метку level пайплайном Promtail). Высокий Error/Fatal — детали в дашборде Deal-Errors.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 12
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum by (level) (count_over_time({service=~\"$service\", level=~\"$level\"} [1m]))",
"legendFormat": "{{level}}",
"refId": "A"
}
],
"title": "Строк логов по уровням, /мин",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Объём логов по сервисам (метка service из лейблов Promtail). Заметный провал до нуля — сервис молчит или лежит (см. также Deal-Health).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 12
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum by (service) (count_over_time({service=~\"$service\"} [1m]))",
"legendFormat": "{{service}}",
"refId": "A"
}
],
"title": "Строк логов по сервисам, /мин",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "События с идентификатором тенанта (Serilog-поле TenantId) в логах фоновых сервисов: AI/ML-вызовы («Аудит: tenant …»), Telegram (backfill/realtime). Показывает, какие тенанты активны, по данным логов.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 20
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "topk(10, sum by (TenantId) (count_over_time({service=~\"ai-service|ml-service|telegram-service\"} | json | TenantId =~ \".+\" [5m])))",
"legendFormat": "{{TenantId}}",
"refId": "A"
}
],
"title": "Активность по тенантам (AI/ML/Telegram, top-10), /5мин",
"type": "timeseries"
},
{
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 20
},
"id": 5,
"options": {
"content": "**Действия пользователей логируются не сюда, а в аудит.** Создание/перемещение/удаление карточек, контейнеры, настройки, каналы, входы/выходы/инвайты пишутся append-only в `public.audit_log` (AuditService) и доступны через `GET /api/operator/audit` / экран «Аудит» оператор-консоли.\n\nВ логах (этат дашборд) по действиям доступны: access-логи HTTP/gRPC (метод/путь/код), аудит-строки фоновых сервисов (`Аудит: tenant …`) и идентификатор тенанта (`TenantId`). Поиск по ключевой фразе добавьте в ленту: `{service=~\"$service\"} |= \"фраза\"`.",
"mode": "markdown"
},
"title": "Где искать действия пользователей",
"type": "text"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [
"deal"
],
"templating": {
"list": [
{
"allValue": ".*",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"datasource": {
"type": "loki",
"uid": "loki"
},
"definition": "label_values(service)",
"includeAll": true,
"label": "Сервис",
"multi": true,
"name": "service",
"options": [],
"query": "label_values(service)",
"refresh": 2,
"regex": "",
"sort": 1,
"type": "query"
},
{
"allValue": ".*",
"current": {
"selected": true,
"text": [
"All"
],
"value": [
"$__all"
]
},
"definition": "Information,Warning,Error,Fatal",
"includeAll": true,
"label": "Уровень",
"multi": true,
"name": "level",
"options": [
{
"selected": false,
"text": "Information",
"value": "Information"
},
{
"selected": false,
"text": "Warning",
"value": "Warning"
},
{
"selected": false,
"text": "Error",
"value": "Error"
},
{
"selected": false,
"text": "Fatal",
"value": "Fatal"
}
],
"query": "Information,Warning,Error,Fatal",
"type": "custom"
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Deal-Logs",
"uid": "deal-logs",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,606 @@
{
"annotations": {
"list": []
},
"editable": true,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Суммарная нагрузка всех deal-процессов (HTTP + gRPC) в секунду. Источник — OTel-инструментация ASP.NET Core (метрика http.server.request.duration, счётчик _count), scrape /metrics :9464.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(rate(http_server_request_duration_seconds_count[$__rate_interval]))",
"legendFormat": "RPS",
"refId": "A"
}
],
"title": "RPS (HTTP+gRPC) сейчас",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Активные непросроченные сессии пользователей тенантов и операторов (public.sessions + public.operator_sessions). Агрегат по всем тенантам — в метке нет tenantId (низкая кардинальность).",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 6,
"y": 0
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "deal_sessions_active",
"legendFormat": "сессии",
"refId": "A"
}
],
"title": "Активные сессии",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Суммарная глубина очереди пайплайна (new+filtered) по всем тенантам; считает фоновый DealMetricsCollector через PipelineProcessingService.QueueCountsAsync.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 12,
"y": 0
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "deal_pipeline_queue_depth",
"legendFormat": "очередь",
"refId": "A"
}
],
"title": "Очередь пайплайна",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Суммарная глубина очереди обучения ML (MlOutbox) по всем тенантам; считает фоновый DealMetricsCollector через IMlLearningStore.CountOutboxAsync. Рост при недоступности ml-service.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 6,
"x": 18,
"y": 0
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "deal_ml_outbox_depth",
"legendFormat": "outbox",
"refId": "A"
}
],
"title": "Очередь ML-outbox",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "RPS по сервисам (метка service из таргетов Prometheus). Включает HTTP-запросы core и gRPC-вызовы всех 4 процессов (gRPC идёт как ASP.NET Core-запрос с route = имя метода).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 5
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (service) (rate(http_server_request_duration_seconds_count[$__rate_interval]))",
"legendFormat": "{{service}}",
"refId": "A"
}
],
"title": "RPS по сервисам",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "p95 длительности запроса по сервисам (OTel-гистограмма http.server.request.duration). Рост при недоступности БД/внешних вызовов (ИИ-провайдер, ml/telegram-service).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 5
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "histogram_quantile(0.95, sum by (le, service) (rate(http_server_request_duration_seconds_bucket[$__rate_interval])))",
"legendFormat": "{{service}}",
"refId": "A"
}
],
"title": "Длительность запроса p95 по сервисам",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Доля HTTP/gRPC-запросов с кодом ответа 5xx в секунду по сервисам (метка http_response_status_code).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 13
},
"id": 7,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (service) (rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[$__rate_interval]))",
"legendFormat": "{{service}}",
"refId": "A"
}
],
"title": "Ошибки 5xx по сервисам",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Токены платного ИИ (по видам prompt/completion) и оценка токенов локальных ML-вызовов, токенов/сек. Инкремент — общая с ядром точка записи token_usage_events (TokenUsageRecorder).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 13
},
"id": 8,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (type) (rate(deal_ai_tokens_total[$__rate_interval]))",
"legendFormat": "ai/{{type}}",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(rate(deal_ml_tokens_total[$__rate_interval]))",
"legendFormat": "ml/prompt",
"refId": "B"
}
],
"title": "Токены AI/ML, tokens/s",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Вызовы платного ИИ и локального ML в секунду (соответствуют событиям token_usage_events: kind=ai|ml).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 21
},
"id": 9,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(rate(deal_ai_calls_total[$__rate_interval]))",
"legendFormat": "AI-вызовы",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum(rate(deal_ml_calls_total[$__rate_interval]))",
"legendFormat": "ML-вызовы",
"refId": "B"
}
],
"title": "Вызовы AI/ML, calls/s",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"description": "Топ-10 типов событий аудита по частоте (метки event/actor низкокардинальные; tenantId/userId в метрики не попадают). Полная лента с актором — public.audit_log через GET /api/operator/audit.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 21
},
"id": 10,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "topk(10, sum by (event) (rate(deal_audit_events_total[$__rate_interval])))",
"legendFormat": "{{event}}",
"refId": "A"
}
],
"title": "События аудита по типам (top-10)",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": [
"deal",
"metrics"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Deal-Metrics-Overview",
"uid": "deal-metrics",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,375 @@
{
"annotations": {
"list": []
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "HTTP-запросы core в секунду (access-лог HttpAccessLogMiddleware; строка «HTTP <метод> <путь> -> <код>»). gRPC-ингресс здесь не виден — его считают панели gRPC (HTTP-статус gRPC-вызовов всегда 200).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum(rate({service=\"core\"} |= \"HTTP \" [1m]))",
"legendFormat": "HTTP core",
"refId": "A"
}
],
"title": "HTTP RPS (core)",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "RPC-вызовы gRPC в секунду по deal-процессам (access-лог RpcCallLoggingInterceptor; строка «gRPC <метод>: <статус>»). gRPC-health не логируется — фоновые liveness-пробы в счёт не попадают.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum by (service) (rate({service=~\"core|telegram-service|ai-service|ml-service\"} |= \"gRPC \" [1m]))",
"legendFormat": "{{service}}",
"refId": "A"
}
],
"title": "gRPC RPS по сервисам",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Топ-10 HTTP-путей core по частоте вызовов, запросов/сек. По access-логу core (поле Path, query-строка не логируется).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "topk(10, sum by (Path) (rate({service=\"core\"} | json | Path =~ \".+\" [5m])))",
"legendFormat": "{{Path}}",
"refId": "A"
}
],
"title": "HTTP RPS по путям (top-10, core)",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Перцентили длительности запроса core (access-лог core, поле DurationMs — миллисекунды всего пути). p95 резко растёт при недоступности БД/внешних вызовов.",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "ms"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "quantile_over_time(0.5, {service=\"core\"} | json | DurationMs =~ \"[0-9]+\" | unwrap DurationMs [1m])",
"legendFormat": "p50",
"refId": "A"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "quantile_over_time(0.95, {service=\"core\"} | json | DurationMs =~ \"[0-9]+\" | unwrap DurationMs [1m])",
"legendFormat": "p95",
"refId": "B"
}
],
"title": "Длительность HTTP-запросов core (p50/p95), мс",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Топ-10 gRPC-методов по частоте вызовов (access-лог gRPC, поле RpcMethod — полное имя метода с пакетом).",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never"
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "topk(10, sum by (RpcMethod) (rate({service=~\"core|telegram-service|ai-service|ml-service\"} |= \"gRPC \" | json | RpcMethod =~ \".+\" [5m])))",
"legendFormat": "{{RpcMethod}}",
"refId": "A"
}
],
"title": "gRPC-методы по частоте (top-10)",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"description": "Суммарная нагрузка (HTTP + gRPC) в секунду по всем deal-процессам за последнюю минуту.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 5,
"w": 12,
"x": 0,
"y": 24
},
"id": 6,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "sum(rate({service=~\"core|telegram-service|ai-service|ml-service\"} |~ \"(HTTP|gRPC) \" [1m]))",
"legendFormat": "RPS",
"refId": "A"
}
],
"title": "RPS (HTTP+gRPC) сейчас",
"type": "stat"
},
{
"gridPos": {
"h": 5,
"w": 12,
"x": 12,
"y": 24
},
"id": 7,
"options": {
"content": "RPS/нагрузка получены из access-логов (Promtail → Loki) как ленточное дополнение к метрикам. С этапа 12 (пакет A) RPS/латентность/ошибки есть и в OTel-метриках → Prometheus: дашборд Deal-Metrics-Overview (источник — Prometheus). Строка на запрос: HTTP-путь и gRPC-метод пишутся HTTP access-логом и RpcCallLoggingInterceptor без query/заголовков/тел — секретов в этих панелях нет.",
"mode": "markdown"
},
"title": "Откуда эти цифры",
"type": "text"
}
],
"refresh": "1m",
"schemaVersion": 39,
"tags": [
"deal"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Deal-Rps",
"uid": "deal-rps",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,16 @@
# Grafana — provisioning папки дашбордов (Ruling 7/9, план Task 14).
# Дашборды из каталога /var/lib/grafana/dashboards (монтируется из deploy/observability/grafana/dashboards)
# появляются в Grafana автоматически; правка — файлами в репозитории, не через UI.
apiVersion: 1
providers:
- name: deal
orgId: 1
folder: Дейл
type: file
disableDeletion: true
updateIntervalSeconds: 30
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
@@ -0,0 +1,28 @@
# Grafana — provisioning датасорсов Loki (логи) и Prometheus (метрики) — Ruling 7/9; метрики — этап 12, пакет A.
# Монтируется в /etc/grafana/provisioning/datasources (compose.prod.yml).
apiVersion: 1
datasources:
- name: Loki
# UID фиксирован: на него ссылаются панели дашбордов Deal-Health/Auth/Errors/Rps/Logs (datasource uid: loki).
uid: loki
type: loki
access: proxy
url: http://loki:3100
isDefault: true
jsonData:
maxLines: 1000
- name: Prometheus
# UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus).
# URL — сервис compose-профиля observability; метрики scrape'ит сам Prometheus с /metrics процессов.
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: false
jsonData:
# Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile.
httpMethod: POST
timeInterval: 15s
+45
View File
@@ -0,0 +1,45 @@
# Loki — хранилище логов deal-стека (Ruling 7/9, план Task 14). Single-binary режим, файловое
# хранилище (без object-store): данные — /loki на volume deal_loki_data.
#
# Retention — 7 суток (168h): включается compactor'ом (retention_enabled) + limits_config.
# Это сознательно «логи на неделю»: долгосрочное хранение/выгрузка — rolling-файлы Serilog
# data/logs/deal-*.json (у core — на volume /app/data, см. compose.prod.yml).
auth_enabled: false
server:
http_listen_port: 3100
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
# Период хранения: 7 суток (168 часов). События старше удаляются compactor'ом (см. ниже).
limits_config:
retention_period: 168h
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
# Loki 3.x: при retention_enabled требуется store для delete-запросов (файловое хранилище).
delete_request_store: filesystem
+81
View File
@@ -0,0 +1,81 @@
# Правила алертов Prometheus для процессов Deal (этап 12, пакет A; Ruling 7 этапа 7).
#
# Подключаются в deploy/observability/prometheus.yml через `rule_files`. Источник метрик — эндпоинты
# /metrics процессов (OTel → экспортёр Prometheus), метка target `service` (core/telegram-service/
# ai-service/ml-service). Имена метрик — в legacy-схеме (global.metric_name_validation_scheme: legacy),
# поэтому точки OTel-имён экранированы в `_` (`deal.sessions.active` → `deal_sessions_active`).
#
# Пороги подобраны консервативно и осознанно «мягкие»: это задел оператору, а не жёсткий SLO —
# уточняйте под реальный профиль нагрузки. Токен-бюджет в Prometheus НЕ алертится намеренно: метрики
# бюджета (`deal_*`) нет (метки низкокардинальные, без tenantId/бюджета). Исчерпание ИИ-бюджета
# доставляется тенанту SSE-тостом (BudgetAlertScheduler), см. техдок §6/§7/§10.
groups:
- name: deal-availability
rules:
# Сервис не отвечает на scrape (недоступен): конфиг включает 4 процесса Deal + сам Prometheus.
- alert: DealServiceDown
expr: up{job="deal"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Сервис Deal недоступен — {{ $labels.service }}"
description: "Prometheus не может снять /metrics с {{ $labels.instance }} (service={{ $labels.service }}) более 2 минут."
# Пропажа метрик ядра: scrape ядра или datasource недоступен, хотя Prometheus жив.
- alert: DealCoreMetricsAbsent
expr: absent(deal_sessions_active)
for: 5m
labels:
severity: critical
annotations:
summary: "Нет метрик ядра Deal"
description: "Метрика deal_sessions_active отсутствует более 5 минут — не скрейпится ядро (core:9464) или datasource недоступен."
- name: deal-errors
rules:
# Рост доли 5xx: gRPC-вызовы тоже попадают в http_server_request_duration_seconds_* (gRPC-ингресс).
- alert: DealHigh5xxRatio
expr: |
sum by (service) (rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m]))
/
clamp_min(sum by (service) (rate(http_server_request_duration_seconds_count[5m])), 0.001) > 0.05
for: 10m
labels:
severity: warning
annotations:
summary: "Рост 5xx в сервисе {{ $labels.service }}"
description: "Доля 5xx в {{ $labels.service }} выше 5% за 5 минут (текущее значение {{ $value | humanizePercentage }})."
# Абсолютный всплеск 5xx — ловит рост ошибок и на низком трафике, где доля не показательна.
- alert: Deal5xxBurst
expr: sum by (service) (rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "Всплеск 5xx в сервисе {{ $labels.service }}"
description: "Более 1 ответа 5xx/с в {{ $labels.service }} за 5 минут ({{ $value }}/с)."
- name: deal-queues
rules:
# Лаг очереди пайплайна: воркер ходит каждые 2 с, устойчивая глубина = воркер не успевает/залип.
- alert: DealPipelineQueueBacklog
expr: deal_pipeline_queue_depth > 100
for: 15m
labels:
severity: warning
annotations:
summary: "Растёт очередь пайплайна Deal"
description: "Суммарная глубина очереди пайплайна (new+filtered) держится выше 100 более 15 минут ({{ $value }})."
# Лаг ML-outbox: outbox-цикл идёт каждые 10 с; устойчивый рост = ml-service недоступен/отстаёт.
- alert: DealMlOutboxBacklog
expr: deal_ml_outbox_depth > 100
for: 15m
labels:
severity: warning
annotations:
summary: "Растёт очередь обучения ML (outbox)"
description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})."
+48
View File
@@ -0,0 +1,48 @@
# Prometheus — сбор метрик Deal-процессов (этап 12, пакет A; Ruling 7 этапа 7).
#
# Источник — эндпоинты /metrics процессов Deal (OpenTelemetry → экспортёр Prometheus). Процессы
# слушают метрики на ОТДЕЛЬНОМ HTTP/1.1 Kestrel-эндпоинте :9464 (gRPC-порты :51015103/:5082 — HTTP/2,
# обычный GET scrape по ним невозможен). Порт метрик наружу не публикуется: scrape идёт внутри
# compose-сети от этого контейнера. Метка service (целевая) используется панелями Grafana
# Deal-Metrics-Overview для разбивки RPS/латентности/ошибок по процессам.
#
# Поднимается профилем observability (deploy/compose.prod.yml); конфиг монтируется в
# /etc/prometheus/prometheus.yml. Retention задаётся ключом --storage.tsdb.retention.time (15 суток).
global:
scrape_interval: 15s
evaluation_interval: 15s
# Имена метрик — в legacy-схеме Prometheus (точки в OTel-именах экранируются в _): без этой опции
# scrape-запрос Prometheus 3 запрашивает UTF-8-схему, и метрики сохраняются с точками
# (`deal.sessions.active`), что ломает привычные PromQL-имена (`deal_sessions_active`).
metric_name_validation_scheme: legacy
# Правила алертов (этап 12) — монтируются рядом с этим конфигом (/etc/prometheus/prometheus-rules.yml).
# Путь относительный — резолвится от каталога конфига (/etc/prometheus). Правила см. в
# deploy/observability/prometheus-rules.yml.
rule_files:
- prometheus-rules.yml
scrape_configs:
# Само-мониторинг Prometheus (доступность самого коллектора).
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
# 4 процесса «Дейла»: core (:5080 HTTP + :5082 gRPC-ингресс) и три gRPC-сервиса.
# У каждого — свой /metrics на :9464; target-метка service попадает во все серии.
- job_name: deal
metrics_path: /metrics
static_configs:
- targets: ["core:9464"]
labels:
service: core
- targets: ["telegram-service:9464"]
labels:
service: telegram-service
- targets: ["ai-service:9464"]
labels:
service: ai-service
- targets: ["ml-service:9464"]
labels:
service: ml-service
+52
View File
@@ -0,0 +1,52 @@
# Promtail — сбор логов контейнеров deal-стека для Loki (Ruling 7/9, план Task 14).
#
# Источник — docker-логи ВСЕХ контейнеров хоста через docker_sd (нужен доступ к docker.sock, см.
# compose.prod.yml). Контейнеры deal-процессов пишут в stdout одну JSON-строку на событие
# (Serilog CompactJsonFormatter, Ruling 7) — лог можно парсить в Loki через «| json» без доп. шаблонов.
# relabel добавляет метки: container (имя контейнера) и service (имя compose-сервиса из стандартной
# метки com.docker.compose.service) — по ним строятся дашборды Grafana (Deal-Health, Deal-Auth,
# Deal-Errors, Deal-Rps, Deal-Logs). Pipeline дополнительно поднимает поле Serilog "@l" в метку
# level (Information/Warning/Error/Fatal) — по ней фильтруют панели Deal-Logs.
server:
http_listen_port: 9080
grpc_listen_port: 0
# Позиции чтения — на volume deal_promtail_data (переживают рестарт контейнера).
positions:
filename: /var/lib/promtail/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
# Имя контейнера (docker присваивает <проект>-<сервис>-<n> или container_name).
- source_labels: ["__meta_docker_container_name"]
regex: "/(.*)"
target_label: container
# Имя compose-сервиса (core/telegram-service/ai-service/ml-service/...) из метки compose.
- source_labels: ["__meta_docker_container_label_com_docker_compose_service"]
target_label: service
# Поток stdout/stderr — отдельной меткой (у Serilog весь вывод — stdout).
- source_labels: ["__meta_docker_container_log_stream"]
target_label: stream
pipeline_stages:
# Пайплайн применяется ТОЛЬКО к deal-процессам (match по метке service из relabel выше):
# их логи — одна JSON-строка Serilog CompactJsonFormatter на событие. Прочие контейнеры
# хоста (postgres/minio/caddy/loki/...) пишут не-JSON — их пайплайн не трогает.
- match:
selector: '{service=~"core|telegram-service|ai-service|ml-service"}'
stages:
# Поле Serilog "@l" (ключ со спецсимволом @ — литерал JMESPath в одинарных кавычках).
- json:
expressions:
level: '"@l"'
# drop_malformed не задан (false) — строка без валидного JSON не роняется, а проходит
# без метки level (деградация мягкая).
- labels:
level: