Добавлен OTLP-провайдер, реализовано отбрасывание метрик и трейсов в observability + добавлен перезапуск nginx при пересборке контейнеров (ошибка, когда докер меняет адреса сервисов)
This commit is contained in:
@@ -353,6 +353,15 @@ SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me
|
||||
# Observability
|
||||
# =============================================================================
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
OTEL_SERVICE_NAME_API=api-backend
|
||||
OTEL_SERVICE_NAME_SMS_API=sms-service
|
||||
OTEL_SERVICE_NAME_SMS_WORKER=sms-worker
|
||||
OTEL_TRACES_SAMPLER=always_on
|
||||
SMS_METRICS_PORT=9464
|
||||
OTEL_REMOTE_ENDPOINT=192.168.0.5:4317
|
||||
OTEL_REMOTE_AUTH_HEADER=
|
||||
OTEL_REMOTE_TLS_INSECURE=true
|
||||
OTEL_QUEUE_SIZE=10000
|
||||
```
|
||||
|
||||
S3-клиенты используют только virtual-hosted addressing
|
||||
|
||||
+62
-36
@@ -1,39 +1,65 @@
|
||||
В разработку:
|
||||
~~1. MVP frontend: один чат с компанией без истории диалогов; пункт «Чат» открывает текущий активный диалог или создаёт его при отсутствии~~
|
||||
~~2. При создании пользователя номер телефона копировать в профиль Russian_Phone~~
|
||||
~~3. Убрать хеширование устройства клиента в devise_json - хочу видеть его параметры.~~
|
||||
~~4. Добавить параметр, ограничивающих кол-во неуспешных попыток ввода смс.~~
|
||||
~~5. Поправить чтение сообщений от Битрикса. Сейчас они выглядят так: "[b]Антон Пичугин:[/b] [br]опять ты?" Надо убрать из текста сообщения Отправителя в битриксе~~
|
||||
6. Кнопка "Войти" для авторизации
|
||||
~~7. Если неавторизованный пользователь вводит сообщение, после отправки идет на регистрацию, после окончания регистрации его сообщение пропадает. Надо чтобы сохранялось и отправлялось (по аналогии с нажатием на кнопку из раздела "Популярные вопросы")~~
|
||||
~~8. Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession)~~
|
||||
9. Кнопка "Позвонить оператору" (ссылка tel:+74999591007)
|
||||
10. Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован, превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h), превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts).
|
||||
11. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно.
|
||||
~~12. Яндекс.капчу добавить~~
|
||||
13. На экране профиля в гостевом режиме добавить "Авторизоваться"
|
||||
14. Проверить повторную отправку СМС (меня перенесло на главный экран)
|
||||
15. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak))
|
||||
16. Сделать тестового пользователя с фиксированным СМС-входом
|
||||
~~17. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)~~
|
||||
~~18. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."~~
|
||||
19. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение)
|
||||
20. Убрать с экрана при запросе OTP тексты согласий (внизу экрана)
|
||||
21. UX-дефект: frontend показывает «Не удалось завершить вход» при ошибке отправки отложенного сообщения, хотя вход завершён. Это следует исправить: завершать экран авторизации после bootstrap, а ошибку Bitrix показывать уже в чате.
|
||||
22. Веб-пуши для PWA
|
||||
23. На кнопке Чат отображать значок наличия непрочитанных уведомлений. Требуется синхронизация между устройствами (решение, например через Dialog.client_last_opened_at)
|
||||
24. На главном экране две кнопки: чат и звонок оператору. На кнопке с чатом уведомление при наличии непрочитанных сообщений.
|
||||
25. Поменять функционал карточке: сейчас слайдер, нужна карусель со стрелками (либо какое-то комбо - подобрать в фигма.)
|
||||
# Закрыто 21.07-27.07
|
||||
1. MVP frontend: один чат с компанией без истории диалогов; пункт «Чат» открывает текущий активный диалог или создаёт его при отсутствии
|
||||
2. При создании пользователя номер телефона копировать в профиль Russian_Phone
|
||||
3. Убрать хеширование устройства клиента в devise_json - хочу видеть его параметры.
|
||||
4. Добавить параметр, ограничивающих кол-во неуспешных попыток ввода смс.
|
||||
5. Поправить чтение сообщений от Битрикса. Сейчас они выглядят так: "[b]Антон Пичугин:[/b] [br]опять ты?" Надо убрать из текста сообщения Отправителя в битриксе
|
||||
6. Если неавторизованный пользователь вводит сообщение, после отправки идет на регистрацию, после окончания регистрации его сообщение пропадает. Надо чтобы сохранялось и отправлялось (по аналогии с нажатием на кнопку из раздела "Популярные вопросы")
|
||||
7. Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession)
|
||||
8. Яндекс.капчу добавить
|
||||
9. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)
|
||||
10. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."
|
||||
11. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.
|
||||
12. Моделирование уведомлений — постановка v6 синхронизирована с `functional_blocks (business logic)/notification-requirements.md`, arch-00…05 и module-01/03/07. Зафиксированы: instruction только в новой вкладке; TTL только при отсутствии `date_expired`; первое скачивание любого связанного документа скрывает; `producer_test` только для smoke, secret/hash раздельно.
|
||||
13. Кнопка "Позвонить оператору" (ссылка tel:+74999591007)
|
||||
14. На главном экране две кнопки: чат и звонок оператору. На кнопке с чатом уведомление при наличии непрочитанных сообщений.
|
||||
|
||||
На будущее (после доработки отдельных функциональностей):
|
||||
1. Разработка message-safety
|
||||
2. Разработка sync-service
|
||||
~~3. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.~~
|
||||
3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?)
|
||||
4. Моделирование профиля клиента/
|
||||
~~5. Моделирование уведомлений — постановка v6 синхронизирована с `functional_blocks (business logic)/notification-requirements.md`, arch-00…05 и module-01/03/07. Зафиксированы: instruction только в новой вкладке; TTL только при отсутствии `date_expired`; первое скачивание любого связанного документа скрывает; `producer_test` только для smoke, secret/hash раздельно.~~
|
||||
6. Реализация мнемоник.
|
||||
7. Реализовать в полноценном `bitrix-sync` обработчик `document.client_uploaded`: claim/retry/DLQ, идемпотентность по `client_document_id`, группировка по `submission_id`; до этого stub задачи не claim-ит.
|
||||
# Закрыто 28.07-03.08
|
||||
|
||||
|
||||
|
||||
# В разработку:
|
||||
1. Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован, превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h), превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts).
|
||||
2. После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно.
|
||||
3. На экране профиля в гостевом режиме добавить кнопку "Авторизоваться"
|
||||
4. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak))
|
||||
5. Сделать тестового пользователя с фиксированным СМС-входом
|
||||
6. Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение)
|
||||
7. Убрать с экрана при запросе OTP тексты согласий (внизу экрана)
|
||||
8. UX-дефект: frontend показывает «Не удалось завершить вход» при ошибке отправки отложенного сообщения, хотя вход завершён. Это следует исправить: завершать экран авторизации после bootstrap, а ошибку Bitrix показывать уже в чате.
|
||||
9. Веб-пуши для PWA
|
||||
10. На кнопке Чат отображать значок наличия непрочитанных уведомлений. Требуется синхронизация между устройствами (решение, например через Dialog.client_last_opened_at)
|
||||
11. Поменять функционал карточке: сейчас слайдер, нужна карусель со стрелками (либо какое-то комбо - подобрать в фигма.)
|
||||
12. Описание бизнес сущностей
|
||||
13. Вынести за пределы ВМ1 сервисы message-safety и sync-service.
|
||||
14. Сделать страницу с инстркцией по установке приложения
|
||||
15. Написать пользовательское соглашение.
|
||||
16. Разработка message-safety
|
||||
17. Разработка sync-service
|
||||
18. Разработка notification-service
|
||||
19. Подключить OTLP-провайдер
|
||||
20. Поднять второй контур для продакшн
|
||||
21. Спрятать сеть за балансировщиком нагрузки
|
||||
22. Ограничить кол-во символов в сообщении на фронте
|
||||
23. При отрицательном результате проверки сообщения - выдавать пользователю корректную ошибку. Если сообщение отправлялось с главного экрана, то надо направить в чат, и там показать ошибку.
|
||||
24. Добавить логи (Для Python-сервисов добавить OTLP Log Exporter: api-backend; sms-service; sms-worker. Подключить LoggerProvider, BatchLogRecordProcessor и bounded queue. Передавать resource attributes: service.name; service.version; deployment.environment; service.namespace=han-chat.) Экспортировать структурированные поля request_id, trace_id, span_id, severity и event name. Оставить stdout как аварийный локальный журнал. Добавить canary-тесты, запрещающие экспорт токенов, cookie, телефонов, email, текстов сообщений, SQL и object keys.).
|
||||
25. Nginx metrics/tracing в signoz
|
||||
|
||||
|
||||
На будущее (после доработки отдельных функциональностей):
|
||||
1. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?)
|
||||
2. Моделирование профиля клиента.
|
||||
3. Реализация мнемоник.
|
||||
4. Реализовать в полноценном `bitrix-sync` обработчик `document.client_uploaded`: claim/retry/DLQ, идемпотентность по `client_document_id`, группировка по `submission_id`; до этого stub задачи не claim-ит.
|
||||
|
||||
На анализ:
|
||||
debounce на отправку СМС (сейчас есть Фиксированный cooldownmin_seconds_between_attempts)
|
||||
debounce на отправку СМС (сейчас есть Фиксированный cooldownmin_seconds_between_attempts)
|
||||
|
||||
# Критично для релиза:
|
||||
1. Разработка message-safety
|
||||
2. Разработка sync-service
|
||||
3. Пользовательское соглашение
|
||||
4. Разработка notification-service
|
||||
5. Подключить OTLP-провайдер
|
||||
6. Починить баги
|
||||
7. Второй контур для продакшн
|
||||
@@ -0,0 +1,146 @@
|
||||
# SigNoz для HAN Chat
|
||||
|
||||
Одноузловой self-hosted SigNoz на Ubuntu 24.04 через официальный
|
||||
`foundryctl` и Docker Compose.
|
||||
|
||||
Сетевая модель:
|
||||
|
||||
- VM SigNoz: `192.168.0.5`;
|
||||
- OTLP/gRPC: `192.168.0.5:4317`, только приватная сеть;
|
||||
- OTLP/HTTP: `192.168.0.5:4318`, только приватная сеть;
|
||||
- UI/API: `127.0.0.1:8080`, доступ только через SSH-туннель;
|
||||
- ClickHouse, PostgreSQL и ClickHouse Keeper наружу не публикуются.
|
||||
|
||||
## Требования
|
||||
|
||||
- минимум 4 GiB RAM (для эксплуатации лучше 8 GiB);
|
||||
- минимум 30 GiB свободного диска для старта;
|
||||
- временный исходящий доступ в интернет на время установки и обновлений;
|
||||
- рабочий приватный интерфейс с адресом `192.168.0.5`;
|
||||
- доступ к ВМ по SSH через приватную сеть до отключения внешнего IP.
|
||||
|
||||
## Установка
|
||||
|
||||
Скопируйте эту папку на ВМ, например в `/opt/signoz`:
|
||||
|
||||
```bash
|
||||
rsync -rltD --no-perms --no-owner --no-group -ivc --delete \
|
||||
--exclude='.env' \
|
||||
--exclude='dist/' \
|
||||
--exclude='secrets/' \
|
||||
--exclude='pours/' \
|
||||
--exclude='casting.yaml.lock' \
|
||||
-e "ssh -i ~/.ssh/hansel-private" \
|
||||
/mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/Signoz/ \
|
||||
root@135.106.166.7:/opt/signoz/
|
||||
```
|
||||
|
||||
Исключения `pours/` и `casting.yaml.lock` обязательны при повторной
|
||||
синхронизации: Foundry создаёт их непосредственно на VM. Без исключений
|
||||
`rsync --delete` удалит runtime-конфигурацию, которой нет локально.
|
||||
|
||||
На ВМ:
|
||||
|
||||
```bash
|
||||
cd /opt/signoz
|
||||
sed -i 's/\r$//' scripts/*.sh
|
||||
chmod +x scripts/*.sh
|
||||
|
||||
sudo ./scripts/05-configure-private-network.sh
|
||||
sudo PRIVATE_IP=192.168.0.5 ./scripts/00-check-vm.sh
|
||||
sudo ./scripts/10-install-docker.sh
|
||||
sudo PRIVATE_IP=192.168.0.5 ./scripts/20-deploy-signoz.sh
|
||||
```
|
||||
|
||||
После запуска создайте первого администратора и организацию SigNoz. До этого
|
||||
OpAMP не выдаст ingester рабочую OTLP-конфигурацию, хотя контейнер будет
|
||||
выглядеть запущенным.
|
||||
|
||||
Адреса `127.0.0.1` всегда означают loopback той машины, на которой
|
||||
интерпретируются. HAN_CHAT имеет приватный адрес `192.168.0.1`, а SigNoz —
|
||||
`192.168.0.5`. UI слушает `127.0.0.1:8080` именно на VM SigNoz.
|
||||
|
||||
Для постоянного доступа после удаления публичного IP SigNoz добавьте в
|
||||
`C:\Users\MI\.ssh\config`:
|
||||
|
||||
```sshconfig
|
||||
Host han-jump
|
||||
HostName 135.106.164.58
|
||||
User root
|
||||
IdentityFile C:\Users\MI\.ssh\hansel
|
||||
|
||||
Host signoz-private
|
||||
HostName 192.168.0.5
|
||||
User root
|
||||
IdentityFile C:\Users\MI\.ssh\hansel-private
|
||||
ProxyJump han-jump
|
||||
```
|
||||
|
||||
На рабочем компьютере откройте туннель до SigNoz через HAN_CHAT:
|
||||
|
||||
```powershell
|
||||
ssh -L 8080:127.0.0.1:8080 signoz-private -N
|
||||
```
|
||||
Пока есть доступ снаружи, можно проще:
|
||||
ssh -i C:\Users\MI\.ssh\hansel-private -L 8080:127.0.0.1:8080 root@135.106.166.7 -N
|
||||
|
||||
|
||||
Маршрут SSH: Windows → публичный адрес HAN_CHAT → `192.168.0.5:22`.
|
||||
Назначение `127.0.0.1:8080` в `-L` открывает конечная VM SigNoz, а не
|
||||
HAN_CHAT.
|
||||
|
||||
Откройте `http://127.0.0.1:8080`, создайте администратора/организацию, затем
|
||||
на VM выполните:
|
||||
|
||||
```bash
|
||||
cd /opt/signoz
|
||||
sudo docker compose -f pours/deployment/compose.yaml restart ingester
|
||||
sudo ./scripts/30-verify-signoz.sh
|
||||
```
|
||||
|
||||
После успешной проверки настройте host firewall. Подставьте реальный приватный
|
||||
IP backend:
|
||||
|
||||
```bash
|
||||
sudo OTLP_SOURCE=192.168.0.1 \
|
||||
ADMIN_CIDR=192.168.0.0/24 \
|
||||
PRIVATE_IP=192.168.0.5 \
|
||||
./scripts/40-configure-firewall.sh
|
||||
```
|
||||
|
||||
Затем выполните чек-лист из [docs/NETWORK.md](docs/NETWORK.md), подключите
|
||||
backend по [docs/BACKEND_OTLP.md](docs/BACKEND_OTLP.md) и только после
|
||||
успешной end-to-end проверки удалите внешний IP.
|
||||
|
||||
## Проверка и управление
|
||||
|
||||
```bash
|
||||
cd /opt/signoz
|
||||
sudo ./scripts/30-verify-signoz.sh
|
||||
sudo docker compose -f pours/deployment/compose.yaml ps
|
||||
sudo docker compose -f pours/deployment/compose.yaml logs --tail=200
|
||||
sudo docker compose -f pours/deployment/compose.yaml restart
|
||||
```
|
||||
|
||||
Не редактируйте `pours/` вручную: Foundry перегенерирует эту папку.
|
||||
Постоянные изменения вносятся в `casting.yaml`, после чего снова запускается
|
||||
`20-deploy-signoz.sh`.
|
||||
|
||||
Обновление требует временного доступа к Docker Hub, GitHub и SigNoz:
|
||||
|
||||
```bash
|
||||
cd /opt/signoz
|
||||
sudo ./scripts/20-deploy-signoz.sh
|
||||
```
|
||||
|
||||
Перед обновлением сделайте snapshot диска ВМ. Данные находятся в Docker
|
||||
volumes ClickHouse и PostgreSQL; `docker compose down -v` удалит их и поэтому
|
||||
для штатного обслуживания запрещён.
|
||||
|
||||
## Документация
|
||||
|
||||
- [NETWORK.md](docs/NETWORK.md) — сеть, группа безопасности, SSH-туннель;
|
||||
- [BACKEND_OTLP.md](docs/BACKEND_OTLP.md) — передача телеметрии HAN Chat;
|
||||
- [SIGNOZ_RUNBOOK.md](docs/SIGNOZ_RUNBOOK.md) — что смотреть в SigNoz;
|
||||
- [MVP_DASHBOARDS_ALERTS.md](docs/MVP_DASHBOARDS_ALERTS.md) — versioned
|
||||
спецификация первых dashboards и alerts.
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Installation
|
||||
metadata:
|
||||
name: signoz
|
||||
spec:
|
||||
deployment:
|
||||
flavor: compose
|
||||
mode: docker
|
||||
patches:
|
||||
- target: deployment/compose.yaml
|
||||
operations:
|
||||
- op: replace
|
||||
path: /services/ingester/ports
|
||||
value:
|
||||
- "192.168.0.5:4317:4317"
|
||||
- "192.168.0.5:4318:4318"
|
||||
- op: replace
|
||||
path: /services/signoz-signoz-0/ports
|
||||
value:
|
||||
- "127.0.0.1:8080:8080"
|
||||
@@ -0,0 +1,362 @@
|
||||
# Подключение HAN Chat к SigNoz
|
||||
|
||||
## Схема
|
||||
|
||||
Приложения в Docker-сети отправляют OTLP локальному `otel-collector`:
|
||||
|
||||
```text
|
||||
HAN containers -> otel-collector:4317 -> 192.168.0.5:4317 -> SigNoz
|
||||
```
|
||||
|
||||
Локальный Collector выполняет редактирование чувствительных атрибутов,
|
||||
добавляет `service.namespace=han-chat`, окружение и версию, сохраняет очередь
|
||||
на диск и пересылает данные в SigNoz.
|
||||
|
||||
## Настройка backend
|
||||
|
||||
В `codebase/backend/.env`:
|
||||
cd /opt/han-chat/backend
|
||||
|
||||
```dotenv
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
OTEL_REMOTE_ENDPOINT=192.168.0.5:4317
|
||||
OTEL_REMOTE_AUTH_HEADER=
|
||||
OTEL_REMOTE_TLS_INSECURE=true
|
||||
```
|
||||
|
||||
`OTEL_REMOTE_TLS_INSECURE=true` допустим только потому, что трафик идёт по
|
||||
изолированной приватной сети. Self-hosted SigNoz по умолчанию не требует
|
||||
authorization header. Не подставляйте фиктивный токен.
|
||||
|
||||
Применение:
|
||||
|
||||
```bash
|
||||
cd /opt/han-chat/backend
|
||||
# Перед сменой sampling остановите producers и дайте persistent queue уйти.
|
||||
docker compose stop api-backend sms-service sms-worker
|
||||
until ! docker compose logs --since=2m otel-collector |
|
||||
grep -Eq 'queue is full|sending_queue.*[1-9][0-9]*'; do sleep 15; done
|
||||
|
||||
# Сначала validate той же версией Collector, которая закреплена в Compose.
|
||||
docker run --rm \
|
||||
-e APP_ENV=validate -e RELEASE_VERSION=validate \
|
||||
-e OTEL_REMOTE_ENDPOINT=192.168.0.5:4317 \
|
||||
-e OTEL_REMOTE_AUTH_HEADER= -e OTEL_REMOTE_TLS_INSECURE=true \
|
||||
--tmpfs /var/lib/otelcol/queue:rw,noexec,nosuid,size=16m,mode=0777 \
|
||||
-v "$PWD/observability/otel-collector.yaml:/etc/otelcol/config.yaml:ro" \
|
||||
otel/opentelemetry-collector-contrib:0.117.0 \
|
||||
validate --config=/etc/otelcol/config.yaml
|
||||
|
||||
docker compose config >/dev/null
|
||||
docker compose up -d --force-recreate otel-collector
|
||||
docker compose up -d --build api-backend sms-service sms-worker
|
||||
docker compose up -d --wait api-backend sms-service sms-worker
|
||||
|
||||
# Nginx хранит IP Docker upstream после загрузки config. После пересоздания
|
||||
# API/SMS перечитайте Docker DNS без остановки edge.
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose kill -s HUP nginx
|
||||
|
||||
docker compose ps otel-collector
|
||||
docker compose logs --tail=200 otel-collector
|
||||
curl -fsS https://chat.han0107.ru/api/v1/public/app-config >/dev/null
|
||||
```
|
||||
|
||||
Не удаляйте `observability/.otel-queue`: это bounded persistent queue, которая
|
||||
обеспечивает recovery после недоступности SigNoz.
|
||||
|
||||
Проверка сети с backend-хоста:
|
||||
|
||||
```bash
|
||||
timeout 3 bash -c 'exec 3<>/dev/tcp/192.168.0.5/4317' \
|
||||
&& echo 'OTLP gRPC reachable'
|
||||
curl -fsS --max-time 5 \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"resourceSpans":[]}' \
|
||||
http://192.168.0.5:4318/v1/traces
|
||||
```
|
||||
|
||||
Если используется только gRPC, после диагностики порт 4318 можно закрыть.
|
||||
|
||||
## End-to-end тест канала
|
||||
|
||||
Одна лишь доступность TCP не доказывает запись в ClickHouse. Отправьте тестовые
|
||||
трейсы через локальный Collector из его Docker-сети:
|
||||
|
||||
```bash
|
||||
cd /opt/han-chat/backend
|
||||
chmod +x deployment/scripts/verify-observability.sh
|
||||
./deployment/scripts/verify-observability.sh
|
||||
```
|
||||
|
||||
Скрипт проверяет внутренние SMS metrics endpoints, отправляет canary traces и
|
||||
проверяет свежие exporter/queue ошибки. Эквивалентная ручная отправка:
|
||||
|
||||
```bash
|
||||
docker run --rm --network han-chat-observability \
|
||||
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
|
||||
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
|
||||
--service han-chat-otlp-smoke --traces 100 --rate 20
|
||||
```
|
||||
|
||||
Collector сохраняет только 10% traces. Сто тестовых traces дают достаточно
|
||||
высокую вероятность увидеть результат; запуск с десятью traces может случайно
|
||||
не оставить ни одного.
|
||||
|
||||
Если имя образа/синтаксис изменились, используйте актуальный `telemetrygen`
|
||||
из релиза OpenTelemetry Collector Contrib. На production-хосте образ нужен
|
||||
только для smoke-теста и затем может быть удалён.
|
||||
|
||||
В SigNoz откройте **Services** или **Traces** и отфильтруйте:
|
||||
|
||||
```text
|
||||
service.name = han-chat-otlp-smoke
|
||||
service.namespace = han-chat
|
||||
```
|
||||
|
||||
Проверьте, что timestamps свежие. Затем изучите логи Collector:
|
||||
|
||||
```bash
|
||||
docker compose logs --since=10m otel-collector
|
||||
```
|
||||
|
||||
Не должно быть `connection refused`, `tls`, `Unauthenticated`, переполнения
|
||||
очереди или постоянных retry.
|
||||
|
||||
## Outage/recovery acceptance
|
||||
|
||||
Проводите тест в согласованное окно. Нужны доступ к security group VM SigNoz,
|
||||
две SSH-сессии на backend и открытый UI SigNoz. Бизнес-сервисы и локальный
|
||||
Collector не останавливайте.
|
||||
|
||||
### 1. Зафиксировать baseline
|
||||
|
||||
На backend:
|
||||
|
||||
```bash
|
||||
cd /opt/han-chat/backend
|
||||
date -u '+baseline_started=%Y-%m-%dT%H:%M:%SZ'
|
||||
docker compose ps otel-collector api-backend sms-service sms-worker
|
||||
./deployment/scripts/verify-observability.sh
|
||||
```
|
||||
|
||||
Получить внутренние метрики очереди через Python, уже имеющийся в
|
||||
`sms-service`:
|
||||
|
||||
```bash
|
||||
collector_metrics() {
|
||||
docker compose exec -T sms-service python -c \
|
||||
'import urllib.request; print(urllib.request.urlopen(
|
||||
"http://otel-collector:8888/metrics", timeout=3
|
||||
).read().decode())'
|
||||
}
|
||||
|
||||
collector_metrics |
|
||||
grep -E 'otelcol_exporter_(queue_size|queue_capacity|send_failed)'
|
||||
```
|
||||
|
||||
Перед тестом `queue_size` для exporter `otlp/remote` должен быть `0` или
|
||||
стабильно уменьшаться. Сохраните baseline:
|
||||
|
||||
```bash
|
||||
collector_metrics > /tmp/otel-metrics-before.txt
|
||||
docker compose logs --since=10m otel-collector \
|
||||
> /tmp/otel-collector-before.log
|
||||
```
|
||||
|
||||
### 2. Отключить только remote OTLP
|
||||
|
||||
В security group VM SigNoz временно отключите входящее правило:
|
||||
|
||||
```text
|
||||
source: приватный IP backend
|
||||
destination: 192.168.0.5
|
||||
protocol/port: TCP 4317
|
||||
```
|
||||
|
||||
Не меняйте TCP 22, маршруты приватной сети и правила самого backend. Запишите
|
||||
UTC-время изменения. На backend проверьте, что блокировка действительно
|
||||
применилась:
|
||||
|
||||
```bash
|
||||
date -u '+outage_started=%Y-%m-%dT%H:%M:%SZ'
|
||||
if timeout 5 bash -c 'exec 3<>/dev/tcp/192.168.0.5/4317'; then
|
||||
echo 'ОШИБКА: 4317 всё ещё доступен'
|
||||
else
|
||||
echo 'OK: remote OTLP недоступен'
|
||||
fi
|
||||
```
|
||||
|
||||
### 3. Проверить fail-open бизнес-запросов
|
||||
|
||||
Укажите реальный публичный URL HAN Chat. Используйте read-only endpoint, чтобы
|
||||
не создавать бизнес-данные:
|
||||
|
||||
```bash
|
||||
export HAN_BASE_URL='https://<HAN_CHAT_HOST>'
|
||||
rm -f /tmp/otel-outage-http.tsv
|
||||
|
||||
for i in $(seq 1 24); do
|
||||
request_id="$(cat /proc/sys/kernel/random/uuid)"
|
||||
if ! code="$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-H "X-Request-ID: $request_id" \
|
||||
"$HAN_BASE_URL/api/v1/public/content")"; then
|
||||
code=000
|
||||
fi
|
||||
printf '%s\t%s\t%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$code" "$request_id" |
|
||||
tee -a /tmp/otel-outage-http.tsv
|
||||
sleep 5
|
||||
done
|
||||
|
||||
awk -F '\t' '$2 !~ /^2/ {print "FAIL", $0; failed=1} END {exit failed}' \
|
||||
/tmp/otel-outage-http.tsv
|
||||
```
|
||||
|
||||
Все 24 запроса должны вернуть `2xx`. Это подтверждает, что недоступность
|
||||
remote exporter не останавливает API. `429`, `5xx` или `000` требуют разбора;
|
||||
не считайте такой запуск успешным.
|
||||
|
||||
Чтобы очередь гарантированно получила достаточное число traces, пока порт
|
||||
заблокирован, отправьте canary через локальный Collector:
|
||||
|
||||
```bash
|
||||
docker run --rm --network han-chat-observability \
|
||||
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
|
||||
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
|
||||
--service han-chat-outage-canary --traces 100 --rate 20
|
||||
```
|
||||
|
||||
### 4. Контролировать bounded queue
|
||||
|
||||
Во второй SSH-сессии сначала повторно объявите функцию `collector_metrics` из
|
||||
шага 1, затем каждые 10 секунд выполняйте:
|
||||
|
||||
collector_metrics() {
|
||||
docker compose exec -T sms-service python -c \
|
||||
'import urllib.request; print(urllib.request.urlopen("http://otel-collector:8888/metrics", timeout=3).read().decode())'
|
||||
}
|
||||
|
||||
collector_metrics > /tmp/otel-metrics-before.txt
|
||||
docker compose logs --since=10m otel-collector \
|
||||
> /tmp/otel-collector-before.log
|
||||
|
||||
```Проверка
|
||||
collector_metrics | grep otelcol_exporter_queue
|
||||
|
||||
```bash
|
||||
collector_metrics |
|
||||
grep -E 'otelcol_exporter_(queue_size|queue_capacity|send_failed)'
|
||||
docker compose logs --since=30s otel-collector |
|
||||
grep -Ei 'retry|queue is full|dropp|refused|permanent error' || true
|
||||
```
|
||||
|
||||
Ожидаемо: `queue_size` растёт, Collector пишет о временных retry, а приложения
|
||||
остаются healthy. Немедленно восстановите правило TCP 4317, если:
|
||||
|
||||
- `queue_size / queue_capacity >= 0.8`;
|
||||
- появился `queue is full`, permanent error или dropped telemetry;
|
||||
- любой бизнес-запрос перестал возвращать `2xx`;
|
||||
- тест длится более 5 минут.
|
||||
|
||||
Зафиксируйте максимальный `queue_size`. Не ждите заполнения очереди специально.
|
||||
|
||||
``` Посмотреть компактно метрики (общий объем, логи, метрики, трэйсы)
|
||||
collector_metrics |
|
||||
awk '/^otelcol_exporter_queue_(size|capacity)\{/ &&
|
||||
/exporter="otlp\/remote"/ {print}'
|
||||
|
||||
|
||||
### 5. Восстановить связь и дождаться drain
|
||||
|
||||
Верните исходное правило TCP 4317 в security group и проверьте сеть:
|
||||
|
||||
```bash
|
||||
date -u '+recovery_started=%Y-%m-%dT%H:%M:%SZ'
|
||||
timeout 5 bash -c 'exec 3<>/dev/tcp/192.168.0.5/4317' \
|
||||
&& echo 'OK: remote OTLP доступен'
|
||||
```
|
||||
|
||||
Наблюдайте очередь до нуля:
|
||||
|
||||
```bash
|
||||
while true; do
|
||||
date -u '+%Y-%m-%dT%H:%M:%SZ'
|
||||
output="$(collector_metrics)"
|
||||
printf '%s\n' "$output" |
|
||||
grep -E 'otelcol_exporter_(queue_size|queue_capacity|send_failed)'
|
||||
size="$(printf '%s\n' "$output" |
|
||||
awk '/otelcol_exporter_queue_size.*exporter="otlp\/remote"/ {print $NF; exit}')"
|
||||
[[ "${size:-unknown}" == "0" ]] && break
|
||||
sleep 10
|
||||
done
|
||||
date -u '+drain_finished=%Y-%m-%dT%H:%M:%SZ'
|
||||
```
|
||||
|
||||
Если версия Collector использует другие labels, найдите фактическую строку
|
||||
командой `collector_metrics | grep otelcol_exporter_queue` и скорректируйте
|
||||
только выражение `awk`.
|
||||
|
||||
После drain:
|
||||
|
||||
```bash
|
||||
docker compose logs --since=10m otel-collector |
|
||||
grep -Ei 'queue is full|dropp|refused|permanent error' || true
|
||||
```
|
||||
|
||||
В SigNoz установите период от `outage_started` до `drain_finished` и найдите:
|
||||
|
||||
```text
|
||||
service.name = han-chat-outage-canary
|
||||
service.namespace = han-chat
|
||||
deployment.environment = production-like
|
||||
```
|
||||
|
||||
Из-за tail sampling сохраняется около 10% обычных успешных traces, поэтому
|
||||
ожидайте не все 100, а хотя бы один свежий canary trace. Проверьте также свежий
|
||||
trace `api-backend` с правильными `service.version` и environment.
|
||||
|
||||
### 6. Записать результат
|
||||
|
||||
В runbook/протоколе теста зафиксируйте:
|
||||
|
||||
- UTC `outage_started`, `recovery_started`, `drain_finished`;
|
||||
- длительность outage и recovery;
|
||||
- количество/коды synthetic HTTP responses;
|
||||
- baseline, peak и конечный queue size, queue capacity;
|
||||
- были ли `queue is full`, dropped/refused/permanent errors;
|
||||
- ссылку на canary trace в SigNoz;
|
||||
- итог `PASS` только при `2xx`, bounded queue, успешном drain и наличии trace.
|
||||
|
||||
Не удаляйте queue-файлы и не имитируйте outage остановкой локального Collector:
|
||||
это проверяет другой failure mode.
|
||||
|
||||
## Реализованный MVP и ограничения
|
||||
|
||||
После развёртывания актуальной версии backend в SigNoz поступают:
|
||||
|
||||
- traces и OTLP runtime metrics `api-backend`, `sms-service`, `sms-worker`;
|
||||
- FastAPI, HTTPX, SQLAlchemy, Redis и botocore dependency spans по месту
|
||||
использования;
|
||||
- ручной span `sms.process` без SMS/user identifiers;
|
||||
- Prometheus metrics `sms-service`, `sms-worker`, Keycloak и Collector;
|
||||
- API-метрики `han_http_*`, bootstrap и rate-limit decisions;
|
||||
- errors и traces медленнее 2 секунд полностью, остальные успешные — 10%
|
||||
через tail sampling.
|
||||
|
||||
Structlog получает настоящий `trace_id`/`span_id`, но JSON stdout пока
|
||||
остаётся только локальным аварийным журналом Docker и не экспортируется в
|
||||
SigNoz. Audit events продолжают храниться отдельно в PostgreSQL.
|
||||
|
||||
Следующая фаза:
|
||||
|
||||
1. инструментировать `message-safety`, Bitrix-сервисы и остальные workers;
|
||||
2. провести redaction-аудит и выбрать OTLP logs либо ограниченный filelog;
|
||||
3. добавить Redis/nginx exporters и безопасный nginx route class;
|
||||
4. экспортировать проверенный Dashboard V2 JSON;
|
||||
5. уточнить sampling, retention и alert thresholds после baseline/load test.
|
||||
|
||||
Канонический перечень метрик, SLO, dashboards и alert policy находится в
|
||||
`modules/module-09-observability.md`.
|
||||
|
||||
До прохождения end-to-end и outage/recovery acceptance не используйте
|
||||
отсутствие ошибок в SigNoz как доказательство здоровья HAN Chat.
|
||||
@@ -0,0 +1,89 @@
|
||||
# SigNoz MVP: dashboards и alerts
|
||||
|
||||
Документ фиксирует versioned-спецификацию первых панелей. Создавайте их через
|
||||
SigNoz Query Builder и экспортируйте полученный Dashboard V2 JSON обратно в
|
||||
репозиторий после проверки реальных имён attributes на production-like данных.
|
||||
|
||||
Обязательные фильтры каждой панели:
|
||||
|
||||
```text
|
||||
service.namespace = han-chat
|
||||
deployment.environment = production-like
|
||||
```
|
||||
|
||||
Добавьте переменные `deployment.environment`, `service.name` и
|
||||
`service.version`. Идентификаторы пользователей, запросов, SMS и сессий
|
||||
переменными dashboard не являются.
|
||||
|
||||
Для Prometheus-scraped SMS/Keycloak metrics имя источника находится в bounded
|
||||
datapoint label `service_name`; для OTLP signals используется resource
|
||||
attribute `service.name`.
|
||||
|
||||
## Dashboard: HAN API RED
|
||||
|
||||
1. **Request rate** — rate/sum `han_http_requests_total`, group by
|
||||
`http.route`, `http.request.method`.
|
||||
2. **Error rate** — доля `han_http_requests_total` с
|
||||
`http.response.status_class=5xx`.
|
||||
3. **Latency p50/p95/p99** — `han_http_request_duration_seconds`, group by
|
||||
route template.
|
||||
4. **Rate-limit decisions** — rate `han_rate_limit_decisions_total`, group by
|
||||
`scope`, `outcome`.
|
||||
5. **Bootstrap outcomes** — rate `han_auth_bootstrap_total`, group by
|
||||
`outcome`.
|
||||
6. **Slow/error traces** — link в Traces с `service.name=api-backend`.
|
||||
|
||||
## Dashboard: HAN SMS
|
||||
|
||||
1. **Send outcomes** — rate `sms_send_total`, group by `provider`,
|
||||
`send_status`.
|
||||
2. **Provider p95 latency** — `sms_provider_request_duration_seconds`.
|
||||
3. **Uncertain outcomes** — rate `sms_uncertain_total`.
|
||||
4. **Callback result/lag** — `sms_callback_total`,
|
||||
`sms_callback_lag_seconds`.
|
||||
5. **Oldest pending** — `sms_pending_oldest_age_seconds`.
|
||||
6. **Journal rows/settings** — `sms_journal_rows`, `sms_settings_valid`.
|
||||
7. **Worker traces** — `service.name=sms-worker`, span `sms.process`.
|
||||
|
||||
## Dashboard: Collector health
|
||||
|
||||
Используйте autocomplete Metrics Explorer для фактических `otelcol_*` имён
|
||||
версии Collector `0.117.0`:
|
||||
|
||||
1. accepted и refused spans/metric points/log records;
|
||||
2. exporter sent/failed;
|
||||
3. sending queue capacity/size;
|
||||
4. tail-sampling sampled/dropped/late spans;
|
||||
5. process RSS/CPU;
|
||||
6. Prometheus scrape failures для `sms-service`, `sms-worker`, `keycloak`;
|
||||
7. отсутствие данных по каждому обязательному `service.name`.
|
||||
|
||||
## Первые alerts
|
||||
|
||||
Alerts создаются после 24 часов baseline. Все правила получают owner, severity
|
||||
и ссылку на `SIGNOZ_RUNBOOK.md`.
|
||||
|
||||
- **API 5xx:** доля 5xx > 5% в течение 5 минут.
|
||||
- **API latency:** p95 выше 750 ms 10 минут; до разделения route-классов это
|
||||
warning, не page.
|
||||
- **SMS uncertain:** рост `sms_uncertain_total` дольше 5 минут.
|
||||
- **SMS pending stale:** `sms_pending_oldest_age_seconds > 300` 5 минут.
|
||||
- **SMS settings invalid:** `sms_settings_valid < 1` 5 минут.
|
||||
- **Collector refused/dropped:** значение > 0 дольше 5 минут.
|
||||
- **Collector queue:** заполнение > 80% 10 минут.
|
||||
- **No telemetry:** обязательный production-like сервис отсутствует 10 минут
|
||||
при ожидаемом трафике.
|
||||
|
||||
Не создавайте SLO по sampled traces. Availability/error budget рассчитываются
|
||||
по metrics. Multi-window burn alerts добавляются после двух недель baseline.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Dashboard считается введённым в эксплуатацию, когда:
|
||||
|
||||
1. панели показывают свежие данные после synthetic request;
|
||||
2. фильтр release отделяет текущий rollout от предыдущего;
|
||||
3. route содержит template, а не UUID/raw URI;
|
||||
4. ни одна metric series не содержит user/session/request/trace identifiers;
|
||||
5. alert проверен контролируемым synthetic failure;
|
||||
6. экспортированный Dashboard V2 JSON сохранён рядом с этим документом.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Сеть и доступ
|
||||
|
||||
## Сбор диагностики
|
||||
|
||||
До изменения Netplan выполните и сохраните вывод:
|
||||
|
||||
```bash
|
||||
ip -br link
|
||||
ip -br -4 address
|
||||
ip -4 route
|
||||
ip -4 rule
|
||||
sudo netplan get
|
||||
sudo netplan status --all 2>&1 || true
|
||||
sudo ls -la /etc/netplan
|
||||
sudo sh -c 'for f in /etc/netplan/*.yaml; do echo "--- $f"; cat "$f"; done'
|
||||
systemctl is-active systemd-networkd NetworkManager
|
||||
networkctl list 2>&1 || true
|
||||
networkctl status 2>&1 || true
|
||||
grep -RniE 'network:|network-config|disable_network_config' \
|
||||
/etc/cloud/cloud.cfg /etc/cloud/cloud.cfg.d 2>/dev/null || true
|
||||
```
|
||||
|
||||
Диагностика этой VM показала:
|
||||
|
||||
- публичный интерфейс `eth0`, `135.106.166.7/24`, default gateway
|
||||
`135.106.166.1`;
|
||||
- приватный интерфейс `eth1`, MAC `fa:16:3e:b6:c6:70`, изначально
|
||||
`DOWN/unmanaged`;
|
||||
- приватный адрес VM — `192.168.0.5/24`.
|
||||
|
||||
Для `eth1` не нужен gateway: узлы `192.168.0.0/24` доступны connected route.
|
||||
Default route должен остаться только на `eth0`.
|
||||
|
||||
## Настройка приватного интерфейса
|
||||
|
||||
Используйте подготовленный скрипт:
|
||||
|
||||
```bash
|
||||
cd /opt/signoz
|
||||
sudo ./scripts/05-configure-private-network.sh
|
||||
```
|
||||
|
||||
Он создаёт отдельный `/etc/netplan/60-signoz-private.yaml`, проверяет MAC,
|
||||
выполняет `netplan generate` и запускает `netplan try --timeout 120`. Файл
|
||||
`50-cloud-init.yaml` не изменяется: cloud-init продолжает управлять публичным
|
||||
`eth0`, а отдельный файл сохраняет конфигурацию `eth1`.
|
||||
|
||||
Пока `netplan try` ожидает подтверждения:
|
||||
|
||||
```bash
|
||||
ip -br -4 address show eth1
|
||||
ip route get 192.168.0.1
|
||||
ping -c 3 192.168.0.1
|
||||
```
|
||||
|
||||
С другой VM приватной сети проверьте:
|
||||
|
||||
```bash
|
||||
ping -c 3 192.168.0.5
|
||||
ssh -i ~/.ssh/hansel-private root@192.168.0.5
|
||||
```
|
||||
|
||||
Если новая SSH-сессия работает, вернитесь в первую и подтвердите Netplan
|
||||
клавишей Enter. Если нет — не подтверждайте: через 120 секунд произойдёт откат.
|
||||
Отсутствие ответа на ping само по себе может означать запрет ICMP; SSH является
|
||||
основной проверкой.
|
||||
|
||||
## Группа безопасности
|
||||
|
||||
Минимальные входящие правила для VM SigNoz:
|
||||
|
||||
- TCP 22 от административного узла/подсети приватной сети;
|
||||
- TCP 4317 от приватного IP backend;
|
||||
- TCP 4318 от приватного IP backend только если планируется OTLP/HTTP;
|
||||
- никаких входящих правил для 8080, 5432, 8123, 9000, 9181.
|
||||
|
||||
Для текущего backend используется OTLP/gRPC, поэтому после проверки 4318 можно
|
||||
закрыть. Исходящий доступ к приватной сети оставьте. Для обновления временно
|
||||
разрешайте HTTPS/DNS наружу или используйте внутренний registry/proxy.
|
||||
|
||||
## Доступ к UI
|
||||
|
||||
UI слушает только `127.0.0.1:8080` на VM SigNoz.
|
||||
|
||||
Через доступный jump host:
|
||||
|
||||
```powershell
|
||||
ssh -i C:\Users\MI\.ssh\hansel `
|
||||
-J root@<JUMP_PUBLIC_IP> `
|
||||
-L 8080:127.0.0.1:8080 `
|
||||
root@192.168.0.5 -N
|
||||
```
|
||||
|
||||
Если ключи jump host и SigNoz различаются, удобнее добавить оба узла в
|
||||
`~/.ssh/config`. После запуска туннеля откройте
|
||||
`http://127.0.0.1:8080`.
|
||||
|
||||
## Проверки перед удалением внешнего IP
|
||||
|
||||
1. Новая SSH-сессия к `192.168.0.5` через jump host открывается.
|
||||
2. Туннель показывает UI SigNoz.
|
||||
3. `scripts/30-verify-signoz.sh` проходит без ошибок.
|
||||
4. С backend доступны `192.168.0.5:4317` и при необходимости `:4318`.
|
||||
5. В SigNoz появился свежий trace сервиса HAN Chat.
|
||||
6. Все контейнеры имеют статус `running`, healthcheck — `healthy`.
|
||||
7. Создан snapshot диска ВМ.
|
||||
8. В облачной группе безопасности нет публичного доступа к служебным портам.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Работа с SigNoz для HAN Chat
|
||||
|
||||
## Базовые фильтры
|
||||
|
||||
Во всех разделах начинайте с:
|
||||
|
||||
```text
|
||||
service.namespace = han-chat
|
||||
deployment.environment = production-like
|
||||
```
|
||||
|
||||
Для сравнения релизов используйте `service.version`. Не смешивайте production
|
||||
и smoke/local данные в одном запросе без фильтра окружения.
|
||||
|
||||
MVP service names:
|
||||
|
||||
```text
|
||||
api-backend
|
||||
sms-service
|
||||
sms-worker
|
||||
```
|
||||
|
||||
Для worker ищите ручные spans `sms.claim`, `sms.process`, `sms.provider`,
|
||||
`sms.save_result`. Health routes исключены до tail sampling.
|
||||
|
||||
## Ежедневная проверка
|
||||
|
||||
1. **Services** — появились ли ожидаемые сервисы, нет ли резкого роста error
|
||||
rate или p95 latency.
|
||||
2. **Traces** — последние ошибки и медленные запросы, путь от HTTP endpoint до
|
||||
БД/Redis/внешнего API.
|
||||
3. **Logs** — события рядом с trace по `trace_id`, ошибки и повторы workers.
|
||||
4. **Metrics** — насыщение ресурсов, очереди, SMS и фоновые задачи.
|
||||
5. **Alerts** — firing/acknowledged alerts и причины, а не только факт firing.
|
||||
|
||||
Пока приложения не инструментированы, эти представления будут неполными; см.
|
||||
ограничение в `BACKEND_OTLP.md`.
|
||||
|
||||
## Разбор инцидента
|
||||
|
||||
Начните с узкого временного диапазона вокруг события:
|
||||
|
||||
1. найдите пользовательский `request_id` из ответа API или журнала;
|
||||
2. в Logs ищите точное значение `request_id`;
|
||||
3. откройте связанный trace по `trace_id`;
|
||||
4. найдите первый ошибочный или самый долгий span, а не последний симптом;
|
||||
5. сравните с предыдущей версией по `service.version`;
|
||||
6. проверьте зависимости: PostgreSQL, Redis, Keycloak, S3, SMS provider,
|
||||
Bitrix;
|
||||
7. сохраните ссылку на запрос SigNoz и зафиксируйте UTC-интервал инцидента.
|
||||
|
||||
Не добавляйте в SigNoz телефон, email, токены, cookie, содержимое сообщений,
|
||||
полные SQL statements и query string. Collector удаляет известные
|
||||
чувствительные атрибуты, но новые поля должны проходить отдельную проверку.
|
||||
|
||||
## Рекомендуемые представления после инструментации
|
||||
|
||||
**API**
|
||||
|
||||
- request rate, error rate, p50/p95/p99 duration;
|
||||
- разрез по нормализованному `http.route`, не по сырому URL;
|
||||
- 401/403 отдельно от 5xx;
|
||||
- latency PostgreSQL, Redis, Keycloak и S3;
|
||||
- количество активных WebSocket/SSE соединений и reconnect.
|
||||
|
||||
**SMS**
|
||||
|
||||
- `sms_send_total` по результату/provider;
|
||||
- provider latency;
|
||||
- uncertain deliveries и callback lag;
|
||||
- возраст pending-заявок и размер journal;
|
||||
- валидность runtime settings.
|
||||
|
||||
**Workers**
|
||||
|
||||
- длительность и результат job;
|
||||
- глубина очереди, oldest item age;
|
||||
- retries/dead-letter/recovery;
|
||||
- lag cleanup/delivery/safety/notification jobs.
|
||||
|
||||
**Инфраструктура**
|
||||
|
||||
- CPU, RAM, filesystem usage VM SigNoz;
|
||||
- ClickHouse inserts/query latency, parts и disk usage;
|
||||
- dropped/refused spans, exporter failures и queue size Collector;
|
||||
- restarts и health контейнеров.
|
||||
|
||||
## Первые алерты
|
||||
|
||||
Точный состав панелей и acceptance-критерии зафиксированы в
|
||||
`MVP_DASHBOARDS_ALERTS.md`.
|
||||
|
||||
Создавайте алерты только после получения базовой линии, чтобы избежать шума:
|
||||
|
||||
- API 5xx rate выше согласованного порога 5–10 минут;
|
||||
- p95 latency выше SLO 10 минут;
|
||||
- нет телеметрии от production-сервиса 5–10 минут при ожидаемом трафике;
|
||||
- Collector exporter failures/dropped telemetry больше нуля;
|
||||
- очередь workers или oldest item age растёт;
|
||||
- SMS uncertain/callback lag превышает бизнес-порог;
|
||||
- диск VM SigNoz заполнен более чем на 75% и 85%;
|
||||
- любой критичный контейнер перезапускается или unhealthy.
|
||||
|
||||
Для каждого alert укажите severity, owner, runbook URL и минимальное время
|
||||
устойчивого нарушения. Не оповещайте по единичной ошибке.
|
||||
|
||||
## Sampling и интерпретация
|
||||
|
||||
Collector сейчас применяет probabilistic sampling 10% только к traces.
|
||||
Метрики и логи не sampled этим processor. Следствия:
|
||||
|
||||
- единичный запрос может не попасть в Traces;
|
||||
- абсолютное число traces нельзя считать числом запросов;
|
||||
- ошибки тоже могут быть отброшены, потому что tail/error-aware sampling пока
|
||||
не настроен;
|
||||
- SLO и alerting следует строить на метриках, а не на подсчёте sampled traces.
|
||||
|
||||
Для production рекомендуется перейти к tail sampling: сохранять 100% ошибок и
|
||||
медленных traces, а успешные быстрые запросы — выборочно.
|
||||
|
||||
## Обслуживание SigNoz
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
cd /opt/signoz
|
||||
sudo ./scripts/30-verify-signoz.sh
|
||||
sudo docker compose -f pours/deployment/compose.yaml logs --since=30m
|
||||
df -h
|
||||
docker system df
|
||||
```
|
||||
|
||||
Перед обновлением:
|
||||
|
||||
1. временно разрешить исходящий интернет;
|
||||
2. сделать snapshot диска;
|
||||
3. проверить свободное место;
|
||||
4. выполнить `scripts/20-deploy-signoz.sh`;
|
||||
5. повторить smoke-тест и проверить сохранность старых данных;
|
||||
6. отключить внешний доступ.
|
||||
|
||||
Не выполняйте `docker compose down -v`, `docker volume prune` или
|
||||
`docker system prune --volumes`: эти команды могут удалить телеметрию и
|
||||
метаданные SigNoz.
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
PRIVATE_IP="${PRIVATE_IP:-192.168.0.5}"
|
||||
MIN_MEMORY_KIB=$((4 * 1024 * 1024))
|
||||
MIN_DISK_KIB=$((30 * 1024 * 1024))
|
||||
errors=0
|
||||
|
||||
ok() { printf 'OK %s\n' "$*"; }
|
||||
warn() { printf 'WARN %s\n' "$*" >&2; }
|
||||
fail() { printf 'FAIL %s\n' "$*" >&2; errors=$((errors + 1)); }
|
||||
|
||||
echo "=== ОС и ресурсы ==="
|
||||
. /etc/os-release
|
||||
[[ "${ID:-}" == ubuntu && "${VERSION_ID:-}" == "24.04" ]] \
|
||||
&& ok "Ubuntu ${VERSION_ID}" || warn "Проверено для Ubuntu 24.04; найдено ${PRETTY_NAME:-unknown}"
|
||||
|
||||
memory_kib="$(awk '/MemTotal/ {print $2}' /proc/meminfo)"
|
||||
((memory_kib >= MIN_MEMORY_KIB)) && ok "RAM не менее 4 GiB" || fail "SigNoz требует не менее 4 GiB RAM"
|
||||
|
||||
disk_kib="$(df -Pk /opt 2>/dev/null | awk 'NR==2 {print $4}')"
|
||||
((disk_kib >= MIN_DISK_KIB)) && ok "/opt: свободно не менее 30 GiB" \
|
||||
|| fail "Для начальной установки рекомендуется не менее 30 GiB свободно в /opt"
|
||||
|
||||
echo
|
||||
echo "=== Сеть ==="
|
||||
ip -br -4 address
|
||||
ip -4 route
|
||||
ip -4 addr show | grep -Fq "inet ${PRIVATE_IP}/" \
|
||||
&& ok "Приватный адрес ${PRIVATE_IP} назначен" \
|
||||
|| fail "Адрес ${PRIVATE_IP} не найден ни на одном интерфейсе"
|
||||
|
||||
if getent hosts archive.ubuntu.com registry-1.docker.io ghcr.io >/dev/null; then
|
||||
ok "DNS работает"
|
||||
else
|
||||
fail "DNS не разрешает адреса репозиториев"
|
||||
fi
|
||||
|
||||
for url in \
|
||||
https://archive.ubuntu.com \
|
||||
https://download.docker.com \
|
||||
https://registry-1.docker.io \
|
||||
https://github.com \
|
||||
https://signoz.io; do
|
||||
if curl -4fsSI --connect-timeout 8 --max-time 15 "$url" >/dev/null; then
|
||||
ok "доступен $url"
|
||||
else
|
||||
fail "нет доступа к $url"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== Занятость портов ==="
|
||||
for port in 8080 4317 4318; do
|
||||
if ss -H -lnt "( sport = :$port )" | grep -q .; then
|
||||
fail "TCP-порт $port уже занят: $(ss -H -lntp "( sport = :$port )")"
|
||||
else
|
||||
ok "TCP-порт $port свободен"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
timedatectl show --property=NTPSynchronized --property=Timezone --no-pager
|
||||
if ((errors)); then
|
||||
printf '\nОбнаружено ошибок: %d\n' "$errors" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Проверка завершена успешно."
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
PRIVATE_INTERFACE="${PRIVATE_INTERFACE:-eth1}"
|
||||
PRIVATE_MAC="${PRIVATE_MAC:-fa:16:3e:b6:c6:70}"
|
||||
PRIVATE_ADDRESS="${PRIVATE_ADDRESS:-192.168.0.5/24}"
|
||||
NETPLAN_FILE="/etc/netplan/60-signoz-private.yaml"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Запустите через sudo: sudo $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
actual_mac="$(cat "/sys/class/net/${PRIVATE_INTERFACE}/address" 2>/dev/null || true)"
|
||||
if [[ "$actual_mac" != "$PRIVATE_MAC" ]]; then
|
||||
echo "Остановка: MAC ${PRIVATE_INTERFACE} равен '${actual_mac:-не найден}', ожидался ${PRIVATE_MAC}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -d -m 0700 /root/netplan-backup
|
||||
if [[ -f "$NETPLAN_FILE" ]]; then
|
||||
cp -a "$NETPLAN_FILE" "/root/netplan-backup/60-signoz-private.yaml.$(date +%Y%m%d-%H%M%S)"
|
||||
fi
|
||||
|
||||
cat >"$NETPLAN_FILE" <<EOF
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
${PRIVATE_INTERFACE}:
|
||||
match:
|
||||
macaddress: "${PRIVATE_MAC}"
|
||||
set-name: "${PRIVATE_INTERFACE}"
|
||||
addresses:
|
||||
- "${PRIVATE_ADDRESS}"
|
||||
mtu: 1500
|
||||
optional: true
|
||||
EOF
|
||||
chmod 0600 "$NETPLAN_FILE"
|
||||
|
||||
netplan generate
|
||||
echo "Создан ${NETPLAN_FILE}:"
|
||||
cat "$NETPLAN_FILE"
|
||||
echo
|
||||
echo "Netplan временно применит конфигурацию на 120 секунд."
|
||||
echo "Подтвердите её в приглашении только после проверки второй SSH-сессии."
|
||||
netplan try --timeout 120
|
||||
|
||||
echo
|
||||
ip -br -4 address show "$PRIVATE_INTERFACE"
|
||||
ip -4 route show dev "$PRIVATE_INTERFACE"
|
||||
ip route get 192.168.0.1
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Запустите через sudo: sudo $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y ca-certificates curl gnupg jq
|
||||
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
-o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
. /etc/os-release
|
||||
cat >/etc/apt/sources.list.d/docker.sources <<EOF
|
||||
Types: deb
|
||||
URIs: https://download.docker.com/linux/ubuntu
|
||||
Suites: ${VERSION_CODENAME}
|
||||
Components: stable
|
||||
Architectures: $(dpkg --print-architecture)
|
||||
Signed-By: /etc/apt/keyrings/docker.asc
|
||||
EOF
|
||||
|
||||
apt-get update
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io \
|
||||
docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
install -d -m 0755 /etc/docker
|
||||
cat >/etc/docker/daemon.json <<'EOF'
|
||||
{
|
||||
"live-restore": true,
|
||||
"log-driver": "json-file",
|
||||
"log-opts": {
|
||||
"max-size": "50m",
|
||||
"max-file": "5"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
systemctl enable --now docker
|
||||
systemctl restart docker
|
||||
docker version
|
||||
docker compose version
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
PRIVATE_IP="${PRIVATE_IP:-192.168.0.5}"
|
||||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CASTING="${ROOT_DIR}/casting.yaml"
|
||||
COMPOSE="${ROOT_DIR}/pours/deployment/compose.yaml"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Запустите через sudo: sudo $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
command -v docker >/dev/null || { echo "Сначала установите Docker." >&2; exit 1; }
|
||||
ip -4 addr show | grep -Fq "inet ${PRIVATE_IP}/" || {
|
||||
echo "На ВМ отсутствует приватный адрес ${PRIVATE_IP}." >&2
|
||||
exit 1
|
||||
}
|
||||
grep -Fq "${PRIVATE_IP}:4317:4317" "$CASTING" || {
|
||||
echo "PRIVATE_IP не совпадает с адресом в casting.yaml." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ! command -v foundryctl >/dev/null; then
|
||||
installer="$(mktemp)"
|
||||
trap 'rm -f "$installer"' EXIT
|
||||
curl -fsSL https://signoz.io/foundry.sh -o "$installer"
|
||||
bash "$installer"
|
||||
if [[ -x /root/.local/bin/foundryctl ]]; then
|
||||
install -m 0755 /root/.local/bin/foundryctl /usr/local/bin/foundryctl
|
||||
elif [[ ! -x /usr/local/bin/foundryctl ]]; then
|
||||
echo "foundryctl не найден после установки." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
foundryctl gauge -f "$CASTING"
|
||||
foundryctl forge -f "$CASTING"
|
||||
|
||||
[[ -f "$COMPOSE" ]] || { echo "Foundry не создал $COMPOSE" >&2; exit 1; }
|
||||
docker compose -f "$COMPOSE" config --quiet
|
||||
|
||||
# Защита от случайной публикации UI или OTLP на всех интерфейсах.
|
||||
grep -Fq "${PRIVATE_IP}:4317:4317" "$COMPOSE"
|
||||
grep -Fq "${PRIVATE_IP}:4318:4318" "$COMPOSE"
|
||||
grep -Fq "127.0.0.1:8080:8080" "$COMPOSE"
|
||||
if grep -Eq '(^|[[:space:]"-])0\.0\.0\.0:(8080|4317|4318):' "$COMPOSE"; then
|
||||
echo "Остановка: обнаружена публикация служебного порта на 0.0.0.0." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose -f "$COMPOSE" pull
|
||||
docker compose -f "$COMPOSE" up -d --remove-orphans
|
||||
|
||||
echo "Ожидание SigNoz UI..."
|
||||
deadline=$((SECONDS + 300))
|
||||
until curl -fsS --max-time 5 http://127.0.0.1:8080/api/v1/health >/dev/null 2>&1; do
|
||||
if ((SECONDS >= deadline)); then
|
||||
echo "UI не стал готов за 5 минут." >&2
|
||||
docker compose -f "$COMPOSE" ps -a
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
docker compose -f "$COMPOSE" ps -a
|
||||
cat <<'EOF'
|
||||
|
||||
Базовый SigNoz запущен, но первичная настройка ещё не завершена.
|
||||
Откройте UI через SSH-туннель и создайте первого администратора/организацию.
|
||||
После этого перезапустите ingester и выполните scripts/30-verify-signoz.sh.
|
||||
EOF
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
PRIVATE_IP="${PRIVATE_IP:-192.168.0.5}"
|
||||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE="${ROOT_DIR}/pours/deployment/compose.yaml"
|
||||
errors=0
|
||||
|
||||
wait_for() {
|
||||
local label="$1"
|
||||
local timeout_seconds="$2"
|
||||
shift
|
||||
shift
|
||||
local deadline=$((SECONDS + timeout_seconds))
|
||||
printf '%-34s' "$label"
|
||||
until "$@" >/dev/null 2>&1; do
|
||||
if ((SECONDS >= deadline)); then
|
||||
echo "FAIL"
|
||||
errors=$((errors + 1))
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "OK"
|
||||
}
|
||||
|
||||
container_healthy() {
|
||||
[[ "$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$1" 2>/dev/null)" == healthy ]]
|
||||
}
|
||||
|
||||
one_shot_ok() {
|
||||
local state
|
||||
state="$(docker inspect --format '{{.State.Status}} {{.State.ExitCode}}' "$1" 2>/dev/null)" || return 1
|
||||
[[ "$state" == "running 0" || "$state" == "exited 0" ]]
|
||||
}
|
||||
|
||||
[[ -f "$COMPOSE" ]] || { echo "Не найден $COMPOSE" >&2; exit 1; }
|
||||
echo "=== SigNoz: состояние ==="
|
||||
docker compose -f "$COMPOSE" ps
|
||||
echo
|
||||
|
||||
wait_for "PostgreSQL healthy" 300 container_healthy \
|
||||
signoz-metastore-postgres-0
|
||||
wait_for "ClickHouse Keeper healthy" 300 container_healthy \
|
||||
signoz-telemetrykeeper-clickhousekeeper-0
|
||||
wait_for "ClickHouse healthy" 300 container_healthy \
|
||||
signoz-telemetrystore-clickhouse-0-0
|
||||
wait_for "SigNoz healthy" 300 container_healthy \
|
||||
signoz-signoz-0
|
||||
wait_for "SigNoz API/UI" 300 curl -fsS --max-time 5 \
|
||||
http://127.0.0.1:8080/api/v1/health
|
||||
wait_for "OTLP gRPC TCP 4317" 300 timeout 3 bash -c \
|
||||
"exec 3<>/dev/tcp/${PRIVATE_IP}/4317"
|
||||
wait_for "OTLP HTTP 4318" 300 curl -fsS --max-time 5 \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"resourceSpans":[]}' \
|
||||
"http://${PRIVATE_IP}:4318/v1/traces"
|
||||
wait_for "User scripts completed" 300 one_shot_ok \
|
||||
signoz-telemetrystore-clickhouse-user-scripts
|
||||
wait_for "Migrations running/completed" 300 one_shot_ok \
|
||||
signoz-telemetrystore-migrator
|
||||
|
||||
echo
|
||||
docker compose -f "$COMPOSE" ps -a
|
||||
echo
|
||||
ss -lntp "( sport = :8080 or sport = :4317 or sport = :4318 )"
|
||||
if ((errors)); then
|
||||
echo "Проверка завершилась с ошибками: $errors" >&2
|
||||
echo "Логи: docker compose -f '$COMPOSE' logs --tail=200" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SigNoz готов."
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
PRIVATE_IP="${PRIVATE_IP:-192.168.0.5}"
|
||||
ADMIN_CIDR="${ADMIN_CIDR:-192.168.0.0/24}"
|
||||
OTLP_SOURCE="${OTLP_SOURCE:-}"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Запустите через sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OTLP_SOURCE" ]]; then
|
||||
echo "Укажите приватный IP backend, например:" >&2
|
||||
echo " sudo OTLP_SOURCE=192.168.0.1 $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
ip -4 addr show | grep -Fq "inet ${PRIVATE_IP}/" || {
|
||||
echo "Приватный адрес ${PRIVATE_IP} не найден." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "SSH будет разрешён только из ${ADMIN_CIDR}."
|
||||
echo "OTLP будет разрешён только от ${OTLP_SOURCE}."
|
||||
echo "До продолжения проверьте отдельную SSH-сессию через приватную сеть."
|
||||
read -r -p "Введите APPLY для применения правил: " answer
|
||||
[[ "$answer" == APPLY ]] || { echo "Отменено."; exit 1; }
|
||||
|
||||
apt-get update
|
||||
apt-get install -y ufw
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw logging medium
|
||||
ufw allow from "$ADMIN_CIDR" to "$PRIVATE_IP" port 22 proto tcp comment "SigNoz admin SSH"
|
||||
ufw allow from "$OTLP_SOURCE" to "$PRIVATE_IP" port 4317 proto tcp comment "HAN OTLP gRPC"
|
||||
ufw allow from "$OTLP_SOURCE" to "$PRIVATE_IP" port 4318 proto tcp comment "HAN OTLP HTTP"
|
||||
ufw --force enable
|
||||
ufw status verbose
|
||||
|
||||
cat <<'EOF'
|
||||
|
||||
Важно: Docker-публикации могут обходить цепочку UFW INPUT. Основная фильтрация
|
||||
источника OTLP должна оставаться в облачной группе безопасности. casting.yaml
|
||||
дополнительно привязывает OTLP только к приватному IP, а UI — к loopback.
|
||||
EOF
|
||||
@@ -174,12 +174,18 @@ SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=change-me
|
||||
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me
|
||||
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
OTEL_SERVICE_NAME_API=api-backend
|
||||
OTEL_SERVICE_NAME_SMS_API=sms-service
|
||||
OTEL_SERVICE_NAME_SMS_WORKER=sms-worker
|
||||
SMS_METRICS_PORT=9464
|
||||
#Если есть внешний OTLP-сервис, замените (Точный формат авторизации зависит от провайдера):
|
||||
#Если внешнего OTLP-сервиса пока нет, otlp.example.invalid:4317 можно временно оставить, но Collector будет постоянно пытаться подключиться, писать предупреждения и накапливать очередь.
|
||||
OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
|
||||
OTEL_REMOTE_AUTH_HEADER=change-me
|
||||
# Сейчас соответствуют 10% трассировок и очереди 10000
|
||||
# Также обнаружена особенность проекта: OTEL_TRACES_SAMPLER_ARG и OTEL_QUEUE_SIZE сейчас фактически не подставляются в конфигурацию — там жёстко установлены 10% и 10000. Поэтому менять эти две переменные пока бессмысленно.
|
||||
OTEL_TRACES_SAMPLER_ARG=0.10
|
||||
# true только для plaintext OTLP внутри доверенной приватной сети (например self-hosted SigNoz).
|
||||
OTEL_REMOTE_TLS_INSECURE=false
|
||||
# SDK отправляет все spans локальному Collector; решение о хранении принимает tail_sampling.
|
||||
OTEL_TRACES_SAMPLER=always_on
|
||||
# OTEL_QUEUE_SIZE пока документирует целевую ёмкость, конфигурация Collector закреплена в YAML.
|
||||
OTEL_QUEUE_SIZE=10000
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from app.integrations import (
|
||||
S3Client,
|
||||
SafetyClient,
|
||||
)
|
||||
from app.metrics import AUTH_BOOTSTRAP, HTTP_DURATION, HTTP_REQUESTS, RATE_LIMIT_DECISIONS
|
||||
from app.notification_routes import router as notification_router
|
||||
from app.notification_service import synchronize_source_tokens
|
||||
from app.realtime import RealtimeFanout
|
||||
@@ -86,6 +87,7 @@ from app.services import (
|
||||
start_session,
|
||||
)
|
||||
from app.settings import get_settings
|
||||
from app.telemetry import add_trace_context, current_trace_id, init_telemetry, instrument_fastapi
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
@@ -93,6 +95,7 @@ def configure_logging(level: str) -> None:
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
add_trace_context,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.JSONRenderer(),
|
||||
@@ -113,6 +116,7 @@ async def refresh_settings_cache(app: FastAPI) -> None:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
telemetry = init_telemetry()
|
||||
configure_logging(settings.log_level)
|
||||
app.state.settings = settings
|
||||
app.state.db = Database(settings.database_url)
|
||||
@@ -138,14 +142,18 @@ async def lifespan(app: FastAPI):
|
||||
await app.state.jwks.refresh()
|
||||
except Exception:
|
||||
structlog.get_logger().warning("jwks.warmup_failed")
|
||||
yield
|
||||
settings_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await settings_task
|
||||
await app.state.http.aclose()
|
||||
await app.state.redis.aclose()
|
||||
await app.state.redis_rt.aclose()
|
||||
await app.state.db.close()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
settings_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await settings_task
|
||||
await app.state.http.aclose()
|
||||
await app.state.redis.aclose()
|
||||
await app.state.redis_rt.aclose()
|
||||
await app.state.db.close()
|
||||
if telemetry:
|
||||
telemetry.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -221,7 +229,7 @@ async def request_context(request: Request, call_next: Any) -> Response:
|
||||
except ValueError:
|
||||
request_id = str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
request.state.trace_id = request_trace_id(request)
|
||||
request.state.trace_id = current_trace_id() or request_trace_id(request)
|
||||
request.state.user_agent_hash = user_agent_hash(request)
|
||||
request.state.started_at = time.monotonic()
|
||||
structlog.contextvars.clear_contextvars()
|
||||
@@ -230,7 +238,6 @@ async def request_context(request: Request, call_next: Any) -> Response:
|
||||
trace_id=request.state.trace_id,
|
||||
ux_session_id=request.headers.get("X-Ux-Session-Id"),
|
||||
method=request.method,
|
||||
route=request.url.path,
|
||||
**{"service.name": "api-backend"},
|
||||
)
|
||||
origin = request.headers.get("Origin")
|
||||
@@ -252,10 +259,21 @@ async def request_context(request: Request, call_next: Any) -> Response:
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = response.headers.get("Cache-Control", "no-store")
|
||||
duration_seconds = time.monotonic() - request.state.started_at
|
||||
route = getattr(request.scope.get("route"), "path", "unmatched")
|
||||
metric_attributes = {
|
||||
"service.name": "api-backend",
|
||||
"http.route": route,
|
||||
"http.request.method": request.method,
|
||||
"http.response.status_class": f"{response.status_code // 100}xx",
|
||||
}
|
||||
HTTP_REQUESTS.add(1, metric_attributes)
|
||||
HTTP_DURATION.record(duration_seconds, metric_attributes)
|
||||
log.info(
|
||||
"request.complete",
|
||||
route=route,
|
||||
status_code=response.status_code,
|
||||
duration_ms=round((time.monotonic() - request.state.started_at) * 1000, 2),
|
||||
duration_ms=round(duration_seconds * 1000, 2),
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -396,17 +414,21 @@ async def enforce_limit(
|
||||
retry_after = await request.app.state.rate_limiter.consume(key, limit, window)
|
||||
except DependencyFailure:
|
||||
if fail_closed:
|
||||
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "dependency_error"})
|
||||
raise DomainError(
|
||||
"dependency_unavailable", 503, "Rate limit service is unavailable"
|
||||
) from None
|
||||
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "bypass"})
|
||||
return
|
||||
if retry_after:
|
||||
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "denied"})
|
||||
raise DomainError(
|
||||
"rate_limit_exceeded",
|
||||
429,
|
||||
"Rate limit exceeded",
|
||||
{"retry_after": retry_after},
|
||||
)
|
||||
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "allowed"})
|
||||
|
||||
|
||||
@app.get("/health/live", tags=["health"])
|
||||
@@ -561,7 +583,13 @@ async def content(
|
||||
async def auth_bootstrap(
|
||||
body: BootstrapRequest, request: Request, db: Session, auth: PrincipalDep, settings: SnapshotDep
|
||||
):
|
||||
return await bootstrap(db, auth, body, settings, audit_context(request))
|
||||
try:
|
||||
result = await bootstrap(db, auth, body, settings, audit_context(request))
|
||||
except Exception:
|
||||
AUTH_BOOTSTRAP.add(1, {"outcome": "error"})
|
||||
raise
|
||||
AUTH_BOOTSTRAP.add(1, {"outcome": "success"})
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/v1/consents", status_code=201, tags=["auth"])
|
||||
@@ -1106,6 +1134,9 @@ async def realtime(websocket: WebSocket):
|
||||
return
|
||||
|
||||
|
||||
instrument_fastapi(app)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
settings = get_settings()
|
||||
uvicorn.run(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from opentelemetry import metrics
|
||||
|
||||
meter = metrics.get_meter("han.api")
|
||||
|
||||
HTTP_REQUESTS = meter.create_counter(
|
||||
"han_http_requests_total",
|
||||
description="Completed HTTP requests",
|
||||
)
|
||||
HTTP_DURATION = meter.create_histogram(
|
||||
"han_http_request_duration_seconds",
|
||||
unit="s",
|
||||
description="HTTP request duration",
|
||||
)
|
||||
AUTH_BOOTSTRAP = meter.create_counter(
|
||||
"han_auth_bootstrap_total",
|
||||
description="Authentication bootstrap outcomes",
|
||||
)
|
||||
RATE_LIMIT_DECISIONS = meter.create_counter(
|
||||
"han_rate_limit_decisions_total",
|
||||
description="Rate-limit decisions",
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from opentelemetry import metrics, trace
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||
from opentelemetry.instrumentation.redis import RedisInstrumentor
|
||||
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
||||
from opentelemetry.propagate import set_global_textmap
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
|
||||
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TelemetryRuntime:
|
||||
tracer_provider: TracerProvider
|
||||
meter_provider: MeterProvider
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.meter_provider.shutdown()
|
||||
self.tracer_provider.shutdown()
|
||||
|
||||
|
||||
_runtime: TelemetryRuntime | None = None
|
||||
|
||||
|
||||
def _resource(service_name: str) -> Resource:
|
||||
return Resource.create(
|
||||
{
|
||||
"service.name": service_name,
|
||||
"service.namespace": "han-chat",
|
||||
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
|
||||
"deployment.environment": os.getenv("APP_ENV", "production-like"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
|
||||
global _runtime
|
||||
if _runtime is not None:
|
||||
return _runtime
|
||||
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
|
||||
if not endpoint:
|
||||
return None
|
||||
|
||||
resource = _resource(service_name or os.getenv("OTEL_SERVICE_NAME", "api-backend"))
|
||||
insecure = endpoint.startswith("http://")
|
||||
set_global_textmap(TraceContextTextMapPropagator())
|
||||
|
||||
tracer_provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
|
||||
tracer_provider.add_span_processor(
|
||||
BatchSpanProcessor(
|
||||
OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||
max_queue_size=2048,
|
||||
schedule_delay_millis=5000,
|
||||
max_export_batch_size=512,
|
||||
export_timeout_millis=3000,
|
||||
)
|
||||
)
|
||||
trace.set_tracer_provider(tracer_provider)
|
||||
|
||||
metric_reader = PeriodicExportingMetricReader(
|
||||
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||
export_interval_millis=30000,
|
||||
export_timeout_millis=3000,
|
||||
)
|
||||
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||
metrics.set_meter_provider(meter_provider)
|
||||
|
||||
HTTPXClientInstrumentor().instrument()
|
||||
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||
RedisInstrumentor().instrument()
|
||||
BotocoreInstrumentor().instrument()
|
||||
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
|
||||
return _runtime
|
||||
|
||||
|
||||
def instrument_fastapi(app: FastAPI) -> None:
|
||||
FastAPIInstrumentor.instrument_app(
|
||||
app,
|
||||
excluded_urls="/health/live,/health/ready,/nginx-health/live",
|
||||
)
|
||||
|
||||
|
||||
def add_trace_context(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
context = trace.get_current_span().get_span_context()
|
||||
if context.is_valid:
|
||||
event_dict["trace_id"] = format(context.trace_id, "032x")
|
||||
event_dict["span_id"] = format(context.span_id, "016x")
|
||||
return event_dict
|
||||
|
||||
|
||||
def current_trace_id() -> str | None:
|
||||
context = trace.get_current_span().get_span_context()
|
||||
return format(context.trace_id, "032x") if context.is_valid else None
|
||||
@@ -9,6 +9,14 @@ dependencies = [
|
||||
"boto3>=1.39,<2",
|
||||
"fastapi>=0.116,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"opentelemetry-api>=1.44,<2",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.44,<2",
|
||||
"opentelemetry-instrumentation-botocore>=0.65b0,<1",
|
||||
"opentelemetry-instrumentation-fastapi>=0.65b0,<1",
|
||||
"opentelemetry-instrumentation-httpx>=0.65b0,<1",
|
||||
"opentelemetry-instrumentation-redis>=0.65b0,<1",
|
||||
"opentelemetry-instrumentation-sqlalchemy>=0.65b0,<1",
|
||||
"opentelemetry-sdk>=1.44,<2",
|
||||
"phonenumbers>=9,<10",
|
||||
"prometheus-client>=0.22,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
from app import telemetry
|
||||
|
||||
|
||||
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
|
||||
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
||||
monkeypatch.setattr(telemetry, "_runtime", None)
|
||||
|
||||
assert telemetry.init_telemetry("test-service") is None
|
||||
|
||||
|
||||
def test_structlog_processor_adds_active_trace_context_only() -> None:
|
||||
provider = TracerProvider()
|
||||
tracer = provider.get_tracer("test")
|
||||
event = {"event": "safe", "request_id": "request-1"}
|
||||
|
||||
with tracer.start_as_current_span("operation"):
|
||||
result = telemetry.add_trace_context(None, "info", event)
|
||||
|
||||
assert result["event"] == "safe"
|
||||
assert result["request_id"] == "request-1"
|
||||
assert len(result["trace_id"]) == 32
|
||||
assert len(result["span_id"]) == 16
|
||||
assert set(result) == {"event", "request_id", "trace_id", "span_id"}
|
||||
@@ -535,6 +535,33 @@ docker compose --env-file .env logs --tail=200 <SERVICE_NAME>
|
||||
docker inspect "$(docker compose --env-file .env ps -q <SERVICE_NAME>)"
|
||||
```
|
||||
|
||||
### 12.1. Повторная раскатка upstream при уже работающем nginx
|
||||
|
||||
Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После
|
||||
`up --build`, `pull`, rollback или `--force-recreate` контейнер может получить
|
||||
новый IP, а работающий nginx продолжит использовать старый и вернёт `502
|
||||
Connection refused`.
|
||||
|
||||
После пересоздания `api-backend`, `keycloak`, `sms-service` или
|
||||
`bitrix-local-app` обязательно выполните:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d --wait \
|
||||
api-backend keycloak sms-service bitrix-local-app
|
||||
docker compose --env-file .env exec -T nginx \
|
||||
nginx -t -c /tmp/nginx.conf
|
||||
docker compose --env-file .env kill -s HUP nginx
|
||||
|
||||
curl -fsS "https://${PUBLIC_HOST}/api/v1/public/app-config" | jq
|
||||
curl -fsS \
|
||||
"https://${PUBLIC_HOST}/auth/realms/han-chat/.well-known/openid-configuration" |
|
||||
jq
|
||||
```
|
||||
|
||||
Не используйте bare-команды `nginx -t` и `nginx -s reload`: рабочая
|
||||
конфигурация находится в `/tmp/nginx.conf`, PID — в `/tmp/nginx.pid`, а
|
||||
контейнер использует read-only filesystem.
|
||||
|
||||
## 13. Первоначальный выпуск TLS-сертификата
|
||||
|
||||
Для ACME требуется работающий nginx по HTTP. В `.env` оставьте
|
||||
|
||||
@@ -148,9 +148,19 @@ docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
|
||||
notification-expire-worker notification-draft-cleanup-worker
|
||||
docker compose up -d bitrix-local-app bitrix-sync
|
||||
docker compose up -d nginx
|
||||
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose kill -s HUP nginx
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Nginx resolves Docker upstream names when its configuration is loaded. After
|
||||
recreating `api-backend`, `keycloak`, `sms-service`, or `bitrix-local-app`,
|
||||
wait for readiness, validate the active `/tmp/nginx.conf`, and signal the
|
||||
master process with `HUP` as shown above. Do not use bare `nginx -t` or
|
||||
`nginx -s reload`: they target the default config/PID under read-only
|
||||
`/var/run`, not the running Nginx instance.
|
||||
|
||||
- [ ] No restart loop/OOM; critical readiness is green.
|
||||
- [ ] `notification-expire-worker` runs daily closure with an advisory lock; `notification-draft-cleanup-worker` removes expired drafts/S3 objects. Both entrypoints exist in the installed image.
|
||||
- [ ] Only documented Bitrix not-installed/sync-stub degradation remains.
|
||||
|
||||
@@ -170,9 +170,19 @@ docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
|
||||
notification-expire-worker notification-draft-cleanup-worker
|
||||
docker compose up -d bitrix-local-app bitrix-sync
|
||||
docker compose up -d nginx
|
||||
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose kill -s HUP nginx
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После
|
||||
любого пересоздания `api-backend`, `keycloak`, `sms-service` или
|
||||
`bitrix-local-app` дождитесь их readiness, проверьте именно рабочий
|
||||
`/tmp/nginx.conf` и отправьте master-процессу `HUP`, как показано выше.
|
||||
Обычные `nginx -t` и `nginx -s reload` использовать нельзя: они обращаются к
|
||||
дефолтному config/PID в read-only `/var/run` и не перезагружают рабочий Nginx.
|
||||
|
||||
- [ ] Нет циклических перезапусков и OOM; критические readiness-проверки успешны.
|
||||
- [ ] `notification-expire-worker` выполняет ежедневное закрытие с advisory lock; `notification-draft-cleanup-worker` очищает просроченные drafts/S3. Оба entrypoint присутствуют в установленном образе.
|
||||
- [ ] Сохраняется только документированная деградация: Bitrix не установлен и bitrix-sync работает как заглушка.
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env bash
|
||||
# Создаёт 9 персональных уведомлений всех видов контура P для одного user_id.
|
||||
# Запускать на ВМ из каталога backend: /opt/han-chat/backend
|
||||
#
|
||||
# cd /opt/han-chat/backend
|
||||
# sed -i 's/\r$//' deployment/scripts/seed-personal-notifications-test.sh
|
||||
# chmod +x deployment/scripts/seed-personal-notifications-test.sh
|
||||
# ./deployment/scripts/seed-personal-notifications-test.sh
|
||||
#
|
||||
# Токен берётся из .env (NOTIFICATIONS_TOKEN_PRODUCER_TEST) — тот же, что у api-backend.
|
||||
# При старте api-backend синхронизирует hash токена в notification_sources.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Не найден $ENV_FILE. Запускайте из каталога backend." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
env_value() {
|
||||
python3 - "$ENV_FILE" "$1" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path, wanted = sys.argv[1:]
|
||||
for raw in Path(path).read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key.strip() == wanted:
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
print(value)
|
||||
break
|
||||
else:
|
||||
raise SystemExit(f"missing environment variable: {wanted}")
|
||||
PY
|
||||
}
|
||||
|
||||
trim_token() {
|
||||
printf '%s' "$1" | tr -d '\r\n\t '
|
||||
}
|
||||
|
||||
TOKEN="$(trim_token "${NOTIFICATIONS_TOKEN_PRODUCER_TEST:-}")"
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
TOKEN="$(trim_token "$(env_value NOTIFICATIONS_TOKEN_PRODUCER_TEST 2>/dev/null || true)")"
|
||||
fi
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (из .env не найден): " TOKEN
|
||||
echo
|
||||
TOKEN="$(trim_token "$TOKEN")"
|
||||
fi
|
||||
|
||||
read -rp "USER_ID клиента: " USER_ID
|
||||
USER_ID="$(trim_token "$USER_ID")"
|
||||
|
||||
if [[ -z "${TOKEN}" || -z "${USER_ID}" ]]; then
|
||||
echo "TOKEN и USER_ID обязательны." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Токен: ${#TOKEN} символов (первые 8: ${TOKEN:0:8}…)"
|
||||
|
||||
PAYLOAD_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
|
||||
|
||||
BASE_TS="$(date +%s)"
|
||||
RUN_ID="manual-test-${BASE_TS}"
|
||||
|
||||
NOW="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_URGENT="$(date -u -d "${NOW} +0 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+0S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_PAYMENT="$(date -u -d "${NOW} - 60 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-60S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_DOCS_REQ="$(date -u -d "${NOW} - 120 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-120S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_DOCS_READY="$(date -u -d "${NOW} - 180 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-180S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_STATUS="$(date -u -d "${NOW} - 240 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-240S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_REMINDER="$(date -u -d "${NOW} - 300 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-300S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_NEWS="$(date -u -d "${NOW} - 360 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-360S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_PROMO="$(date -u -d "${NOW} - 420 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-420S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_ADS="$(date -u -d "${NOW} - 480 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-480S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
DEADLINE_URGENT="$(date -u -d "${NOW} + 2 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+2d +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DEADLINE_DOCS="$(date -u -d "${NOW} + 5 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+5d +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DEADLINE_REMINDER="$(date -u -d "${NOW} + 1 day" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+1d +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
EXPIRE_PAYMENT="$(date -u -d "${NOW} + 3 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+3d +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
write_payload() {
|
||||
local name="$1"
|
||||
cat > "${PAYLOAD_DIR}/${name}.json"
|
||||
}
|
||||
|
||||
create_notification() {
|
||||
local label="$1"
|
||||
local payload_file="${PAYLOAD_DIR}/${label}.json"
|
||||
if [[ ! -f "$payload_file" ]]; then
|
||||
echo "Нет payload: ${payload_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo
|
||||
echo "========== ${label} =========="
|
||||
docker run --rm \
|
||||
--network han-chat-backend \
|
||||
-v "${payload_file}:/payload.json:ro" \
|
||||
curlimages/curl:latest \
|
||||
-sS -i \
|
||||
-X POST \
|
||||
'http://api-backend:8000/internal/notifications/v1/notifications' \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H 'Content-Type: application/json; charset=utf-8' \
|
||||
--data-binary @/payload.json
|
||||
}
|
||||
|
||||
prepare_docs_in_s3() {
|
||||
echo >&2
|
||||
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
|
||||
docker compose --env-file "$ENV_FILE" exec -T \
|
||||
-e "USER_ID=${USER_ID}" \
|
||||
-e "RUN_ID=${RUN_ID}" \
|
||||
api-backend python3 - <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
from app.settings import Settings
|
||||
|
||||
USER_ID = os.environ["USER_ID"]
|
||||
settings = Settings()
|
||||
|
||||
client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=str(settings.selectel_s3_endpoint_url),
|
||||
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
|
||||
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}),
|
||||
)
|
||||
bucket = settings.selectel_s3_bucket_documents
|
||||
|
||||
fixtures = [
|
||||
{
|
||||
"prefix": "CONTRACT",
|
||||
"title": "Уведомление о постановке на миграционный учёт.pdf",
|
||||
"body": b"""%PDF-1.4
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj
|
||||
xref
|
||||
0 4
|
||||
trailer<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
100
|
||||
%%EOF
|
||||
""",
|
||||
},
|
||||
{
|
||||
"prefix": "RESULTS",
|
||||
"title": "Справка о соблюдении миграционного законодательства.pdf",
|
||||
"body": b"""%PDF-1.4
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj
|
||||
xref
|
||||
0 4
|
||||
trailer<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
100
|
||||
%%EOF
|
||||
""",
|
||||
},
|
||||
]
|
||||
|
||||
lines: list[str] = []
|
||||
for item in fixtures:
|
||||
doc_id = str(uuid.uuid4())
|
||||
key = f"documents/users/{USER_ID}/{doc_id}"
|
||||
body = item["body"]
|
||||
checksum = hashlib.sha256(body).hexdigest()
|
||||
client.put_object(
|
||||
Bucket=bucket,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType="application/pdf",
|
||||
)
|
||||
p = item["prefix"]
|
||||
lines.append(
|
||||
f"{p}_KEY={key}\n"
|
||||
f"{p}_TITLE={item['title']}\n"
|
||||
f"{p}_SIZE={len(body)}\n"
|
||||
f"{p}_SHA256={checksum}"
|
||||
)
|
||||
|
||||
print("\n".join(lines))
|
||||
PY
|
||||
}
|
||||
|
||||
echo "Run ID: ${RUN_ID}"
|
||||
echo "User ID: ${USER_ID}"
|
||||
|
||||
read -rp "Подготовить документы в S3 для docs_ready? [Y/n]: " PREP_DOCS
|
||||
PREP_DOCS="${PREP_DOCS:-Y}"
|
||||
|
||||
CONTRACT_KEY=""
|
||||
CONTRACT_TITLE=""
|
||||
CONTRACT_SIZE=""
|
||||
CONTRACT_SHA256=""
|
||||
RESULTS_KEY=""
|
||||
RESULTS_TITLE=""
|
||||
RESULTS_SIZE=""
|
||||
RESULTS_SHA256=""
|
||||
|
||||
if [[ "${PREP_DOCS^^}" != "N" ]]; then
|
||||
PREP_OUT="$(prepare_docs_in_s3)"
|
||||
echo "${PREP_OUT}"
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || continue
|
||||
declare "${BASH_REMATCH[1]}=${BASH_REMATCH[2]}"
|
||||
done <<< "${PREP_OUT}"
|
||||
fi
|
||||
|
||||
# --- payloads ---
|
||||
|
||||
write_payload urgent <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "urgent",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-urgent",
|
||||
"notification_datetime": "${DT_URGENT}",
|
||||
"header": "Истекает срок постановки на учёт",
|
||||
"text": "По данным сервиса, срок уведомления о месте пребывания истекает 29 июля — просрочка влечёт административную ответственность.",
|
||||
"priority_override": 1,
|
||||
"details": {
|
||||
"deadline": "${DEADLINE_URGENT}",
|
||||
"details_header": "Срочно: соблюдение сроков миграционного учёта",
|
||||
"details_text": "Федеральный закон № 109-ФЗ обязывает иностранного гражданина в течение 7 рабочих дней с даты въезда подать уведомление о прибытии в место пребывания (если иное не предусмотрено для вашего правового статуса).\n\nПо имеющимся данным крайний срок для вашего случая — 29.07.2026. Нарушение сроков может повлечь штраф от 2 000 до 5 000 ₽ и, при повторном нарушении, более серьёзные последствия, включая административное выдворение.\n\nЕсли уведомление уже подано — отметьте «Готово»; если нужна помощь — напишите оператору в чат.",
|
||||
"todo_header": "Что проверить сейчас",
|
||||
"todo_plan": [
|
||||
{"number": 1, "text": "Сверьте дату въезда и адрес фактического проживания в анкете личного кабинета."},
|
||||
{"number": 2, "text": "Подготовьте копии паспорта, миграционной карты и документа о праве пребывания (виза, РВП, ВНЖ, патент)."},
|
||||
{"number": 3, "text": "Нажмите «Готово», если уведомление уже подано через МВД или принимающую сторону."},
|
||||
{"number": 4, "text": "При сомнениях выберите «Сделаю позже» и задайте вопрос оператору — укажите город и тип документа."}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload payment_pending <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "payment_pending",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-payment_pending",
|
||||
"notification_datetime": "${DT_PAYMENT}",
|
||||
"header": "Оплата сопровождения по миграционному учёту",
|
||||
"text": "Счёт №МИГ-2026-0718 на 18 500 ₽ за подготовку пакета и подачу уведомления — оплатите до 30 июля.",
|
||||
"price": "18500.00",
|
||||
"old_price": "22000.00",
|
||||
"date_expired": "${EXPIRE_PAYMENT}",
|
||||
"payment_url": "https://pay.han0107.ru/checkout/test-${RUN_ID}"
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload docs_required <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "docs_required",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-docs_required",
|
||||
"notification_datetime": "${DT_DOCS_REQ}",
|
||||
"header": "Загрузите документы для миграционного учёта",
|
||||
"text": "Для проверки соблюдения миграционного законодательства нужен комплект документов — загрузите до 01 августа.",
|
||||
"details": {
|
||||
"deadline": "${DEADLINE_DOCS}",
|
||||
"details_header": "Документы для постановки на миграционный учёт",
|
||||
"details_text": "Специалист проверит комплект в течение 1 рабочего дня после отправки. Принимаются чёткие фото или сканы в JPG, PNG, PDF. Каждый файл — до 10 МБ, не более 10 файлов за одну отправку.\n\nВсе документы должны быть действительными на дату проверки. Если какого-то документа пока нет (например, договор найма ещё не подписан), загрузите доступные — оператор подскажет порядок действий и допустимые альтернативы.",
|
||||
"todo_header": "Необходимый пакет",
|
||||
"todo_plan": [
|
||||
{"number": 1, "text": "Паспорт: страница с фото, действующая виза или иной документ на право пребывания."},
|
||||
{"number": 2, "text": "Миграционная карта с отметкой о въезде (обе стороны)."},
|
||||
{"number": 3, "text": "Документ о месте пребывания: договор найма, свидетельство собственности или письмо принимающей стороны."},
|
||||
{"number": 4, "text": "При трудовой деятельности — копия патента или разрешения на работу (если применимо)."},
|
||||
{"number": 5, "text": "Нажмите «Отправить документы», когда все файлы приложены."}
|
||||
],
|
||||
"send_documents": true
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
if [[ -n "${CONTRACT_KEY}" && -n "${RESULTS_KEY}" ]]; then
|
||||
write_payload docs_ready <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "docs_ready",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-docs_ready",
|
||||
"notification_datetime": "${DT_DOCS_READY}",
|
||||
"header": "Миграционные документы готовы",
|
||||
"text": "Уведомление о постановке на учёт и справка о соблюдении требований закона сформированы — скачайте в деталях.",
|
||||
"details": {
|
||||
"details_header": "Документы по миграционному учёту",
|
||||
"details_text": "Документы подготовлены на основании переданных вами данных и проверены специалистом. Сохраните копии на устройство — они могут понадобиться при проверке или продлении статуса пребывания.\n\nСсылки на скачивание действуют ограниченное время. После первого скачивания карточка скроется с главной, но останется в Центре уведомлений до нажатия «Понятно».",
|
||||
"documents": [
|
||||
{
|
||||
"object_key": "${CONTRACT_KEY}",
|
||||
"title": "${CONTRACT_TITLE}",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": ${CONTRACT_SIZE},
|
||||
"checksum_sha256": "${CONTRACT_SHA256}"
|
||||
},
|
||||
{
|
||||
"object_key": "${RESULTS_KEY}",
|
||||
"title": "${RESULTS_TITLE}",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": ${RESULTS_SIZE},
|
||||
"checksum_sha256": "${RESULTS_SHA256}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
fi
|
||||
|
||||
write_payload status_changed <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "status_changed",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-status_changed",
|
||||
"notification_datetime": "${DT_STATUS}",
|
||||
"header": "Статус миграционной заявки обновлён",
|
||||
"text": "Заявка №МГ-8841 переведена на этап «Проверка комплектности документов».",
|
||||
"details": {
|
||||
"details_header": "Ход рассмотрения заявки №МГ-8841",
|
||||
"details_text": "27.07.2026 в 11:42 специалист принял ваш пакет документов в работу. Сейчас выполняется проверка на соответствие требованиям миграционного законодательства РФ: сроки пребывания, адрес учёта, основания для трудовой деятельности.\n\nОжидаемое время на этом этапе: 1–2 рабочих дня. При выявлении недостающих документов вы получите отдельное уведомление с перечнем. При успешной проверке будет сформировано уведомление о постановке на учёт.",
|
||||
"todo_header": "Этапы обработки",
|
||||
"todo_plan": [
|
||||
{"number": 1, "text": "Документы получены — выполнено 25.07.2026"},
|
||||
{"number": 2, "text": "Первичная верификация — выполнено 27.07.2026"},
|
||||
{"number": 3, "text": "Проверка комплектности и сроков — в работе"},
|
||||
{"number": 4, "text": "Формирование уведомления / рекомендаций — ожидает"}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload reminder <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "reminder",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-reminder",
|
||||
"notification_datetime": "${DT_REMINDER}",
|
||||
"header": "Напоминание: продление патента",
|
||||
"text": "28 июля истекает срок действия патента — подайте заявление на продление заранее.",
|
||||
"details": {
|
||||
"deadline": "${DEADLINE_REMINDER}",
|
||||
"details_header": "Сроки продления документа на право работы",
|
||||
"details_text": "Патент на работу необходимо продлевать заблаговременно — подача заявления рекомендуется не позднее чем за 10–15 рабочих дней до даты окончания действия. Просрочка означает прекращение права на трудовую деятельность и риск штрафа по ст. 18.15 КоАП РФ.\n\nДля продления потребуются: действующий патент, чеки об оплате авансовых платежей по НДФЛ, полис ДМС, сертификат о знании русского языка (если срок действия истекает), договор найма или иной документ о месте пребывания.",
|
||||
"todo_header": "Чек-лист перед подачей",
|
||||
"todo_plan": [
|
||||
{"number": 1, "text": "Проверьте дату окончания патента в личном кабинете или на бланке документа."},
|
||||
{"number": 2, "text": "Убедитесь, что авансовые платежи по НДФЛ оплачены без просрочки."},
|
||||
{"number": 3, "text": "Подготовьте сканы документов — при необходимости загрузите через уведомление «Требуются документы»."},
|
||||
{"number": 4, "text": "При вопросах напишите оператору — укажите регион и номер патента."}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload news <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "news",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-news",
|
||||
"notification_datetime": "${DT_NEWS}",
|
||||
"header": "Изменения в правилах миграционного учёта",
|
||||
"text": "С 1 августа 2026 уточнены сроки подачи уведомлений при смене адреса пребывания.",
|
||||
"details": {
|
||||
"details_header": "Что изменилось для иностранных граждан",
|
||||
"details_text": "С 01.08.2026 при смене адреса фактического проживания в том же субъекте РФ уведомление необходимо подать в течение 3 рабочих дней (ранее — 7). При переезде в другой регион срок остаётся 7 рабочих дней с даты регистрации по новому адресу.\n\nСервис HAN напомнит о приближающихся сроках через Центр уведомлений. Рекомендуем заранее подготовить копии договора найма и отметку о регистрации — это ускорит проверку оператором.\n\nПодробности процедуры — в чате с оператором или на официальном портале МВД России."
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload promo_personal <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "promo_personal",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-promo_personal",
|
||||
"notification_datetime": "${DT_PROMO}",
|
||||
"header": "Скидка 15% на годовое сопровождение",
|
||||
"text": "Персональное предложение: контроль сроков патента, учёта и уведомлений — до конца месяца.",
|
||||
"price": "15725.00",
|
||||
"old_price": "18500.00",
|
||||
"date_expired": "${EXPIRE_PAYMENT}",
|
||||
"chat_message_text": "Здравствуйте! Хочу подключить годовое сопровождение по соблюдению миграционного законодательства со скидкой 15%. Подскажите, что входит в пакет и как оформить."
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload ads_personal <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "ads_personal",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-ads_personal",
|
||||
"notification_datetime": "${DT_ADS}",
|
||||
"header": "Бесплатная проверка миграционного статуса",
|
||||
"text": "15 минут с экспертом: оценим риски и составим чек-лист обязательных действий.",
|
||||
"chat_message_text": "Здравствуйте! Хочу воспользоваться бесплатной проверкой миграционного статуса. Подскажите, как записаться и какие документы подготовить к консультации."
|
||||
}
|
||||
JSON
|
||||
|
||||
# --- отправка ---
|
||||
|
||||
create_notification urgent
|
||||
create_notification payment_pending
|
||||
create_notification docs_required
|
||||
if [[ -f "${PAYLOAD_DIR}/docs_ready.json" ]]; then
|
||||
create_notification docs_ready
|
||||
else
|
||||
echo
|
||||
echo "========== docs_ready — ПРОПУЩЕН =========="
|
||||
fi
|
||||
create_notification status_changed
|
||||
create_notification reminder
|
||||
create_notification news
|
||||
create_notification promo_personal
|
||||
create_notification ads_personal
|
||||
|
||||
echo
|
||||
echo "========== Готово =========="
|
||||
echo "External ID prefix: ${RUN_ID}-*"
|
||||
echo
|
||||
echo "Если видите 401: проверьте NOTIFICATIONS_TOKEN_PRODUCER_TEST в .env и перезапустите api-backend."
|
||||
echo " grep NOTIFICATIONS_TOKEN_PRODUCER_TEST .env"
|
||||
echo " docker compose --env-file .env up -d --force-recreate api-backend"
|
||||
@@ -9,5 +9,5 @@ flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit
|
||||
docker compose --profile certbot run --rm certbot renew \
|
||||
--webroot -w /var/www/certbot --quiet
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose exec -T nginx nginx -s reload
|
||||
docker compose kill -s HUP nginx
|
||||
echo "{\"event\":\"tls.renew.completed\",\"timestamp\":\"$(date -u +%FT%TZ)\"}"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
NETWORK="${OBSERVABILITY_NETWORK:-han-chat-observability}"
|
||||
COLLECTOR_SERVICE="${COLLECTOR_SERVICE:-otel-collector}"
|
||||
errors=0
|
||||
|
||||
ok() { printf 'OK %s\n' "$*"; }
|
||||
fail() { printf 'FAIL %s\n' "$*" >&2; errors=$((errors + 1)); }
|
||||
|
||||
collector_id="$(docker compose ps -q "$COLLECTOR_SERVICE" 2>/dev/null || true)"
|
||||
if [[ -n "$collector_id" ]] &&
|
||||
[[ "$(docker inspect --format '{{.State.Status}}' "$collector_id")" == running ]]; then
|
||||
ok "Collector service is running"
|
||||
else
|
||||
fail "Collector service '$COLLECTOR_SERVICE' is not running"
|
||||
fi
|
||||
|
||||
for service in sms-service sms-worker; do
|
||||
if docker compose exec -T "$service" python - <<'PY' >/dev/null 2>&1
|
||||
import os
|
||||
import urllib.request
|
||||
port = os.environ.get("SMS_METRICS_PORT", "9464") if "worker" in os.environ.get("OTEL_SERVICE_NAME", "") else "8080"
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/metrics", timeout=3).read(1024)
|
||||
PY
|
||||
then
|
||||
ok "${service} metrics endpoint"
|
||||
else
|
||||
fail "${service} metrics endpoint"
|
||||
fi
|
||||
done
|
||||
|
||||
docker run --rm --network "$NETWORK" \
|
||||
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
|
||||
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
|
||||
--service han-chat-e2e-canary --traces 100 --rate 20 >/dev/null \
|
||||
&& ok "100 canary traces submitted" \
|
||||
|| fail "telemetrygen failed"
|
||||
|
||||
bad_logs="$(
|
||||
docker compose logs --since=10m "$COLLECTOR_SERVICE" 2>&1 |
|
||||
grep -Ei 'queue is full|connection refused|tls:|Unauthenticated|Permanent error' || true
|
||||
)"
|
||||
if [[ -z "$bad_logs" ]]; then
|
||||
ok "No exporter/queue errors in last 10 minutes"
|
||||
else
|
||||
fail "Collector reports exporter/queue errors"
|
||||
printf '%s\n' "$bad_logs" >&2
|
||||
fi
|
||||
|
||||
if ((errors)); then
|
||||
printf 'Observability verification failed: %d check(s)\n' "$errors" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat <<'EOF'
|
||||
Локальный канал исправен. В SigNoz проверьте за последние 15 минут:
|
||||
service.name = han-chat-e2e-canary
|
||||
service.namespace = han-chat
|
||||
Затем выполните synthetic API request и проверьте общий trace между
|
||||
api-backend и dependency spans, service.version и deployment.environment.
|
||||
EOF
|
||||
@@ -9,6 +9,8 @@ import type { NotificationItem } from "../types";
|
||||
import { ErrorNotice, Loading } from "../ui";
|
||||
import { NotificationCard } from "./NotificationCard";
|
||||
|
||||
const CARD_GAP = spacing.sm;
|
||||
|
||||
export function NotificationCarousel({
|
||||
authenticated,
|
||||
autoplay = false,
|
||||
@@ -27,6 +29,7 @@ export function NotificationCarousel({
|
||||
const [hiddenGuestIds, setHiddenGuestIds] = useState<Set<string>>(() => new Set());
|
||||
const window = useWindowDimensions();
|
||||
const cardWidth = Math.max(0, Math.min(window.width, layout.maxWidth) - spacing.lg * 2);
|
||||
const pageWidth = cardWidth + CARD_GAP;
|
||||
const catalog = useQuery({
|
||||
queryKey: notificationKeys.catalog,
|
||||
queryFn: notificationApi.catalog,
|
||||
@@ -57,6 +60,12 @@ export function NotificationCarousel({
|
||||
list.current?.scrollToIndex({ index: next, animated: true });
|
||||
};
|
||||
|
||||
const syncActiveIndex = (offset: number) => {
|
||||
if (pageWidth <= 0) return;
|
||||
const next = Math.min(data.length - 1, Math.max(0, Math.round(offset / pageWidth)));
|
||||
setActiveIndex((current) => current === next ? current : next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoplay || data.length < 2) return;
|
||||
const timer = setInterval(() => {
|
||||
@@ -98,17 +107,15 @@ export function NotificationCarousel({
|
||||
horizontal
|
||||
data={data}
|
||||
decelerationRate="fast"
|
||||
pagingEnabled
|
||||
snapToInterval={cardWidth}
|
||||
disableIntervalMomentum
|
||||
snapToInterval={pageWidth}
|
||||
snapToAlignment="start"
|
||||
getItemLayout={(_, index) => ({ length: cardWidth, offset: cardWidth * index, index })}
|
||||
getItemLayout={(_, index) => ({ length: pageWidth, offset: pageWidth * index, index })}
|
||||
ItemSeparatorComponent={() => <View style={local.separator} />}
|
||||
keyExtractor={(item) => item.id}
|
||||
style={local.carousel}
|
||||
onMomentumScrollEnd={(event) => {
|
||||
if (cardWidth > 0) {
|
||||
setActiveIndex(Math.min(data.length - 1, Math.round(event.nativeEvent.contentOffset.x / cardWidth)));
|
||||
}
|
||||
}}
|
||||
onScroll={(event) => syncActiveIndex(event.nativeEvent.contentOffset.x)}
|
||||
scrollEventThrottle={16}
|
||||
renderItem={({ item }) => (
|
||||
<NotificationCard
|
||||
disabled={hide.isPending}
|
||||
@@ -139,6 +146,7 @@ export function NotificationCarousel({
|
||||
const local = StyleSheet.create({
|
||||
section: { paddingVertical: spacing.md },
|
||||
carousel: { marginHorizontal: spacing.lg },
|
||||
separator: { width: CARD_GAP },
|
||||
state: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
|
||||
error: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
|
||||
});
|
||||
|
||||
@@ -48,7 +48,9 @@ export function QuickActions({ phone, authenticated = false }: { phone?: string
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
const hasUnreadMessages = (dialogs.data?.items ?? []).some((dialog) => (dialog.unread_count ?? 0) > 0);
|
||||
const hasUnreadMessages = (dialogs.data?.items ?? []).some(
|
||||
(dialog) => (dialog.unread_count ?? 0) > 0 || dialog.status === "waiting_for_client",
|
||||
);
|
||||
|
||||
const call = () => {
|
||||
if (phone) void Linking.openURL(`tel:${phone}`);
|
||||
|
||||
@@ -19,6 +19,10 @@ x-api-runtime: &api-runtime
|
||||
environment:
|
||||
<<: *no-sms-secrets
|
||||
NOTIFICATIONS_TOKEN_PRODUCER_TEST: ${NOTIFICATIONS_TOKEN_PRODUCER_TEST:?NOTIFICATIONS_TOKEN_PRODUCER_TEST is required}
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
|
||||
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_API:-api-backend}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PG_CA_HOST_PATH}
|
||||
@@ -42,7 +46,10 @@ x-sms-runtime: &sms-runtime
|
||||
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
|
||||
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
|
||||
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_SMS_API:-sms-service}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PG_CA_HOST_PATH}
|
||||
@@ -152,7 +159,12 @@ services:
|
||||
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
|
||||
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
|
||||
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_SMS_WORKER:-sms-worker}
|
||||
SMS_METRICS_PORT: ${SMS_METRICS_PORT:-9464}
|
||||
expose: ["9464"]
|
||||
networks: [backend, observability, egress]
|
||||
depends_on:
|
||||
sms-service: {condition: service_healthy}
|
||||
|
||||
@@ -18,12 +18,13 @@ services:
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
|
||||
OTEL_REMOTE_ENDPOINT: ${OTEL_REMOTE_ENDPOINT}
|
||||
OTEL_REMOTE_AUTH_HEADER: ${OTEL_REMOTE_AUTH_HEADER}
|
||||
OTEL_REMOTE_AUTH_HEADER: ${OTEL_REMOTE_AUTH_HEADER:-}
|
||||
OTEL_REMOTE_TLS_INSECURE: ${OTEL_REMOTE_TLS_INSECURE:-false}
|
||||
expose: ["4317", "4318", "13133", "8888"]
|
||||
volumes:
|
||||
- ./otel-collector.yaml:/etc/otelcol/config.yaml:ro
|
||||
- otel-queue:/var/lib/otelcol/queue
|
||||
networks: [observability, backend, egress]
|
||||
networks: [observability, egress]
|
||||
depends_on:
|
||||
otel-queue-init: {condition: service_completed_successfully}
|
||||
healthcheck:
|
||||
|
||||
@@ -16,6 +16,33 @@ receivers:
|
||||
- job_name: otel-collector
|
||||
static_configs:
|
||||
- targets: ["127.0.0.1:8888"]
|
||||
- job_name: sms-service
|
||||
scrape_interval: 15s
|
||||
scrape_timeout: 10s
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["sms-service:8080"]
|
||||
labels: {service_name: sms-service}
|
||||
- job_name: sms-worker
|
||||
scrape_interval: 15s
|
||||
scrape_timeout: 10s
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["sms-worker:9464"]
|
||||
labels: {service_name: sms-worker}
|
||||
- job_name: keycloak
|
||||
scrape_interval: 30s
|
||||
scrape_timeout: 15s
|
||||
metrics_path: /auth/metrics
|
||||
static_configs:
|
||||
- targets: ["keycloak:9000"]
|
||||
labels: {service_name: keycloak}
|
||||
metric_relabel_configs:
|
||||
- action: keep
|
||||
source_labels: [__name__]
|
||||
regex: "(keycloak_.*|jvm_.*|process_.*|system_.*|agroal_.*)"
|
||||
- action: labeldrop
|
||||
regex: "(client_id|uri|session_id|user_id|phone|email)"
|
||||
|
||||
processors:
|
||||
memory_limiter:
|
||||
@@ -33,19 +60,37 @@ processors:
|
||||
- {key: http.request.header.cookie, action: delete}
|
||||
- {key: http.response.header.set-cookie, action: delete}
|
||||
- {key: url.query, action: delete}
|
||||
- {key: url.full, action: delete}
|
||||
- {key: http.target, action: delete}
|
||||
- {key: http.request.body, action: delete}
|
||||
- {key: db.statement, action: delete}
|
||||
- {key: db.query.text, action: delete}
|
||||
- {key: enduser.id, action: delete}
|
||||
- {key: user.phone, action: delete}
|
||||
- {key: user.email, action: delete}
|
||||
- {key: messaging.message.body, action: delete}
|
||||
- {key: aws.s3.key, action: delete}
|
||||
- {key: s3.object.key, action: delete}
|
||||
filter/noise:
|
||||
error_mode: ignore
|
||||
traces:
|
||||
span:
|
||||
- 'attributes["http.route"] == "/health/live"'
|
||||
- 'attributes["http.route"] == "/nginx-health/live"'
|
||||
probabilistic_sampler:
|
||||
sampling_percentage: 10
|
||||
tail_sampling:
|
||||
decision_wait: 10s
|
||||
num_traces: 10000
|
||||
expected_new_traces_per_sec: 50
|
||||
policies:
|
||||
- name: errors
|
||||
type: status_code
|
||||
status_code: {status_codes: [ERROR]}
|
||||
- name: slow
|
||||
type: latency
|
||||
latency: {threshold_ms: 2000}
|
||||
- name: successful-sample
|
||||
type: probabilistic
|
||||
probabilistic: {sampling_percentage: 10}
|
||||
batch:
|
||||
timeout: 5s
|
||||
send_batch_size: 1024
|
||||
@@ -57,7 +102,7 @@ exporters:
|
||||
headers:
|
||||
authorization: "${env:OTEL_REMOTE_AUTH_HEADER}"
|
||||
tls:
|
||||
insecure: false
|
||||
insecure: "${env:OTEL_REMOTE_TLS_INSECURE}"
|
||||
sending_queue:
|
||||
enabled: true
|
||||
num_consumers: 4
|
||||
@@ -74,7 +119,7 @@ service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [memory_limiter, resource/common, attributes/redact, filter/noise, probabilistic_sampler, batch]
|
||||
processors: [memory_limiter, resource/common, attributes/redact, filter/noise, tail_sampling, batch]
|
||||
exporters: [otlp/remote]
|
||||
metrics:
|
||||
receivers: [otlp, prometheus]
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.service import (
|
||||
read_message,
|
||||
)
|
||||
from app.settings import get_settings
|
||||
from app.telemetry import add_trace_context, init_telemetry, instrument_fastapi
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
@@ -40,6 +41,7 @@ def configure_logging(level: str) -> None:
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
add_trace_context,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.JSONRenderer(),
|
||||
@@ -50,11 +52,16 @@ def configure_logging(level: str) -> None:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
telemetry = init_telemetry()
|
||||
configure_logging(settings.log_level)
|
||||
app.state.settings = settings
|
||||
app.state.db = Database(settings.database_url)
|
||||
yield
|
||||
await app.state.db.close()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await app.state.db.close()
|
||||
if telemetry:
|
||||
telemetry.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -77,7 +84,6 @@ async def request_context(request: Request, call_next: Any) -> Response:
|
||||
structlog.contextvars.bind_contextvars(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
route=request.url.path,
|
||||
**{"service.name": "sms-service"},
|
||||
)
|
||||
response = await call_next(request)
|
||||
@@ -86,6 +92,7 @@ async def request_context(request: Request, call_next: Any) -> Response:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
log.info(
|
||||
"request.complete",
|
||||
route=getattr(request.scope.get("route"), "path", request.url.path),
|
||||
status_code=response.status_code,
|
||||
duration_ms=round((time.monotonic() - started) * 1000, 2),
|
||||
)
|
||||
@@ -312,6 +319,9 @@ def datetime_now():
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
instrument_fastapi(app)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
settings = get_settings()
|
||||
uvicorn.run(
|
||||
|
||||
@@ -18,6 +18,7 @@ class Settings(BaseSettings):
|
||||
callback_password: SecretStr = Field(alias="IDGTL_SMS_CALLBACK_PASSWORD")
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
api_port: int = Field(default=8080, alias="SMS_API_PORT", ge=1, le=65535)
|
||||
metrics_port: int = Field(default=9464, alias="SMS_METRICS_PORT", ge=1, le=65535)
|
||||
|
||||
@field_validator(
|
||||
"service_token",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from opentelemetry import metrics, trace
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
||||
from opentelemetry.propagate import set_global_textmap
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
|
||||
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TelemetryRuntime:
|
||||
tracer_provider: TracerProvider
|
||||
meter_provider: MeterProvider
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.meter_provider.shutdown()
|
||||
self.tracer_provider.shutdown()
|
||||
|
||||
|
||||
_runtime: TelemetryRuntime | None = None
|
||||
|
||||
|
||||
def _resource(service_name: str) -> Resource:
|
||||
return Resource.create(
|
||||
{
|
||||
"service.name": service_name,
|
||||
"service.namespace": "han-chat",
|
||||
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
|
||||
"deployment.environment": os.getenv("APP_ENV", "production-like"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
|
||||
global _runtime
|
||||
if _runtime is not None:
|
||||
return _runtime
|
||||
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
|
||||
if not endpoint:
|
||||
return None
|
||||
|
||||
resource = _resource(service_name or os.getenv("OTEL_SERVICE_NAME", "sms-service"))
|
||||
insecure = endpoint.startswith("http://")
|
||||
set_global_textmap(TraceContextTextMapPropagator())
|
||||
|
||||
tracer_provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
|
||||
tracer_provider.add_span_processor(
|
||||
BatchSpanProcessor(
|
||||
OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||
max_queue_size=2048,
|
||||
schedule_delay_millis=5000,
|
||||
max_export_batch_size=512,
|
||||
export_timeout_millis=3000,
|
||||
)
|
||||
)
|
||||
trace.set_tracer_provider(tracer_provider)
|
||||
|
||||
metric_reader = PeriodicExportingMetricReader(
|
||||
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||
export_interval_millis=30000,
|
||||
export_timeout_millis=3000,
|
||||
)
|
||||
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||
metrics.set_meter_provider(meter_provider)
|
||||
|
||||
HTTPXClientInstrumentor().instrument()
|
||||
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
|
||||
return _runtime
|
||||
|
||||
|
||||
def instrument_fastapi(app: FastAPI) -> None:
|
||||
FastAPIInstrumentor.instrument_app(
|
||||
app,
|
||||
excluded_urls="/health/live,/health/ready",
|
||||
)
|
||||
|
||||
|
||||
def add_trace_context(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
context = trace.get_current_span().get_span_context()
|
||||
if context.is_valid:
|
||||
event_dict["trace_id"] = format(context.trace_id, "032x")
|
||||
event_dict["span_id"] = format(context.span_id, "016x")
|
||||
return event_dict
|
||||
@@ -9,6 +9,8 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from opentelemetry import trace
|
||||
from prometheus_client import start_http_server
|
||||
from sqlalchemy import and_, func, or_, select, update
|
||||
|
||||
from app.db import Database, SendStatus, SmsOutboundMessage
|
||||
@@ -23,6 +25,7 @@ from app.metrics import (
|
||||
from app.provider import IdgtlClient, IdgtlConfig
|
||||
from app.service import RuntimeSettings, load_runtime_settings
|
||||
from app.settings import Settings, get_settings
|
||||
from app.telemetry import add_trace_context, init_telemetry
|
||||
|
||||
log = structlog.get_logger()
|
||||
MAX_CONNECT_ATTEMPTS = 3
|
||||
@@ -32,6 +35,8 @@ def configure_logging(level: str) -> None:
|
||||
logging.basicConfig(level=level, format="%(message)s")
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
add_trace_context,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.JSONRenderer(),
|
||||
@@ -169,7 +174,14 @@ async def update_queue_metrics(db: Database) -> None:
|
||||
|
||||
async def worker_loop(stop: asyncio.Event) -> None:
|
||||
settings = get_settings()
|
||||
telemetry = init_telemetry("sms-worker")
|
||||
configure_logging(settings.log_level)
|
||||
structlog.contextvars.bind_contextvars(**{"service.name": "sms-worker"})
|
||||
metrics_server, metrics_thread = start_http_server(
|
||||
settings.metrics_port,
|
||||
addr="0.0.0.0", # noqa: S104 - internal Docker-network listener
|
||||
)
|
||||
tracer = trace.get_tracer("han.sms.worker")
|
||||
db = Database(settings.database_url)
|
||||
async with httpx.AsyncClient() as http:
|
||||
try:
|
||||
@@ -179,16 +191,30 @@ async def worker_loop(stop: asyncio.Event) -> None:
|
||||
async with db.sessions() as session:
|
||||
runtime = await load_runtime_settings(session)
|
||||
SETTINGS_VALID.set(1)
|
||||
claim_started_ns = time.time_ns()
|
||||
message = await lease_message(db, runtime)
|
||||
claim_finished_ns = time.time_ns()
|
||||
if message is None:
|
||||
await update_queue_metrics(db)
|
||||
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
|
||||
continue
|
||||
client = IdgtlClient(http, provider_config(settings, runtime))
|
||||
started = time.monotonic()
|
||||
result = await client.send(message)
|
||||
PROVIDER_LATENCY.labels("idgtl").observe(time.monotonic() - started)
|
||||
await save_result(db, message.id, result, message.attempt_count)
|
||||
process_span = tracer.start_span("sms.process", start_time=claim_started_ns)
|
||||
with trace.use_span(process_span, end_on_exit=True):
|
||||
claim_span = tracer.start_span("sms.claim", start_time=claim_started_ns)
|
||||
claim_span.set_attribute("sms.claimed", True)
|
||||
claim_span.end(end_time=claim_finished_ns)
|
||||
span = trace.get_current_span()
|
||||
span.set_attribute("messaging.operation.name", "send")
|
||||
span.set_attribute("messaging.system", "idgtl")
|
||||
span.set_attribute("sms.attempt", message.attempt_count)
|
||||
client = IdgtlClient(http, provider_config(settings, runtime))
|
||||
with tracer.start_as_current_span("sms.provider"):
|
||||
started = time.monotonic()
|
||||
result = await client.send(message)
|
||||
PROVIDER_LATENCY.labels("idgtl").observe(time.monotonic() - started)
|
||||
span.set_attribute("sms.outcome", result.send_status.value)
|
||||
with tracer.start_as_current_span("sms.save_result"):
|
||||
await save_result(db, message.id, result, message.attempt_count)
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
@@ -200,6 +226,10 @@ async def worker_loop(stop: asyncio.Event) -> None:
|
||||
pass
|
||||
finally:
|
||||
await db.close()
|
||||
metrics_server.shutdown()
|
||||
metrics_thread.join(timeout=5)
|
||||
if telemetry:
|
||||
telemetry.shutdown()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
|
||||
@@ -8,6 +8,12 @@ dependencies = [
|
||||
"asyncpg>=0.30,<1",
|
||||
"fastapi>=0.116,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"opentelemetry-api>=1.44,<2",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.44,<2",
|
||||
"opentelemetry-instrumentation-fastapi>=0.65b0,<1",
|
||||
"opentelemetry-instrumentation-httpx>=0.65b0,<1",
|
||||
"opentelemetry-instrumentation-sqlalchemy>=0.65b0,<1",
|
||||
"opentelemetry-sdk>=1.44,<2",
|
||||
"phonenumbers>=9,<10",
|
||||
"prometheus-client>=0.22,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
from app import telemetry
|
||||
|
||||
|
||||
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
|
||||
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
||||
monkeypatch.setattr(telemetry, "_runtime", None)
|
||||
|
||||
assert telemetry.init_telemetry("sms-test") is None
|
||||
|
||||
|
||||
def test_structlog_processor_adds_active_trace_context() -> None:
|
||||
tracer = TracerProvider().get_tracer("test")
|
||||
|
||||
with tracer.start_as_current_span("sms.operation"):
|
||||
result = telemetry.add_trace_context(None, "info", {"event": "sms.safe"})
|
||||
|
||||
assert result["event"] == "sms.safe"
|
||||
assert len(result["trace_id"]) == 32
|
||||
assert len(result["span_id"]) == 16
|
||||
@@ -140,12 +140,41 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
"http.request.header.authorization",
|
||||
"http.request.header.cookie",
|
||||
"url.query",
|
||||
"url.full",
|
||||
"db.statement",
|
||||
"db.query.text",
|
||||
"messaging.message.body",
|
||||
"aws.s3.key",
|
||||
):
|
||||
self.assertIn(forbidden_attribute, config)
|
||||
self.assertIn("storage: file_storage", config)
|
||||
self.assertIn("retry_on_failure:", config)
|
||||
self.assertIn('insecure: "${env:OTEL_REMOTE_TLS_INSECURE}"', config)
|
||||
self.assertIn("tail_sampling:", config)
|
||||
self.assertNotIn("probabilistic_sampler:", config)
|
||||
self.assertIn('targets: ["sms-service:8080"]', config)
|
||||
self.assertIn('targets: ["sms-worker:9464"]', config)
|
||||
self.assertIn('targets: ["keycloak:9000"]', config)
|
||||
self.assertIn("metric_relabel_configs:", config)
|
||||
compose = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("OTEL_REMOTE_TLS_INSECURE: ${OTEL_REMOTE_TLS_INSECURE:-false}", compose)
|
||||
self.assertIn("networks: [observability, egress]", compose)
|
||||
self.assertNotIn("networks: [observability, backend, egress]", compose)
|
||||
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_API:-api-backend}", application)
|
||||
self.assertIn("OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_SMS_API:-sms-service}", application)
|
||||
self.assertIn("OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME_SMS_WORKER:-sms-worker}", application)
|
||||
self.assertIn('expose: ["9464"]', application)
|
||||
for service_main in (
|
||||
ROOT / "api-backend/app/main.py",
|
||||
ROOT / "sms-service/app/main.py",
|
||||
):
|
||||
source = service_main.read_text(encoding="utf-8")
|
||||
self.assertNotIn("route=request.url.path", source)
|
||||
self.assertIn('getattr(request.scope.get("route"), "path"', source)
|
||||
worker = (ROOT / "sms-service/app/worker.py").read_text(encoding="utf-8")
|
||||
for span_name in ("sms.claim", "sms.process", "sms.provider", "sms.save_result"):
|
||||
self.assertIn(f'"{span_name}"', worker)
|
||||
|
||||
def test_settings_cli_and_workers_are_deployable(self) -> None:
|
||||
cli = ROOT / "api-backend/app/cli"
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
# Бизнес-постановка: Обмен сообщениями (Chat / Dialog)
|
||||
|
||||
**Статус:** v1 — консолидация принятых решений из `HAN_chat_specification` (`arch-00`…`arch-05`, `module-01`, `module-05`, `module-06`); открытые вопросы зафиксированы в §17
|
||||
**Продукт:** HAN Chat (клиентское приложение + `api-backend` + `message-safety` + `bitrix-local-app` / Open Lines)
|
||||
**Источники:** архитектура `HAN_chat_specification`; макет Figma (**не канон** — только визуализация; при расхождении приоритет у этого ТЗ и arch-документов)
|
||||
**Связанный backlog:** непрочитанные сообщения на кнопке «Чат» (синхронизация между устройствами); UX ошибки отложенного сообщения после входа
|
||||
**Смежно:** пользователь (auth/bootstrap), уведомления (`send_chat_message`, кнопки Чат/Оператор), популярные вопросы, Message Safety
|
||||
|
||||
**Нормативная часть — §1–§16.** §17 — ненормативный журнал решений и открытых вопросов; при расхождении с §1–§16 приоритет у §1–§16. При расхождении этого документа с arch/module после их обновления — приоритет у arch/module до синхронизации.
|
||||
|
||||
---
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Дать клиенту канал диалога с оператором компании: отправка текста и файлов из приложения в Bitrix24 Open Lines и получение ответов оператора в реальном времени — с проверкой исходящих сообщений (Message Safety), идемпотентностью, ownership и деградацией без потери уже принятых данных.
|
||||
|
||||
Чат — канал **«клиент ↔ оператор»**. Уведомления — канал **«компания → клиент»** вне чата (см. `notification-requirements.md`). Непрочитанные ответы оператора **не** моделируются как уведомления; индикатор непрочитанного чата — отдельная фича (§3.2, §17.2).
|
||||
|
||||
---
|
||||
|
||||
## 2. Два направления обмена
|
||||
|
||||
| Направление | `sender_type` | Кто инициирует | Проверка Message Safety | Доставка |
|
||||
|---|---|---|---|---|
|
||||
| **C→O. Клиент → оператор** | `client` | Frontend (JWT) | **Обязательна** до Open Lines | `api-backend` → `bitrix-local-app` → Open Lines |
|
||||
| **O→C. Оператор → клиент** | `company` | Bitrix24 webhook → `bitrix-local-app` → inbox API | **Нет** outbound moderation (доверенный канал); MIME/size/antivirus policy модуля | App DB + WS / polling |
|
||||
|
||||
Правила:
|
||||
|
||||
1. Чат доступен **только авторизованному** клиенту (JWT + `bootstrap`). Гость инициирует auth; текст сохраняется локально и отправляется после входа (§6.4).
|
||||
2. У пользователя **не более одного активного** диалога (`open` \| `waiting_for_company` \| `waiting_for_client`).
|
||||
3. `dialog_id` приложения **равен** `external_chat_id` для Open Lines.
|
||||
4. MVP: одно исходящее сообщение — либо текст, либо ровно один файл (`content_kind`), не оба сразу.
|
||||
5. Клиент на `POST .../messages` получает **только финальный** результат (или ошибку инфраструктуры), не промежуточное «обрабатывается».
|
||||
6. Источник истины ленты — App DB; WS — at-most-once best effort; после reconnect — REST reconcile.
|
||||
|
||||
---
|
||||
|
||||
## 3. Границы релиза
|
||||
|
||||
### 3.1. В scope
|
||||
|
||||
- Один активный диалог на пользователя; ленивое создание перед первым сообщением.
|
||||
- Исходящие: `content_kind` `text` \| `file`; входящие: текст и файлы оператора.
|
||||
- Orchestration Message Safety: sync check + sync-wait poll `task_id` + checkpoint `safety_tasks` + recovery.
|
||||
- Presigned upload клиента в S3-quarantine → complete → safety → promote в S3-data attachments.
|
||||
- Durable outbox App → Open Lines; idempotent delivery по `message_id`.
|
||||
- Inbox Open Lines → App: `message.new`, `dialog.closed`; idempotency по `(external_chat_id, bitrix_message_id)` / `event_id`.
|
||||
- Realtime `WS /api/v1/realtime` + polling fallback истории сообщений.
|
||||
- Популярные вопросы как обычная отправка текста после auth.
|
||||
- Общий механизм **отложенного сообщения** на frontend (ручной ввод, популярный вопрос, CTA уведомлений `send_chat_message`).
|
||||
- Кнопки UI «Чат» и «Оператор» (`tel:` ← `operator.call.phone`) — смежно с уведомлениями.
|
||||
- Нормализация текста входящих из Bitrix (удаление служебной разметки отправителя) — уже закрытый дефект backlog.
|
||||
- Ownership, rate limits, `Idempotency-Key` (TTL 24 ч) на create dialog / send message.
|
||||
- Константы `chat.attachments.*`, `rate_limit.message_send.*`, `operator.call.phone`.
|
||||
|
||||
### 3.2. Вне scope
|
||||
|
||||
- Смешанное сообщение «текст + файл(ы)» (post-MVP, отдельная версия API).
|
||||
- Несколько вложений в одном исходящем сообщении.
|
||||
- UI-раздел «История чатов / список диалогов» как продуктовый экран — **deprecated** для MVP frontend (один чат с компанией); backend `GET /api/v1/dialogs` **не удаляется**.
|
||||
- Индикатор непрочитанных сообщений чата и sync между устройствами (`Dialog.client_last_opened_at` и аналоги) — backlog п.23–24; **не** путать с бейджем уведомлений.
|
||||
- Typing indicators, реакции, редактирование/удаление сообщений клиентом, ответы на конкретное сообщение (quote/reply).
|
||||
- Голосовые / видеосообщения, стикеры, произвольные форматы сверх `chat.attachments.*`.
|
||||
- Push / deep link в чат (модель может быть push-ready позже).
|
||||
- Админ-модерация очереди `needs_review` (enum зарезервирован, в MVP не создаётся).
|
||||
- CRM Contact sync в hot path чата (`bitrix-sync` **не** участвует в Open Lines delivery).
|
||||
- Реальная антивирус/LLM-модерация: в MVP допускается stub `message-safety` с фиксированными правилами теста; канонический контракт вердиктов — arch-02 (`200/203/403`).
|
||||
|
||||
---
|
||||
|
||||
## 4. UI
|
||||
|
||||
| Место | Поведение |
|
||||
|---|---|
|
||||
| Главная: поле ввода | Отправка текста; без auth → согласия → OTP → отложенная отправка |
|
||||
| Главная: популярные вопросы | Тап = автоотправка текста вопроса (тот же поток, что ручной ввод) |
|
||||
| Кнопка «Чат» | Открывает текущий активный диалог или создаёт его при отсутствии |
|
||||
| Кнопка «Оператор» | `tel:` на `operator.call.phone` (не чат-сообщение) |
|
||||
| Экран чата | Лента сообщений клиента и компании; статусы доставки по DTO |
|
||||
| Вложения | Выбор файла → init → PUT в quarantine → complete → send `content_kind=file` |
|
||||
| Гость в Центре уведомлений / чате | Auth-gate; после входа — ЛК |
|
||||
|
||||
Визуал — по Figma. Figma не канон статусов доставки и состава API.
|
||||
|
||||
---
|
||||
|
||||
## 5. Бизнес-модель
|
||||
|
||||
### 5.1. Сущности
|
||||
|
||||
| Сущность | Схема | Назначение |
|
||||
|---|---|---|
|
||||
| `Dialog` | `han_app` | Диалог клиента с Open Lines |
|
||||
| `Message` | `han_app` | Сообщение в диалоге |
|
||||
| `MessageAttachment` | `han_app` | Вложение (client upload или company inbound) |
|
||||
| `safety_tasks` | `han_app` | Checkpoint sync-wait Message Safety (техническая) |
|
||||
| `delivery_outbox` | `han_app` | Durable намерение доставки в Open Lines |
|
||||
| `openlines_inbox_receipts` | `han_app` | Idempotency приёма событий local app |
|
||||
| `dialog_sessions` | `bitrix_local` | Маппинг чата у `bitrix-local-app` |
|
||||
| `popular_questions` | `han_app` | Справочник текстов быстрых вопросов (не сообщения) |
|
||||
|
||||
### 5.2. `Dialog.status`
|
||||
|
||||
| Значение | Смысл |
|
||||
|---|---|
|
||||
| `open` | Диалог создан, сообщений ещё нет |
|
||||
| `waiting_for_company` | Последнее значимое — исходящее от клиента; ждём оператора |
|
||||
| `waiting_for_client` | Последнее значимое — входящее от оператора; ждём клиента |
|
||||
| `closed` | Закрыт в Open Lines (`dialog.closed` / `ONIMCONNECTORDIALOGFINISH`) |
|
||||
|
||||
Переходы:
|
||||
|
||||
| Событие | Новый статус |
|
||||
|---|---|
|
||||
| `POST /dialogs` (новый) | `open` |
|
||||
| Успешная доставка исходящего клиента в Open Lines | `waiting_for_company` |
|
||||
| Сохранено входящее от оператора | `waiting_for_client` |
|
||||
| Inbox `dialog.closed` | `closed` |
|
||||
|
||||
Из `closed` обратный переход **запрещён**. Новый активный диалог после закрытия — снова через `POST /dialogs`, когда продукт это разрешит; инвариант «один active» сохраняется.
|
||||
|
||||
### 5.3. Сообщение: вид и статусы
|
||||
|
||||
**`content_kind` (исходящее MVP):**
|
||||
|
||||
| `content_kind` | Тело запроса | `Message.text` | Вложения |
|
||||
|---|---|---|---|
|
||||
| `text` | непустой `text` | текст | 0 |
|
||||
| `file` | `attachment_id` + `checksum` | пустая строка | ровно 1 |
|
||||
|
||||
Запрещено: текст + файл → `400 mixed_content_not_allowed`; пустое → `400 empty_message`; >1 вложение → `400 too_many_attachments`.
|
||||
|
||||
**`sender_type`:** `client` \| `company`.
|
||||
|
||||
**`safety_status`:**
|
||||
|
||||
| Значение | Смысл |
|
||||
|---|---|
|
||||
| `pending` | Проверка не завершена |
|
||||
| `allowed` | Финальный allow |
|
||||
| `blocked` | Финальный deny |
|
||||
| `needs_review` | Зарезервирован, в MVP не создаётся |
|
||||
|
||||
Входящие `company` всегда `safety_status=allowed`.
|
||||
|
||||
**`delivery_status`:**
|
||||
|
||||
| Значение | Смысл | Клиенту как финал `POST .../messages`? |
|
||||
|---|---|---|
|
||||
| `accepted` | Принято API, safety/доставка ещё не финализированы | нет (промежуточный) |
|
||||
| `processing` | Sync-wait safety | нет |
|
||||
| `delivered` | Allow + ушло в Open Lines (или входящее сохранено) | да |
|
||||
| `rejected` | Deny Message Safety | да (`422 message_blocked`) |
|
||||
| `failed` | Инфраструктурная ошибка (Bitrix/S3/timeout), не safety-deny | да (`503`/`504`) |
|
||||
|
||||
Инварианты: `rejected` ↔ `blocked`; `delivered` → `allowed`.
|
||||
|
||||
### 5.4. Вложение
|
||||
|
||||
| `scan_status` | Смысл |
|
||||
|---|---|
|
||||
| `pending` | В quarantine, проверка не завершена |
|
||||
| `clean` | Allow, файл в S3-data (после promote) |
|
||||
| `infected` | Deny |
|
||||
| `failed` | Ошибка инфраструктуры проверки |
|
||||
|
||||
`direction`: `client_upload` \| `company_inbound`.
|
||||
|
||||
Клиентский файл до allow живёт **только** в S3-quarantine. Постоянных access keys у клиента нет — только короткий presigned PUT/GET.
|
||||
|
||||
### 5.5. Идентификаторы
|
||||
|
||||
| Имя | Назначение |
|
||||
|---|---|
|
||||
| `dialog_id` | UUID диалога = `external_chat_id` Open Lines |
|
||||
| `message_id` | UUID сообщения; ключ idempotent delivery |
|
||||
| `attachment_id` | UUID вложения |
|
||||
| `bitrix_message_id` | ID сообщения в Bitrix для inbox dedup |
|
||||
| `task_id` | Async-проверка Message Safety |
|
||||
| `Idempotency-Key` | Клиентский ключ create dialog / send (Redis + durable fallback), TTL 24 ч |
|
||||
|
||||
### 5.6. Константы (`app_settings`)
|
||||
|
||||
| Ключ | Смысл | Seed |
|
||||
|---|---|---|
|
||||
| `chat.attachments.allowed_extensions` | Разрешённые расширения | jpg,jpeg,png,webp,heic,heif,pdf |
|
||||
| `chat.attachments.allowed_mime_types` | Разрешённые MIME | image/jpeg, image/png, …, application/pdf |
|
||||
| `chat.attachments.disallowed_extensions` | Явный deny-list | svg,doc,docx,xls,xlsx,csv |
|
||||
| `chat.attachments.max_size_mb` | Макс. размер | `5` |
|
||||
| `chat.attachments.storage` | Провайдер | `selectel_s3` |
|
||||
| `chat.attachments.upload_mode` | Режим | `presigned_put` |
|
||||
| `chat.attachments.safety_scan_required` | Safety обязателен | `true` |
|
||||
| `chat.attachments.presigned_upload_ttl_seconds` | TTL upload URL | `600` |
|
||||
| `rate_limit.message_send.per_user` | Лимит отправок | `30/minute` |
|
||||
| `rate_limit.message_send.per_dialog` | Лимит на диалог | `20/minute` |
|
||||
| `rate_limit.download_url.per_user` | Лимит download-url | `60/hour` |
|
||||
| `operator.call.phone` | Телефон кнопки «Оператор» | `+74999591007` |
|
||||
|
||||
Те же `chat.attachments.*` переиспользуются уведомлениями для документов клиента.
|
||||
|
||||
### 5.7. Популярные вопросы
|
||||
|
||||
- Справочник `popular_questions` отдаётся public content API.
|
||||
- Отдельного backend-flow нет: после auth текст уходит как обычное `content_kind=text`.
|
||||
- Без auth — тот же механизм отложенного сообщения, что у ручного ввода.
|
||||
|
||||
---
|
||||
|
||||
## 6. Поведение UI и сценарии
|
||||
|
||||
### 6.1. Создание / открытие диалога
|
||||
|
||||
1. Frontend перед первым `POST .../messages` вызывает `POST /api/v1/dialogs` с `Idempotency-Key`.
|
||||
2. Нет active → `201`, `status=open`.
|
||||
3. Есть active → `200`, возвращается существующий (новый не создаётся).
|
||||
4. Кнопка «Чат» открывает этот диалог (или создаёт при отсутствии).
|
||||
|
||||
### 6.2. Исходящее текстовое сообщение
|
||||
|
||||
1. `POST /dialogs` (если нет `dialog_id`).
|
||||
2. `POST .../messages` с `content_kind=text`, `Idempotency-Key`.
|
||||
3. Rate limits (nginx + app).
|
||||
4. Message Safety (текст, ссылки).
|
||||
5. Allow → outbox → Open Lines → `delivery_status=delivered`, `Dialog` → `waiting_for_company`.
|
||||
6. Deny → `422 message_blocked`, в Open Lines **не** уходит.
|
||||
7. Dependency failure → `503`/`504`, при уже созданном Message — `delivery_status=failed`.
|
||||
|
||||
### 6.3. Исходящий файл
|
||||
|
||||
1. `POST .../attachments/init` → presigned PUT в **S3-quarantine**.
|
||||
2. Frontend грузит байты **напрямую в S3** (не через api-backend).
|
||||
3. `POST .../attachments/{id}/complete` + checksum → HeadObject, metadata, `scan_status=pending`.
|
||||
4. `POST .../messages` с `content_kind=file`, `attachment_id`, `checksum`.
|
||||
5. Safety (файл); при `203 pending` api-backend sync-poll `task_id` внутри того же HTTP-запроса клиента.
|
||||
6. Allow → promote quarantine → S3-data attachments → delivery Open Lines (`message.files` signed URL, `message.text` пустой).
|
||||
7. Deny → quarantine delete, `blocked`/`rejected`.
|
||||
|
||||
Пока идёт poll safety, **это** клиентское соединение ждёт; параллельные запросы других клиентов не блокируются.
|
||||
|
||||
### 6.4. Отложенное сообщение (гость → после auth)
|
||||
|
||||
Общий frontend-механизм для:
|
||||
|
||||
- ручного ввода;
|
||||
- популярного вопроса;
|
||||
- CTA уведомлений с `send_chat_message` (после входа в контуре G).
|
||||
|
||||
Порядок:
|
||||
|
||||
1. Клиент инициирует отправку без JWT → согласия → OTP → `bootstrap` → `session-start`.
|
||||
2. Экран авторизации **завершается после успешного bootstrap**, даже если последующая отправка в чат упадёт.
|
||||
3. Затем `POST /dialogs` → `POST .../messages` с сохранённым текстом.
|
||||
4. Ошибка Bitrix/safety показывается **в контексте чата**, не как «не удалось завершить вход» (backlog п.21).
|
||||
|
||||
### 6.5. Входящее от оператора
|
||||
|
||||
1. Bitrix24 `ONIMCONNECTOR*` → `bitrix-local-app` (inbox, retry, DLQ).
|
||||
2. Forward в `POST /internal/openlines/v1/inbox` (`message.new`).
|
||||
3. api-backend: ownership по `external_chat_id`, save Message `company`/`allowed`/`delivered`, файлы → S3-data + `MessageAttachment`.
|
||||
4. Текст очищается от служебной разметки отправителя Bitrix (BBCode-префиксы имени и т.п.) — клиент видит чистый текст ответа.
|
||||
5. `Dialog.status` → `waiting_for_client`.
|
||||
6. Publish WS `message.new`; при недоступности WS — клиент подтянет через polling.
|
||||
7. Local app ack delivery в Bitrix после успешного apply / duplicate-ack.
|
||||
|
||||
Лимиты MIME/size для файлов оператора в MVP — те же `chat.attachments.*`.
|
||||
|
||||
### 6.6. Закрытие диалога
|
||||
|
||||
Inbox `dialog.closed` → `Dialog.status=closed`. Frontend получает `dialog.status` по WS или при следующем GET. Новый активный — только новым `POST /dialogs`.
|
||||
|
||||
### 6.7. Realtime и fallback
|
||||
|
||||
1. `WS /api/v1/realtime` + JWT.
|
||||
2. После `connected` — `subscribe` с `dialog_ids` (и опционально `notifications`).
|
||||
3. События чата: `message.new`, `message.status`, `dialog.status`.
|
||||
4. Reconnect: backoff 1s…30s; повтор `subscribe`.
|
||||
5. WS недоступен > 30s → polling `GET .../messages?after=<cursor>` (и notifications counter при подписке).
|
||||
6. Ping/pong ~30s.
|
||||
|
||||
### 6.8. Скачивание вложения
|
||||
|
||||
`GET .../attachments/{id}/download-url` → короткий presigned GET + audit `attachment.download_url_issued`. URL в логи/audit не пишется. Ownership обязателен.
|
||||
|
||||
---
|
||||
|
||||
## 7. Матрицы поведения
|
||||
|
||||
### 7.1. Исходящее: safety → delivery
|
||||
|
||||
| Вердикт safety | `safety_status` | `delivery_status` (финал клиенту) | Open Lines | HTTP клиенту |
|
||||
|---|---|---|---|---|
|
||||
| `200 allow` | `allowed` | `delivered` после успешной отправки; иначе `failed` | да (после allow) | `201` или `503`/`504` |
|
||||
| `403 deny` | `blocked` | `rejected` | нет | `422 message_blocked` |
|
||||
| `203 pending` → затем allow/deny | как финал | как финал | только после allow | финальный код после poll |
|
||||
| timeout / circuit open | по политике модуля | `failed` | нет | `503`/`504` |
|
||||
|
||||
Клиенту **не** отдаётся промежуточный `processing` как успешный ответ `POST .../messages`.
|
||||
|
||||
### 7.2. Идемпотентность клиента
|
||||
|
||||
| Ситуация | Результат |
|
||||
|---|---|
|
||||
| Тот же `Idempotency-Key` + то же тело | Тот же HTTP-ответ, без повторного side-effect |
|
||||
| Тот же ключ + другое тело | `409 idempotency_key_reused` |
|
||||
| Нет ключа на обязательном endpoint | `400 validation_error` |
|
||||
| TTL | 24 часа (Redis); durable fallback в `idempotency_records` |
|
||||
|
||||
Доставка в Open Lines идемпотентна по `message_id`. Inbox `message.new` — unique `(external_chat_id, bitrix_message_id)`.
|
||||
|
||||
### 7.3. Деградация зависимостей
|
||||
|
||||
| Зависимость | Влияние на чат |
|
||||
|---|---|
|
||||
| Redis DB0 | Write fail-closed; read ограниченно деградирует |
|
||||
| Redis DB1 (realtime) | REST работает; WS/publish деградирует → polling |
|
||||
| `message-safety` | Отправка недоступна; чтение истории работает |
|
||||
| `bitrix-local-app` / Bitrix | После allow сообщение может стать `failed`; recovery по outbox |
|
||||
| S3 | Файловые операции недоступны; текстовый чат продолжает работать |
|
||||
| Open Lines в readiness | Может быть `degraded`; не обязано валить весь API |
|
||||
|
||||
### 7.4. Ownership и ошибки доступа
|
||||
|
||||
| Ситуация | Код |
|
||||
|---|---|
|
||||
| Чужой `dialog_id` / `attachment_id` / `message` | `404` (существование не раскрывается) |
|
||||
| Нет/невалиден JWT | `401` |
|
||||
| Диалог `closed`, политика запрещает send | по контракту модуля (`409`/`422` — уточнить в OpenAPI при публикации) |
|
||||
| Safety deny | `422 message_blocked` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Идентификация и корреляция
|
||||
|
||||
- Публичные id — UUID.
|
||||
- `dialog_id` генерирует приложение при create и передаётся в Open Lines как `external_chat_id`.
|
||||
- При первой доставке `bitrix-local-app` создаёт/обновляет `dialog_sessions`.
|
||||
- `X-Request-ID`, `traceparent`, опционально `X-Ux-Session-Id` — для логов/audit; не auth.
|
||||
- Connector Bitrix: `han_mobile_app`, Open Line id по env/глоссарию.
|
||||
|
||||
---
|
||||
|
||||
## 9. API
|
||||
|
||||
Общие конвенции — arch-02: `/api/v1/*` JWT, `/api/v1/public/*`, `/internal/{mnemonic}/v1/*`; JSON snake_case; даты RFC 3339 UTC; cursors opaque.
|
||||
|
||||
### 9.1. Клиентские (JWT)
|
||||
|
||||
| Метод и путь | Назначение |
|
||||
|---|---|
|
||||
| `POST /api/v1/dialogs` | Find-or-create active dialog (`Idempotency-Key`) |
|
||||
| `GET /api/v1/dialogs` | Список/история диалогов (API есть; UI MVP — один чат) |
|
||||
| `GET /api/v1/dialogs/{dialog_id}` | Карточка диалога |
|
||||
| `GET /api/v1/dialogs/{dialog_id}/messages?after=&limit=` | История / polling |
|
||||
| `POST /api/v1/dialogs/{dialog_id}/messages` | Отправка (`Idempotency-Key` + safety) |
|
||||
| `POST .../attachments/init` | Presigned PUT quarantine |
|
||||
| `POST .../attachments/{id}/complete` | Подтверждение upload |
|
||||
| `GET .../attachments/{id}/download-url` | Presigned GET + audit |
|
||||
| `WS /api/v1/realtime` | События чата (и опционально уведомлений) |
|
||||
|
||||
### 9.2. Public
|
||||
|
||||
| Метод и путь | Назначение |
|
||||
|---|---|
|
||||
| `GET /api/v1/public/content` | Тексты + `popular_questions` |
|
||||
| `GET /api/v1/public/settings` | В т.ч. публичные флаги/лимиты UI при `is_public` |
|
||||
|
||||
### 9.3. Internal
|
||||
|
||||
| Метод и путь | Кто → кто | Назначение |
|
||||
|---|---|---|
|
||||
| `POST /internal/safety/v1/messages/check` | api-backend → message-safety | Проверка |
|
||||
| `GET /internal/safety/v1/messages/tasks/{task_id}` | api-backend → message-safety | Poll вердикта |
|
||||
| `POST /internal/openlines/v1/messages` | api-backend → bitrix-local-app | Исходящая доставка |
|
||||
| `GET /internal/openlines/v1/dialogs/{external_chat_id}` | api-backend → bitrix-local-app | Reconciliation |
|
||||
| `POST /internal/openlines/v1/inbox` | bitrix-local-app → api-backend | Входящие события |
|
||||
|
||||
### 9.4. Ошибки домена чата
|
||||
|
||||
| Код | HTTP | Когда |
|
||||
|---|---|---|
|
||||
| `validation_error` | 400 | Нет Idempotency-Key, невалидное тело |
|
||||
| `empty_message` | 400 | Нет текста и вложения |
|
||||
| `mixed_content_not_allowed` | 400 | Текст и файл вместе |
|
||||
| `too_many_attachments` | 400 | >1 вложение |
|
||||
| `attachment_not_completed` | 400 | Send до complete |
|
||||
| `attachment_checksum_mismatch` | 400 | Checksum не совпал |
|
||||
| `unauthorized` | 401 | JWT |
|
||||
| `message_blocked` | 422 | Safety deny |
|
||||
| `idempotency_key_reused` | 409 | Ключ с другим телом |
|
||||
| `resource_state_conflict` | 409 | Конфликт состояния (напр. complete с другим checksum) |
|
||||
| `rate_limit_exceeded` | 429 | Лимит |
|
||||
| `dependency_unavailable` | 503 | Circuit / недоступна зависимость |
|
||||
| `dependency_timeout` | 504 | Timeout зависимости |
|
||||
|
||||
### 9.5. DTO `MessageResponse` (REST и WS)
|
||||
|
||||
```json
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"dialog_id": "uuid",
|
||||
"sender_type": "client",
|
||||
"content_kind": "text",
|
||||
"text": "Здравствуйте",
|
||||
"attachments": [],
|
||||
"safety_status": "allowed",
|
||||
"delivery_status": "delivered",
|
||||
"created_at": "2026-07-09T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Сортировка сообщений: `created_at ASC` (append в ленте). Диалоги: `updated_at DESC`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Модель данных (схема `han_app`)
|
||||
|
||||
Общие правила — module-01 §9.1 / arch-05.
|
||||
|
||||
### 10.1. `dialogs`
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| `id` | = `dialog_id` = `external_chat_id` |
|
||||
| `user_id` | FK владельца |
|
||||
| `status` | enum §5.2 |
|
||||
| `last_message_at` | |
|
||||
| `closed_at` | при `closed` |
|
||||
| common fields | обязательны |
|
||||
|
||||
Partial unique: один active dialog на `user_id`.
|
||||
|
||||
### 10.2. `messages`
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| `id` | `message_id` |
|
||||
| `dialog_id` | FK |
|
||||
| `sender_type` | `client` \| `company` |
|
||||
| `content_kind` | `text` \| `file` |
|
||||
| `text` | непустой для text; `''` для file |
|
||||
| `safety_status` / `delivery_status` | §5.3 |
|
||||
| `external_message_id` | Bitrix id для inbound |
|
||||
| `client_idempotency_key` | опционально/связка с Idempotency-Key |
|
||||
| `occurred_at` | |
|
||||
| common fields | |
|
||||
|
||||
Индексы: лента `(dialog_id, created_at, id)`; recovery по `delivery_status`; unique inbound `(dialog_id, external_message_id)`.
|
||||
|
||||
**Решение:** исходящее создаётся до завершения safety со статусами `pending`/`accepted`, чтобы `safety_tasks` имел FK (crash checkpoint).
|
||||
|
||||
### 10.3. `message_attachments`
|
||||
|
||||
Поля: `id`, `dialog_id`, `message_id NULL` до привязки, `owner_user_id`, `direction`, имена/MIME/size/checksum, `scan_status`, storage keys (working + quarantine), timestamps, common fields.
|
||||
|
||||
MVP: unique partial — не более одного active attachment на `message_id`.
|
||||
|
||||
### 10.4. Технические таблицы
|
||||
|
||||
| Таблица | Назначение | Физическая очистка |
|
||||
|---|---|---|
|
||||
| `safety_tasks` | Poll/recovery Message Safety | да, по retention |
|
||||
| `delivery_outbox` | App → Open Lines | по статусам/retention модуля |
|
||||
| `openlines_inbox_receipts` | Dedup inbox | по политике модуля |
|
||||
| `idempotency_records` | Durable fallback Idempotency-Key | по `expires_at` |
|
||||
|
||||
### 10.5. Схема ключей S3 (чат)
|
||||
|
||||
```text
|
||||
quarantine/... # клиентский upload до вердикта
|
||||
attachments/... # проверенные вложения чата (client + company)
|
||||
```
|
||||
|
||||
Бакет documents (`han-chat-documents`) — для документов профиля/уведомлений, **не** hot path чата Open Lines.
|
||||
|
||||
---
|
||||
|
||||
## 11. Фоновые процессы
|
||||
|
||||
| Процесс | Владелец | Назначение |
|
||||
|---|---|---|
|
||||
| Safety recovery worker | api-backend | Доводит `203 pending` после обрыва клиентского HTTP |
|
||||
| Delivery outbox worker | api-backend | Retry доставки в local app / Open Lines |
|
||||
| Quarantine orphan cleanup | api-backend / ops | Удаляет просроченные объекты без active task/attachment |
|
||||
| Inbox retry / DLQ | bitrix-local-app | Надёжность webhook → API |
|
||||
| Realtime publish | api-backend + Redis DB1 | Fan-out WS; при сбое — polling |
|
||||
|
||||
Application-код **не** обходит outbox «в обход» для повторной доставки без idempotency.
|
||||
|
||||
---
|
||||
|
||||
## 12. Audit и observability
|
||||
|
||||
| `event_type` | Когда |
|
||||
|---|---|
|
||||
| `dialog.created` | Новый диалог |
|
||||
| `message.submitted` | Принято к обработке |
|
||||
| `message.blocked` | Safety deny |
|
||||
| `message.delivered` | Успех Open Lines / inbound saved |
|
||||
| `message.failed` | Инфраструктурный fail |
|
||||
| `attachment.upload_initialized` / `completed` / `promoted` / `rejected` | Lifecycle файла |
|
||||
| `attachment.download_url_issued` | Presigned GET |
|
||||
| `openlines.inbox_applied` | Входящее применено |
|
||||
|
||||
Метрики: latency send, safety poll duration/timeout, outbox depth/age/DLQ, inbox duplicate/apply, WS reconnect/drop, rate limit rejects. **Нельзя** использовать `user_id`/`dialog_id` как metric labels.
|
||||
|
||||
В логах нет: тел сообщений, tokens, presigned URL, Bitrix download URL, полного PII.
|
||||
|
||||
---
|
||||
|
||||
## 13. Хранение
|
||||
|
||||
- `Dialog` / `Message` / `MessageAttachment` — прикладные строки, soft-delete, физическое удаление запрещено.
|
||||
- Закрытые диалоги и их сообщения остаются в БД (история API); UI MVP показывает текущий чат.
|
||||
- Quarantine и технические checkpoint — исключения с retention.
|
||||
- S3-data attachments живут с записью вложения; lifecycle бакетов — ops-политика.
|
||||
|
||||
---
|
||||
|
||||
## 14. Смежные сервисы
|
||||
|
||||
| Компонент | Роль в чате |
|
||||
|---|---|
|
||||
| **Frontend** | UI чата, отложенное сообщение, upload, WS/polling, кнопки Чат/Оператор |
|
||||
| **api-backend** | Dialogs/messages/attachments, safety orchestration, outbox, inbox apply, realtime |
|
||||
| **message-safety** | Вердикт allow/deny/pending по исходящим |
|
||||
| **bitrix-local-app** | Connector Open Lines, исходящие/входящие, `dialog_sessions` |
|
||||
| **Bitrix24 Open Lines** | Рабочее место оператора |
|
||||
| **S3** | Quarantine + attachments |
|
||||
| **Redis** | Rate limit, idempotency cache, realtime coordination |
|
||||
| **nginx** | `/api/*` + WS upgrade; `proxy_read_timeout` ≥ safety poll budget + запас |
|
||||
| **bitrix-sync** | **Не** в hot path чата |
|
||||
|
||||
Порядок работ (если поднимать домен с нуля): (1) dialogs + messages text path + idempotency → (2) safety orchestration → (3) Open Lines out + inbox → (4) attachments → (5) WS + polling → (6) recovery/outbox hardening.
|
||||
|
||||
---
|
||||
|
||||
## 15. Влияние на arch-документы
|
||||
|
||||
| Документ | Содержание по чату |
|
||||
|---|---|
|
||||
| `arch-00-glossary.md` | `Dialog`, `Message`, статусы, Open Lines terms |
|
||||
| `arch-01-system-architecture.md` | Потоки C→O и O→C, создание диалога |
|
||||
| `arch-02-api-contracts.md` | REST/WS/safety/openlines контракты |
|
||||
| `arch-03-docker-compose-blueprint.md` | Сервисы, WS location, timeouts |
|
||||
| `arch-04-settings-and-content.md` | `chat.attachments.*`, rate limits, operator phone |
|
||||
| `module-01-api-backend.md` | Алгоритмы, таблицы, workers |
|
||||
| `module-05-message-safety.md` | Stub/production safety |
|
||||
| `module-06-bitrix-local-app.md` | Connector / inbox / out |
|
||||
|
||||
Этот документ собирает бизнес-смысл обмена сообщениями для аналитики и смежных фич; детальные алгоритмы — в module-спеках.
|
||||
|
||||
---
|
||||
|
||||
## 16. Критерии приёмки
|
||||
|
||||
1. Без JWT отправить сообщение нельзя; после auth отложенный текст/популярный вопрос уходит штатным `POST /dialogs` → `POST .../messages`.
|
||||
2. Не более одного active dialog; повторный `POST /dialogs` возвращает существующий.
|
||||
3. Text и file взаимоисключающи; mixed/empty/too many → соответствующие `400`.
|
||||
4. `POST .../messages` возвращает только финальный статус; deny → `422 message_blocked` без доставки в Bitrix.
|
||||
5. Allow → сообщение видно оператору в Open Lines; `Dialog` → `waiting_for_company`.
|
||||
6. Ответ оператора появляется в клиенте через WS или polling; `Dialog` → `waiting_for_client`; текст без служебной разметки имени из Bitrix.
|
||||
7. `dialog.closed` закрывает диалог; из `closed` нельзя вернуться тем же id.
|
||||
8. Файлы: quarantine → safety → promote; deny чистит quarantine; download только presigned + audit.
|
||||
9. Идемпотентность create/send соблюдается 24 ч; повтор delivery/inbox не плодит дубли.
|
||||
10. При падении WS > 30s клиент уходит в polling и не теряет сообщения, уже лежащие в App DB.
|
||||
11. Недоступность safety блокирует send, но не чтение истории; недоступность S3 не ломает text-only чат.
|
||||
12. Ошибка отправки после успешного bootstrap не выглядит как ошибка входа.
|
||||
13. Кнопка «Оператор» берёт номер только из `operator.call.phone`.
|
||||
14. Популярный вопрос не имеет отдельного API — только text message.
|
||||
15. Ownership: чужие dialog/attachment → `404`.
|
||||
|
||||
---
|
||||
|
||||
## 17. Журнал решений и открытых вопросов (ненормативно)
|
||||
|
||||
### 17.1. Принятые решения
|
||||
|
||||
| # | Решение | Раздел / источник |
|
||||
|---|---|---|
|
||||
| D1 | Чат ≠ уведомления; непрочитанный чат не есть Notification | §1, notification-requirements |
|
||||
| D2 | Один active dialog; `dialog_id` = `external_chat_id` | §2, arch-01 |
|
||||
| D3 | MVP content: text XOR file | §5.3, arch-02 |
|
||||
| D4 | Клиент ждёт финальный вердикт на одном HTTP; poll safety внутри api-backend | §6.2–§6.3 |
|
||||
| D5 | Исходящие проходят Message Safety; входящие оператора — доверенный канал | §2, arch-02 |
|
||||
| D6 | Durable outbox + idempotent delivery; inbox dedup | §6, module-01 |
|
||||
| D7 | WS обязателен как primary realtime; polling — fallback | §6.7 |
|
||||
| D8 | UI истории диалогов deprecated; API списка сохраняется | §3.2 |
|
||||
| D9 | Отложенное сообщение — общий frontend-механизм | §6.4 |
|
||||
| D10 | Популярный вопрос = обычный text send | §5.7 |
|
||||
| D11 | Presigned upload напрямую в S3; api-backend не проксирует байты | §6.3 |
|
||||
| D12 | `chat.attachments.*` — единые лимиты для чата (и reuse уведомлениями) | §5.6 |
|
||||
|
||||
### 17.2. Открытые вопросы
|
||||
|
||||
| # | Вопрос | Предложение | Влияние |
|
||||
|---|---|---|---|
|
||||
| Q1 | Индикатор непрочитанных сообщений чата + sync между устройствами (backlog 23–24) | Поле вроде `Dialog.client_last_opened_at` / `last_read_message_id` + WS/REST counter; **не** тип уведомления `message` | Новая мини-постановка |
|
||||
| Q2 | Точный HTTP-код send в уже `closed` dialog | Зафиксировать в OpenAPI (`409 resource_state_conflict` или `422`) | Клиентский UX |
|
||||
| Q3 | Политика показа blocked-сообщения в ленте (полный текст vs redacted) | Минимизация PII в хранении blocked (M8 module-01) + нейтральный UI | DB + frontend |
|
||||
| Q4 | Создание нового dialog сразу после `closed` — всегда разрешено или по бизнес-правилу «сессия поддержки» | MVP: разрешить, пока соблюдён unique active | Продукт / поддержка |
|
||||
| Q5 | Stub `message-safety` с `400` на GET task vs канон arch-02 `403` | Для prod — только канон `200/203/403`; stub не расширяет публичный контракт | module-05 / contract tests |
|
||||
| Q6 | Смешанный content text+files | Отдельный API version post-MVP | arch-02 breaking |
|
||||
| Q7 | Unread badge на кнопке «Чат» vs бейдж колокольчика уведомлений | Развести визуально и в данных | UI + Q1 |
|
||||
|
||||
---
|
||||
|
||||
## 18. Связь со смежными доменами
|
||||
|
||||
| Домен | Связь |
|
||||
|---|---|
|
||||
| **Пользователь** | Чат только после JWT + bootstrap; отложенное сообщение после входа |
|
||||
| **Уведомления** | CTA `send_chat_message` пишет в чат; кнопки Чат/Оператор на главной; тип `message` в уведомлениях **запрещён** |
|
||||
| **Документы профиля** | Другой бакет/реестр; не заменяют вложения чата |
|
||||
| **CRM (`bitrix-sync`)** | Contact map по телефону параллельно; не доставляет сообщения Open Lines |
|
||||
|
||||
Владелец ленты и статусов доставки — `api-backend` + App DB; рабочее место оператора — Bitrix24 Open Lines через `bitrix-local-app`.
|
||||
@@ -58,7 +58,14 @@ Bootstrap:
|
||||
3. Выпустить certificate без остановки nginx.
|
||||
4. Проверить `nginx -t`, атомарно активировать TLS config, reload.
|
||||
|
||||
Renew container/host timer выполняет `certbot renew` минимум дважды в сутки; после фактического renewal — `nginx -s reload`. Reload допускается только после `nginx -t`; при ошибке остаётся старый worker/config/cert и срабатывает alert. Контролируются expiry days и последняя успешная попытка. Staging CA используется в rehearsal, чтобы не исчерпать лимиты.
|
||||
Renew container/host timer выполняет `certbot renew` минимум дважды в сутки;
|
||||
после фактического renewal проверяет рабочую конфигурацию командой
|
||||
`docker compose exec -T nginx nginx -t -c /tmp/nginx.conf` и отправляет
|
||||
master-процессу `docker compose kill -s HUP nginx`. Bare-команды `nginx -t` и
|
||||
`nginx -s reload` запрещены: контейнер read-only, а рабочие config/PID находятся
|
||||
в `/tmp`. При ошибке остаётся старый worker/config/cert и срабатывает alert.
|
||||
Контролируются expiry days и последняя успешная попытка. Staging CA используется
|
||||
в rehearsal, чтобы не исчерпать лимиты.
|
||||
|
||||
## 6. Request ID и forwarded headers
|
||||
|
||||
|
||||
@@ -541,7 +541,8 @@ Legal retention/erasure имеет приоритет; изменение тре
|
||||
|
||||
### Решения
|
||||
|
||||
- O1: обязательный архитектурный минимум — Collector; удалённый управляемый OTLP backend является предпочтительным operable-вариантом и остаётся TBD до выбора провайдера.
|
||||
- O1: обязательный архитектурный минимум — Collector; выбран self-hosted SigNoz
|
||||
на отдельной VM `192.168.0.5`, доступный по приватному OTLP gRPC.
|
||||
- O2: Prometheus/Grafana/Loki/Tempo — отдельный operable profile, не скрытая обязательная нагрузка основной VM.
|
||||
- O3: JSON stdout — аварийный локальный buffer; audit App DB — durable.
|
||||
- O4: telemetry fail-open для business path, но потеря telemetry alertится.
|
||||
@@ -549,7 +550,8 @@ Legal retention/erasure имеет приоритет; изменение тре
|
||||
|
||||
### TBD
|
||||
|
||||
- O-TBD1: выбрать remote backend/provider, endpoint/auth и стоимость.
|
||||
- O-TBD1 закрыт: self-hosted SigNoz, `192.168.0.5:4317`, plaintext только
|
||||
внутри доверенной приватной сети; UI через SSH jump host.
|
||||
- O-TBD2: утвердить SLO/RPS/error-budget с product owner.
|
||||
- O-TBD3: legal retention/erasure и допустимость IP/user-agent.
|
||||
- O-TBD4: точные sampling и resource limits после load test.
|
||||
@@ -562,7 +564,9 @@ Legal retention/erasure имеет приоритет; изменение тре
|
||||
2. `module-05` использует test-only terminal `400` и non-sticky verdict вместо canonical `403`/sticky production verdict. Dashboards обязаны маркировать сервис `stub`; production SLO Safety на нём недостоверен.
|
||||
3. `module-07` — только DB connectivity stub, тогда как arch-01/02 описывают полноценную CRM sync. Dashboard не должен показывать queue/CRM SLI, которых нет.
|
||||
4. Retention, RPO/RTO и production SLO открыты в module-01/04/06/08; значения этого документа являются initial ops policy, не закрывают legal/product TBD.
|
||||
5. Новые observability env (`OTEL_REMOTE_*`, sampling/queue limits) отсутствуют в arch-04; перед реализацией production `.env.example` их нужно добавить туда.
|
||||
5. Observability env (`OTEL_REMOTE_*`, service names, sampling/queue limits)
|
||||
добавлены в arch-04 и `.env.example`; sampling/queue limits уточняются после
|
||||
load test.
|
||||
|
||||
## 19. Ссылки на прототип и исходные документы
|
||||
|
||||
|
||||
@@ -544,9 +544,9 @@ docker compose --profile certbot run --rm certbot certonly \
|
||||
|
||||
```bash
|
||||
cd <BACKEND_ROOT>
|
||||
docker compose exec -T nginx nginx -t
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
# активировать rendered TLS config атомарно
|
||||
docker compose exec -T nginx nginx -s reload
|
||||
docker compose kill -s HUP nginx
|
||||
```
|
||||
|
||||
HSTS пока не включать. Проверить chain/hostname/redirect, затем включить HSTS без preload.
|
||||
@@ -559,8 +559,8 @@ HSTS пока не включать. Проверить chain/hostname/redirect,
|
||||
|
||||
1. взять `flock`, чтобы исключить параллельные запуски;
|
||||
2. выполнить `docker compose --profile certbot run --rm certbot renew --webroot -w /var/www/certbot --quiet`;
|
||||
3. при успешном обновлении проверить `docker compose exec -T nginx nginx -t`;
|
||||
4. только после успешной проверки выполнить `docker compose exec -T nginx nginx -s reload`;
|
||||
3. при успешном обновлении проверить `docker compose exec -T nginx nginx -t -c /tmp/nginx.conf`;
|
||||
4. только после успешной проверки выполнить `docker compose kill -s HUP nginx`;
|
||||
5. записать структурированный результат и метрику времени до истечения;
|
||||
6. вернуть ненулевой exit code при ошибке, чтобы сработал alert;
|
||||
7. не удалять действующий сертификат при неуспешном renew.
|
||||
@@ -761,9 +761,18 @@ docker compose up -d keycloak
|
||||
docker compose up -d message-safety
|
||||
docker compose up -d bitrix-local-app bitrix-sync
|
||||
docker compose up -d nginx
|
||||
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose kill -s HUP nginx
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
При повторной раскатке reload после readiness upstream обязателен: nginx
|
||||
разрешает Docker DNS при загрузке конфигурации и иначе может продолжить
|
||||
обращаться к старому container IP. Bare-команды `nginx -t` и
|
||||
`nginx -s reload` не использовать: рабочий config/PID находятся в `/tmp`, а
|
||||
filesystem контейнера read-only.
|
||||
|
||||
После каждого шага ждать health, но проверять readiness отдельно из internal network:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -71,6 +71,7 @@ docker compose --env-file .env up -d \
|
||||
--force-recreate frontend-static keycloak
|
||||
|
||||
Посмотреть состояние контейнеров
|
||||
docker compose ps --format "table {{.Service}}\t{{.Status}}"
|
||||
docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
# Архивный способ копирования:
|
||||
@@ -456,6 +457,15 @@ systemctl status han-chat-ssl-renew.timer
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
docker compose --env-file .env up -d
|
||||
docker compose --env-file .env up -d --wait \
|
||||
api-backend keycloak sms-service bitrix-local-app
|
||||
|
||||
# Nginx разрешает имена upstream при загрузке конфигурации. Если upstream
|
||||
# был пересоздан и получил новый Docker IP, без reload edge продолжит ходить
|
||||
# на старый адрес и вернёт 502.
|
||||
docker compose --env-file .env exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose --env-file .env kill -s HUP nginx
|
||||
|
||||
docker compose --env-file .env ps
|
||||
```
|
||||
|
||||
@@ -485,6 +495,12 @@ curl -fsS \
|
||||
https://chat.han0107.ru/auth/realms/han-chat/.well-known/openid-configuration | jq
|
||||
```
|
||||
|
||||
Если после `up`, `build`, `pull`, rollback или `--force-recreate` менялись
|
||||
`api-backend`, `keycloak`, `sms-service` либо `bitrix-local-app`, всегда
|
||||
повторяйте проверку `/tmp/nginx.conf` и `docker compose kill -s HUP nginx`.
|
||||
Команды `nginx -t`/`nginx -s reload` без `-c /tmp/nginx.conf` здесь неверны:
|
||||
контейнер read-only и рабочий PID расположен в `/tmp/nginx.pid`.
|
||||
|
||||
Внутренний API не должен быть опубликован:
|
||||
|
||||
```sh
|
||||
|
||||
Reference in New Issue
Block a user