From 41e19005fbd1d46a63e42fc23f5c335b32b5e3da Mon Sep 17 00:00:00 2001 From: mi Date: Wed, 29 Jul 2026 15:15:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20OTLP-=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5?= =?UTF-8?q?=D1=80,=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=20=D0=BE=D1=82=D0=B1=D1=80=D0=B0=D1=81=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=20=D0=B8=20=D1=82=D1=80=D0=B5=D0=B9=D1=81=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20observability=20+=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=20nginx=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B5=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=BE=D0=B2=20(?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0,=20=D0=BA=D0=BE=D0=B3?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=B4=D0=BE=D0=BA=D0=B5=D1=80=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8F=D0=B5=D1=82=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architectory/arch-04-settings-and-content.md | 9 + backlog.md | 98 ++- codebase/Signoz/README.md | 146 +++++ codebase/Signoz/casting.yaml | 20 + codebase/Signoz/docs/BACKEND_OTLP.md | 362 ++++++++++ codebase/Signoz/docs/MVP_DASHBOARDS_ALERTS.md | 89 +++ codebase/Signoz/docs/NETWORK.md | 107 +++ codebase/Signoz/docs/SIGNOZ_RUNBOOK.md | 144 ++++ codebase/Signoz/scripts/00-check-vm.sh | 68 ++ .../scripts/05-configure-private-network.sh | 51 ++ codebase/Signoz/scripts/10-install-docker.sh | 47 ++ codebase/Signoz/scripts/20-deploy-signoz.sh | 72 ++ codebase/Signoz/scripts/30-verify-signoz.sh | 72 ++ .../Signoz/scripts/40-configure-firewall.sh | 44 ++ codebase/backend/.env.example | 12 +- codebase/backend/api-backend/app/main.py | 55 +- codebase/backend/api-backend/app/metrics.py | 21 + codebase/backend/api-backend/app/telemetry.py | 111 ++++ codebase/backend/api-backend/pyproject.toml | 8 + .../api-backend/tests/unit/test_telemetry.py | 25 + .../backend/deployment/DEPLOYMENT_GUIDE.ru.md | 27 + codebase/backend/deployment/RUNBOOK.md | 10 + codebase/backend/deployment/RUNBOOK.ru.md | 10 + .../seed-personal-notifications-test.sh | 445 +++++++++++++ .../backend/deployment/scripts/ssl-renew.sh | 2 +- .../scripts/verify-observability.sh | 62 ++ .../src/components/NotificationCarousel.tsx | 24 +- .../src/components/QuickActions.tsx | 4 +- .../backend/infra/compose/application.yml | 12 + .../backend/observability/docker-compose.yml | 5 +- .../backend/observability/otel-collector.yaml | 53 +- codebase/backend/sms-service/app/main.py | 16 +- codebase/backend/sms-service/app/settings.py | 1 + codebase/backend/sms-service/app/telemetry.py | 102 +++ codebase/backend/sms-service/app/worker.py | 40 +- codebase/backend/sms-service/pyproject.toml | 6 + .../sms-service/tests/unit/test_telemetry.py | 21 + codebase/backend/tests/test_config.py | 29 + .../chat-requirements.md | 617 ++++++++++++++++++ modules/module-03-nginx.md | 9 +- modules/module-09-observability.md | 10 +- modules/module-10-deployment-runbook.md | 17 +- releases/#0 deploy-steps.md | 16 + 43 files changed, 3016 insertions(+), 83 deletions(-) create mode 100644 codebase/Signoz/README.md create mode 100644 codebase/Signoz/casting.yaml create mode 100644 codebase/Signoz/docs/BACKEND_OTLP.md create mode 100644 codebase/Signoz/docs/MVP_DASHBOARDS_ALERTS.md create mode 100644 codebase/Signoz/docs/NETWORK.md create mode 100644 codebase/Signoz/docs/SIGNOZ_RUNBOOK.md create mode 100644 codebase/Signoz/scripts/00-check-vm.sh create mode 100644 codebase/Signoz/scripts/05-configure-private-network.sh create mode 100644 codebase/Signoz/scripts/10-install-docker.sh create mode 100644 codebase/Signoz/scripts/20-deploy-signoz.sh create mode 100644 codebase/Signoz/scripts/30-verify-signoz.sh create mode 100644 codebase/Signoz/scripts/40-configure-firewall.sh create mode 100644 codebase/backend/api-backend/app/metrics.py create mode 100644 codebase/backend/api-backend/app/telemetry.py create mode 100644 codebase/backend/api-backend/tests/unit/test_telemetry.py create mode 100644 codebase/backend/deployment/scripts/seed-personal-notifications-test.sh create mode 100644 codebase/backend/deployment/scripts/verify-observability.sh create mode 100644 codebase/backend/sms-service/app/telemetry.py create mode 100644 codebase/backend/sms-service/tests/unit/test_telemetry.py create mode 100644 functional_blocks (business logic)/chat-requirements.md diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md index 45bc6cf..71de07b 100644 --- a/architectory/arch-04-settings-and-content.md +++ b/architectory/arch-04-settings-and-content.md @@ -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 diff --git a/backlog.md b/backlog.md index b64a6e9..77aa40a 100644 --- a/backlog.md +++ b/backlog.md @@ -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) \ No newline at end of file +debounce на отправку СМС (сейчас есть Фиксированный cooldownmin_seconds_between_attempts) + +# Критично для релиза: +1. Разработка message-safety +2. Разработка sync-service +3. Пользовательское соглашение +4. Разработка notification-service +5. Подключить OTLP-провайдер +6. Починить баги +7. Второй контур для продакшн \ No newline at end of file diff --git a/codebase/Signoz/README.md b/codebase/Signoz/README.md new file mode 100644 index 0000000..be0c6b9 --- /dev/null +++ b/codebase/Signoz/README.md @@ -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. diff --git a/codebase/Signoz/casting.yaml b/codebase/Signoz/casting.yaml new file mode 100644 index 0000000..38e33be --- /dev/null +++ b/codebase/Signoz/casting.yaml @@ -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" diff --git a/codebase/Signoz/docs/BACKEND_OTLP.md b/codebase/Signoz/docs/BACKEND_OTLP.md new file mode 100644 index 0000000..21f9c82 --- /dev/null +++ b/codebase/Signoz/docs/BACKEND_OTLP.md @@ -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://' +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. diff --git a/codebase/Signoz/docs/MVP_DASHBOARDS_ALERTS.md b/codebase/Signoz/docs/MVP_DASHBOARDS_ALERTS.md new file mode 100644 index 0000000..4003c03 --- /dev/null +++ b/codebase/Signoz/docs/MVP_DASHBOARDS_ALERTS.md @@ -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 сохранён рядом с этим документом. diff --git a/codebase/Signoz/docs/NETWORK.md b/codebase/Signoz/docs/NETWORK.md new file mode 100644 index 0000000..eec85e5 --- /dev/null +++ b/codebase/Signoz/docs/NETWORK.md @@ -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@ ` + -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. В облачной группе безопасности нет публичного доступа к служебным портам. diff --git a/codebase/Signoz/docs/SIGNOZ_RUNBOOK.md b/codebase/Signoz/docs/SIGNOZ_RUNBOOK.md new file mode 100644 index 0000000..ab9c0ac --- /dev/null +++ b/codebase/Signoz/docs/SIGNOZ_RUNBOOK.md @@ -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. diff --git a/codebase/Signoz/scripts/00-check-vm.sh b/codebase/Signoz/scripts/00-check-vm.sh new file mode 100644 index 0000000..133ed6c --- /dev/null +++ b/codebase/Signoz/scripts/00-check-vm.sh @@ -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 "Проверка завершена успешно." diff --git a/codebase/Signoz/scripts/05-configure-private-network.sh b/codebase/Signoz/scripts/05-configure-private-network.sh new file mode 100644 index 0000000..a334191 --- /dev/null +++ b/codebase/Signoz/scripts/05-configure-private-network.sh @@ -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" <&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 </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 diff --git a/codebase/Signoz/scripts/20-deploy-signoz.sh b/codebase/Signoz/scripts/20-deploy-signoz.sh new file mode 100644 index 0000000..25ed183 --- /dev/null +++ b/codebase/Signoz/scripts/20-deploy-signoz.sh @@ -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 diff --git a/codebase/Signoz/scripts/30-verify-signoz.sh b/codebase/Signoz/scripts/30-verify-signoz.sh new file mode 100644 index 0000000..f41f085 --- /dev/null +++ b/codebase/Signoz/scripts/30-verify-signoz.sh @@ -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 готов." diff --git a/codebase/Signoz/scripts/40-configure-firewall.sh b/codebase/Signoz/scripts/40-configure-firewall.sh new file mode 100644 index 0000000..46089cd --- /dev/null +++ b/codebase/Signoz/scripts/40-configure-firewall.sh @@ -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 diff --git a/codebase/backend/.env.example b/codebase/backend/.env.example index 2806737..dabe926 100644 --- a/codebase/backend/.env.example +++ b/codebase/backend/.env.example @@ -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 diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py index 29ace2f..f40edc6 100644 --- a/codebase/backend/api-backend/app/main.py +++ b/codebase/backend/api-backend/app/main.py @@ -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( diff --git a/codebase/backend/api-backend/app/metrics.py b/codebase/backend/api-backend/app/metrics.py new file mode 100644 index 0000000..a6bf012 --- /dev/null +++ b/codebase/backend/api-backend/app/metrics.py @@ -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", +) diff --git a/codebase/backend/api-backend/app/telemetry.py b/codebase/backend/api-backend/app/telemetry.py new file mode 100644 index 0000000..c633998 --- /dev/null +++ b/codebase/backend/api-backend/app/telemetry.py @@ -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 diff --git a/codebase/backend/api-backend/pyproject.toml b/codebase/backend/api-backend/pyproject.toml index 166a292..8fc8aee 100644 --- a/codebase/backend/api-backend/pyproject.toml +++ b/codebase/backend/api-backend/pyproject.toml @@ -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", diff --git a/codebase/backend/api-backend/tests/unit/test_telemetry.py b/codebase/backend/api-backend/tests/unit/test_telemetry.py new file mode 100644 index 0000000..432fc8b --- /dev/null +++ b/codebase/backend/api-backend/tests/unit/test_telemetry.py @@ -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"} diff --git a/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md b/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md index ab52269..41f7238 100644 --- a/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md +++ b/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md @@ -535,6 +535,33 @@ docker compose --env-file .env logs --tail=200 docker inspect "$(docker compose --env-file .env ps -q )" ``` +### 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` оставьте diff --git a/codebase/backend/deployment/RUNBOOK.md b/codebase/backend/deployment/RUNBOOK.md index b6253e1..de2c397 100644 --- a/codebase/backend/deployment/RUNBOOK.md +++ b/codebase/backend/deployment/RUNBOOK.md @@ -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. diff --git a/codebase/backend/deployment/RUNBOOK.ru.md b/codebase/backend/deployment/RUNBOOK.ru.md index 806902b..b73e9b7 100644 --- a/codebase/backend/deployment/RUNBOOK.ru.md +++ b/codebase/backend/deployment/RUNBOOK.ru.md @@ -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 работает как заглушка. diff --git a/codebase/backend/deployment/scripts/seed-personal-notifications-test.sh b/codebase/backend/deployment/scripts/seed-personal-notifications-test.sh new file mode 100644 index 0000000..27df693 --- /dev/null +++ b/codebase/backend/deployment/scripts/seed-personal-notifications-test.sh @@ -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<>endobj +2 0 obj<>endobj +3 0 obj<>endobj +xref +0 4 +trailer<> +startxref +100 +%%EOF +""", + }, + { + "prefix": "RESULTS", + "title": "Справка о соблюдении миграционного законодательства.pdf", + "body": b"""%PDF-1.4 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>endobj +xref +0 4 +trailer<> +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 <&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 diff --git a/codebase/backend/frontend-test-site/src/components/NotificationCarousel.tsx b/codebase/backend/frontend-test-site/src/components/NotificationCarousel.tsx index ad583c9..0898a0e 100644 --- a/codebase/backend/frontend-test-site/src/components/NotificationCarousel.tsx +++ b/codebase/backend/frontend-test-site/src/components/NotificationCarousel.tsx @@ -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>(() => 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={() => } 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 }) => ( (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}`); diff --git a/codebase/backend/infra/compose/application.yml b/codebase/backend/infra/compose/application.yml index 6203a36..1694b30 100644 --- a/codebase/backend/infra/compose/application.yml +++ b/codebase/backend/infra/compose/application.yml @@ -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} diff --git a/codebase/backend/observability/docker-compose.yml b/codebase/backend/observability/docker-compose.yml index 565f0eb..2bc5a20 100644 --- a/codebase/backend/observability/docker-compose.yml +++ b/codebase/backend/observability/docker-compose.yml @@ -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: diff --git a/codebase/backend/observability/otel-collector.yaml b/codebase/backend/observability/otel-collector.yaml index 452fcf9..41a2b2c 100644 --- a/codebase/backend/observability/otel-collector.yaml +++ b/codebase/backend/observability/otel-collector.yaml @@ -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] diff --git a/codebase/backend/sms-service/app/main.py b/codebase/backend/sms-service/app/main.py index 6ed74bc..b2411ac 100644 --- a/codebase/backend/sms-service/app/main.py +++ b/codebase/backend/sms-service/app/main.py @@ -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( diff --git a/codebase/backend/sms-service/app/settings.py b/codebase/backend/sms-service/app/settings.py index 390bf49..99767fb 100644 --- a/codebase/backend/sms-service/app/settings.py +++ b/codebase/backend/sms-service/app/settings.py @@ -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", diff --git a/codebase/backend/sms-service/app/telemetry.py b/codebase/backend/sms-service/app/telemetry.py new file mode 100644 index 0000000..fbc0bde --- /dev/null +++ b/codebase/backend/sms-service/app/telemetry.py @@ -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 diff --git a/codebase/backend/sms-service/app/worker.py b/codebase/backend/sms-service/app/worker.py index afe7360..ef888cc 100644 --- a/codebase/backend/sms-service/app/worker.py +++ b/codebase/backend/sms-service/app/worker.py @@ -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: diff --git a/codebase/backend/sms-service/pyproject.toml b/codebase/backend/sms-service/pyproject.toml index 740124a..40b6e9d 100644 --- a/codebase/backend/sms-service/pyproject.toml +++ b/codebase/backend/sms-service/pyproject.toml @@ -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", diff --git a/codebase/backend/sms-service/tests/unit/test_telemetry.py b/codebase/backend/sms-service/tests/unit/test_telemetry.py new file mode 100644 index 0000000..c597598 --- /dev/null +++ b/codebase/backend/sms-service/tests/unit/test_telemetry.py @@ -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 diff --git a/codebase/backend/tests/test_config.py b/codebase/backend/tests/test_config.py index fc62339..f5d150a 100644 --- a/codebase/backend/tests/test_config.py +++ b/codebase/backend/tests/test_config.py @@ -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" diff --git a/functional_blocks (business logic)/chat-requirements.md b/functional_blocks (business logic)/chat-requirements.md new file mode 100644 index 0000000..c3ba335 --- /dev/null +++ b/functional_blocks (business logic)/chat-requirements.md @@ -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=` (и 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`. diff --git a/modules/module-03-nginx.md b/modules/module-03-nginx.md index b692302..df51c14 100644 --- a/modules/module-03-nginx.md +++ b/modules/module-03-nginx.md @@ -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 diff --git a/modules/module-09-observability.md b/modules/module-09-observability.md index 821a4ed..e8fd100 100644 --- a/modules/module-09-observability.md +++ b/modules/module-09-observability.md @@ -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. Ссылки на прототип и исходные документы diff --git a/modules/module-10-deployment-runbook.md b/modules/module-10-deployment-runbook.md index d55fc57..3439b6e 100644 --- a/modules/module-10-deployment-runbook.md +++ b/modules/module-10-deployment-runbook.md @@ -544,9 +544,9 @@ docker compose --profile certbot run --rm certbot certonly \ ```bash cd -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 diff --git a/releases/#0 deploy-steps.md b/releases/#0 deploy-steps.md index 9cfe6bf..bb5325c 100644 --- a/releases/#0 deploy-steps.md +++ b/releases/#0 deploy-steps.md @@ -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