diff --git a/deploy/compose.dev.yml b/deploy/compose.dev.yml index ece12ff..b4403eb 100644 --- a/deploy/compose.dev.yml +++ b/deploy/compose.dev.yml @@ -108,6 +108,9 @@ services: Storage__Minio__SecretKey: deal_minio_secret Storage__Minio__Bucket: deal-files Storage__Minio__Secure: "false" + # Трейсинг OTel → коллектор профиля observability (без коллектора трейсы не экспортируются). + OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-} + OTEL_SERVICE_NAME: core ports: - "5080:5080" - "5082:5082" # gRPC-ингресс telegram-service (сервисы ходят на http://core:5082 внутри сети) @@ -146,6 +149,8 @@ services: DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:-ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=} DEAL_TELEGRAM_SESSION_DIR: /data/sessions DEAL_LOGS_DIR: /tmp/logs + OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-} + OTEL_SERVICE_NAME: telegram-service SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082} ports: - "5101:5101" @@ -176,6 +181,8 @@ services: GRPC_PORT: "5102" DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} DEAL_LOGS_DIR: /tmp/logs + OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-} + OTEL_SERVICE_NAME: ai-service ports: - "5102:5102" healthcheck: @@ -203,6 +210,8 @@ services: DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:-deal_dev_service_token} DEAL_ML_DATA_DIR: /data/ml # файлы моделей data/ml/.sqlite на volume deal_ml_data (Ruling 4/12) DEAL_LOGS_DIR: /tmp/logs + OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-} + OTEL_SERVICE_NAME: ml-service ports: - "5103:5103" volumes: @@ -233,6 +242,8 @@ services: DEAL_STORAGE_BUCKET: deal-attachments DEAL_STORAGE_SECURE: "false" DEAL_LOGS_DIR: /tmp/logs + OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-} + OTEL_SERVICE_NAME: storage-service ports: - "5104:5104" depends_on: @@ -261,6 +272,66 @@ services: - ./observability/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro - deal_prometheus_data:/prometheus + # OpenTelemetry Collector — приём трейсов Deal-процессов (OTLP) → Tempo (профиль observability). + otel-collector: + image: otel/opentelemetry-collector-contrib:0.160.0 + container_name: deal-otel-collector + profiles: ["observability"] + command: ["--config=/etc/otelcol-contrib/config.yaml"] + ports: + - "4317:4317" + - "4318:4318" + volumes: + - ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro + depends_on: + tempo: + condition: service_started + + # Tempo — хранилище трейсов (OTLP от коллектора), UI/API — :3200 (профиль observability). + tempo: + image: grafana/tempo:2.8.1 + container_name: deal-tempo + profiles: ["observability"] + command: ["-config.file=/etc/tempo.yml"] + ports: + - "3200:3200" + volumes: + - ./observability/tempo.yml:/etc/tempo.yml:ro + - deal_tempo_data:/var/tempo + + # cAdvisor — ресурсы контейнеров (CPU/RAM/сеть/диск); scrape — job cadvisor. + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.52.1 + container_name: deal-cadvisor + profiles: ["observability"] + privileged: true + devices: + - /dev/kmsg:/dev/kmsg + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + + # node-exporter — ресурсы хоста (CPU/RAM/диск/сеть); scrape — job node-exporter. + node-exporter: + image: prom/node-exporter:v1.9.1 + container_name: deal-node-exporter + profiles: ["observability"] + command: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/host/root + - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/) + ports: + - "9100:9100" + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/host/root:ro + pid: host + volumes: deal_pgdata: deal_minio_data: @@ -268,3 +339,4 @@ volumes: deal_ml_data: deal_api_data: deal_prometheus_data: + deal_tempo_data: diff --git a/deploy/compose.prod.yml b/deploy/compose.prod.yml index b2c1df5..d4ee492 100644 --- a/deploy/compose.prod.yml +++ b/deploy/compose.prod.yml @@ -6,12 +6,12 @@ # Состав (всё в одной внутренней сети compose, наружу — ТОЛЬКО caddy :80/:443): # postgres, minio — хранилища БЕЗ host-портов (volume'ы); # core (:5080 HTTP + :5082 gRPC-ингресс), telegram-service (:5101), ai-service (:5102), -# ml-service (:5103) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже); +# ml-service (:5103), storage-service (:5104) — процессы «Дейла»; mTLS-транспорт — по env Ruling 6 (см. ниже); # caddy — edge: TLS-терминация, статика фронта, reverse_proxy /api → core. -# loki/promtail/grafana/prometheus — observability (Ruling 7; метрики — этап 12, пакет A): ПРОФИЛЬ -# `observability` — поднимается только: docker compose --profile observability up -d -# (или ... up -d --profile observability). Prometheus scrape'ит /metrics -# (порт 9464) всех 4 процессов; Grafana — логи (Loki) и метрики (Prometheus). +# observability (ПРОФИЛЬ `observability`) — современный стек: otel-collector (приём трейсов OTLP), +# tempo (хранилище трейсов), loki/promtail (логи), prometheus (метрики), +# cadvisor/node-exporter (потребление ресурсов контейнеров/хоста), grafana (UI). +# Подъём: docker compose --profile observability up -d. # # Секреты — ТОЛЬКО из env: шаблон deploy/.env.prod.example → скопируйте в deploy/.env.prod, # заполните значения и запускайте с --env-file: @@ -125,6 +125,7 @@ services: Services__Ai__Endpoint: ${DEAL_AI_ENDPOINT:-http://ai-service:5102} Services__Telegram__UseLocal: "false" Services__Telegram__Endpoint: ${DEAL_TELEGRAM_ENDPOINT:-http://telegram-service:5101} + Services__Storage__Endpoint: ${DEAL_STORAGE_SERVICE_ENDPOINT:-http://storage-service:5104} # Файлы — MinIO (внутренний http; TLS minio — вне этапа, при желании Storage__Minio__Secure=true # + endpoint https и сертификаты). Storage__Minio__Endpoint: minio:9000 @@ -132,6 +133,9 @@ services: Storage__Minio__SecretKey: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан} Storage__Minio__Bucket: deal-files Storage__Minio__Secure: "false" + # Трейсинг OTel → коллектор профиля observability; без коллектора трейсы не экспортируются. + OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-} + OTEL_SERVICE_NAME: core # mTLS внутреннего gRPC (Ruling 6; пути — /etc/deal/certs, см. volume ниже). DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem @@ -172,6 +176,8 @@ services: DEAL_TELEGRAM_SESSION_KEY: ${DEAL_TELEGRAM_SESSION_KEY:?DEAL_TELEGRAM_SESSION_KEY не задан (ключ AES-GCM сессий)} DEAL_TELEGRAM_SESSION_DIR: /data/sessions DEAL_LOGS_DIR: /tmp/logs + OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-} + OTEL_SERVICE_NAME: telegram-service SERVICES__CORE__INGRESS: ${DEAL_CORE_INGRESS:-http://core:5082} DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem @@ -201,6 +207,8 @@ services: GRPC_PORT: "5102" DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} DEAL_LOGS_DIR: /tmp/logs + OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-} + OTEL_SERVICE_NAME: ai-service DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ai-service-server.pfx @@ -229,6 +237,8 @@ services: DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} DEAL_ML_DATA_DIR: /data/ml DEAL_LOGS_DIR: /tmp/logs + OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-} + OTEL_SERVICE_NAME: ml-service DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/ml-service-server.pfx @@ -245,6 +255,44 @@ services: retries: 10 restart: unless-stopped + # storage-service — общий gRPC-сервис данных (вложения источников), :5104. Бэкенд — MinIO. + # Без host-портов; защита — общий service-token (+ mTLS при DEAL_MTLS_ENABLED=1). + storage-service: + build: + context: .. + dockerfile: src/storage-service/Deal.Storage/Dockerfile + <<: *service-hardening + mem_limit: 512m + cpus: 1.0 + environment: + GRPC_PORT: "5104" + DEAL_SERVICE_TOKEN: ${DEAL_SERVICE_TOKEN:?DEAL_SERVICE_TOKEN не задан} + DEAL_STORAGE_ENDPOINT: minio:9000 + DEAL_STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER:?MINIO_ROOT_USER не задан} + DEAL_STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD не задан} + DEAL_STORAGE_BUCKET: deal-attachments + DEAL_STORAGE_SECURE: "false" + DEAL_LOGS_DIR: /tmp/logs + OTEL_EXPORTER_OTLP_ENDPOINT: ${DEAL_OTEL_ENDPOINT:-} + OTEL_SERVICE_NAME: storage-service + DEAL_MTLS_ENABLED: ${DEAL_MTLS_ENABLED:-0} + DEAL_MTLS_CA_PEM: /etc/deal/certs/ca.pem + DEAL_MTLS_SERVER_CERT_PFX: /etc/deal/certs/storage-service-server.pfx + DEAL_MTLS_SERVER_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} + DEAL_MTLS_CLIENT_CERT_PFX: /etc/deal/certs/deal-client.pfx + DEAL_MTLS_CLIENT_CERT_PASSWORD: ${DEAL_MTLS_CERT_PASSWORD:-} + volumes: + - ${DEAL_CERTS_DIR:-./certs}:/etc/deal/certs:ro + depends_on: + minio: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "if [ \"$$DEAL_MTLS_ENABLED\" = \"1\" ]; then /bin/grpc_health_probe -addr=localhost:5104 -tls -tls-ca-cert=/etc/deal/certs/ca.pem -tls-client-cert=/etc/deal/certs/deal-client.crt -tls-client-key=/etc/deal/certs/deal-client.key -tls-server-name=localhost; else /bin/grpc_health_probe -addr=localhost:5104; fi"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + # caddy — edge: наружу только :80/:443. TLS — плейсхолдер tls internal (см. Caddyfile: домен, # реальный сертификат/Cloudflare, CSP/HSTS). Статика — ../src/frontend/dist (СОБРАТЬ ДО up). caddy: @@ -334,6 +382,63 @@ services: condition: service_started prometheus: condition: service_started + tempo: + condition: service_started + restart: unless-stopped + + # ── Трейсы и ресурсы (observability-стек) ───────────────────────────────── + # OpenTelemetry Collector — приёмник трейсов Deal-процессов (OTLP gRPC :4317 / HTTP :4318), + # батчит и перекладывает в Tempo. Наружу порты не публикуются (внутри compose-сети). + otel-collector: + image: otel/opentelemetry-collector-contrib:0.160.0 + profiles: ["observability"] + command: ["--config=/etc/otelcol-contrib/config.yaml"] + volumes: + - ./observability/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro + depends_on: + tempo: + condition: service_started + restart: unless-stopped + + # Tempo — хранилище трейсов (OTLP от коллектора). Retention блоков — 7 суток (см. tempo.yml). + tempo: + image: grafana/tempo:2.8.1 + profiles: ["observability"] + command: ["-config.file=/etc/tempo.yml"] + volumes: + - ./observability/tempo.yml:/etc/tempo.yml:ro + - deal_tempo_data:/var/tempo + restart: unless-stopped + + # cAdvisor — ресурсы контейнеров (CPU/RAM/сеть/диск); scrape — job cadvisor в prometheus.yml. + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.52.1 + profiles: ["observability"] + privileged: true + devices: + - /dev/kmsg:/dev/kmsg + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + restart: unless-stopped + + # node-exporter — ресурсы хоста (CPU/RAM/диск/сеть); scrape — job node-exporter в prometheus.yml. + node-exporter: + image: prom/node-exporter:v1.9.1 + profiles: ["observability"] + command: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/host/root + - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/) + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/host/root:ro + pid: host restart: unless-stopped volumes: @@ -343,6 +448,7 @@ volumes: deal_ml_data: deal_api_data: deal_caddy_data: + deal_tempo_data: deal_caddy_config: deal_loki_data: deal_promtail_data: diff --git a/deploy/observability/README.md b/deploy/observability/README.md new file mode 100644 index 0000000..6a2879c --- /dev/null +++ b/deploy/observability/README.md @@ -0,0 +1,53 @@ +# Observability-стек «Дейла» + +Современный (vendor-neutral) стек мониторинга. Поднимается **профилем `observability`** (в prod — +`deploy/compose.prod.yml`, в dev — `deploy/compose.dev.yml`); наружу порты не публикуются (prod — +доступ оператору по SSH-туннелю). + +## Состав и поток данных + +| Слой | Сервис | Конфиг | Поток | +|---|---|---|---| +| Трейсы (приём) | `otel-collector` | `otel-collector.yml` | OTLP от сервисов (`:4317`) → Tempo | +| Трейсы (хранение) | `tempo` | `tempo.yml` | OTLP от коллектора, retention 7 сут. | +| Логи | `loki` + `promtail` | `loki.yml`, `promtail.yml` | docker-логи → Loki | +| Метрики | `prometheus` | `prometheus.yml`, `prometheus-rules.yml` | scrape `/metrics` процессов и `cadvisor`/`node-exporter` | +| Ресурсы контейнеров | `cadvisor` | — | Prometheus | +| Ресурсы хоста | `node-exporter` | — | Prometheus | +| Визуализация | `grafana` | `grafana/provisioning/**` | Loki + Prometheus + Tempo | + +## Подъём + +```bash +# prod (нужен deploy/.env.prod с DEAL_GRAFANA_ADMIN_PASSWORD) +docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml --profile observability up -d + +# dev +docker compose -f deploy/compose.dev.yml --profile observability up -d +``` + +Проверка: Prometheus `/targets` (job `deal` — 4 процесса UP, `cadvisor`, `node-exporter`, `tempo`) → +Grafana → папка «Дейл» → `Deal-Metrics-Overview` / `Deal-Logs` / `Deal-Traces` / `Deal-Resources`. + +## Подключение сервисов (код) + +Общая настройка — `Deal.Grpc.Hosting` (сервисы) и `Deal.Api/Observability` (ядро): + +- **метрики**: `DealMetricsHosting` — OTel → Prometheus, отдельный HTTP/1.1-эндпоинт `/metrics:9464` + (env `METRICS_PORT`); +- **трейсы**: `DealTracingHosting` — OTel → OTLP, **опт-ин** через env `OTEL_EXPORTER_OTLP_ENDPOINT` + (адрес коллектора; без него трейсинг выключен), имя сервиса — `OTEL_SERVICE_NAME`; +- **логи**: Serilog JSON обогащается `TraceId`/`SpanId` (`TraceContextEnricher`) для связи с трейсами. + +## Дашборды и алерты + +Дашборды — как код: `grafana/dashboards/*.json` (правки только в репозитории, UI не сохраняет). +Алерты — `prometheus-rules.yml` (доступность, ошибки/5xx, очереди). Алерты по **ресурсам** вынесены в +`prometheus-resource-rules.yml` и **отключены по умолчанию**; включаются добавлением файла в `rule_files`, +пороги — через env `DEAL_ALERT_*` (см. шапку файла). + +## Обновление версий образов + +Версии зафиксированы в compose-файлах. При обновлении — свежие стабильные теги: +`otel/opentelemetry-collector-contrib`, `grafana/tempo`, `prom/node-exporter`, +`gcr.io/cadvisor/cadvisor`, `grafana/loki`, `grafana/promtail`, `prom/prometheus`, `grafana/grafana`. diff --git a/deploy/observability/grafana/dashboards/Deal-Resources.json b/deploy/observability/grafana/dashboards/Deal-Resources.json new file mode 100644 index 0000000..3c7ed47 --- /dev/null +++ b/deploy/observability/grafana/dashboards/Deal-Resources.json @@ -0,0 +1,289 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Потребление CPU контейнерами Deal (cAdvisor). Значение — ядра, занятые контейнером за 5 минут.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 15, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name=~\".*deal.*|.*core.*|.*service.*\"}[5m]))", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "CPU контейнеров (cAdvisor)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Рабочий набор памяти контейнеров Deal (cAdvisor). Лимиты заданы mem_limit в compose.prod.yml.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 15, + "lineWidth": 1, + "showPoints": "never" + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (name) (container_memory_working_set_bytes{name=~\".*deal.*|.*core.*|.*service.*\"})", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "Память контейнеров (cAdvisor)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Загрузка CPU хоста (node-exporter), 0–100%.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 15, + "lineWidth": 1, + "showPoints": "never" + }, + "max": 100, + "min": 0, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", + "legendFormat": "CPU хоста", + "refId": "A" + } + ], + "title": "CPU хоста (node-exporter)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Занятая память хоста (node-exporter), проценты от общего объёма.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 15, + "lineWidth": 1, + "showPoints": "never" + }, + "max": 100, + "min": 0, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100", + "legendFormat": "Память хоста", + "refId": "A" + } + ], + "title": "Память хоста (node-exporter)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Свободное место на разделах хоста (node-exporter), проценты.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 15, + "lineWidth": 1, + "showPoints": "never" + }, + "max": 100, + "min": 0, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "min by (mountpoint) (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay\"}) * 100", + "legendFormat": "{{mountpoint}}", + "refId": "A" + } + ], + "title": "Свободное место на диске (node-exporter)", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "deal", + "resources" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timezone": "browser", + "title": "Deal — Ресурсы", + "uid": "deal-resources", + "version": 1 +} diff --git a/deploy/observability/grafana/dashboards/Deal-Traces.json b/deploy/observability/grafana/dashboards/Deal-Traces.json new file mode 100644 index 0000000..3a892b3 --- /dev/null +++ b/deploy/observability/grafana/dashboards/Deal-Traces.json @@ -0,0 +1,56 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "description": "Поиск трейсов Deal (Tempo, TraceQL). Источник — OTLP от сервисов через otel-collector. Клик по трейсу — спаны по сервисам; из спана можно перейти к логам (Loki) по traceId.", + "gridPos": { + "h": 22, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": {}, + "targets": [ + { + "datasource": { + "type": "tempo", + "uid": "tempo" + }, + "query": "{}", + "queryType": "traceql", + "refId": "A" + } + ], + "title": "Трейсы (Tempo)", + "type": "traces" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "deal", + "traces" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timezone": "browser", + "title": "Deal — Трейсы", + "uid": "deal-traces", + "version": 1 +} diff --git a/deploy/observability/grafana/provisioning/datasources/datasources.yml b/deploy/observability/grafana/provisioning/datasources/datasources.yml index d657bd1..1d082b9 100644 --- a/deploy/observability/grafana/provisioning/datasources/datasources.yml +++ b/deploy/observability/grafana/provisioning/datasources/datasources.yml @@ -13,6 +13,12 @@ datasources: isDefault: true jsonData: maxLines: 1000 + # Клик по TraceId в логе открывает трейс в Tempo (обогащение логов TraceContextEnricher). + derivedFields: + - name: TraceID + matcherRegex: '"TraceId":"([0-9a-f]+)"' + datasourceUid: tempo + url: "$${__value.raw}" - name: Prometheus # UID фиксирован: на него ссылаются панели дашборда Deal-Metrics-Overview (datasource uid: prometheus). @@ -26,3 +32,24 @@ datasources: # Prometheus хранит OTel-гистограммы в нативных bucket'ах — используем нативные histogram_quantile. httpMethod: POST timeInterval: 15s + + - name: Tempo + # UID фиксирован: на него ссылаются панели Deal-Traces и derivedFields логов (datasource uid: tempo). + uid: tempo + type: tempo + access: proxy + url: http://tempo:3200 + isDefault: false + jsonData: + # Из спана трейса — к логам того же сервиса и traceId (обратная корреляция Loki ↔ Tempo). + tracesToLogsV2: + datasourceUid: loki + filterByTraceID: true + filterBySpanID: false + spanStartTimeShift: -1m + spanEndTimeShift: 1m + # Карта сервисов и граф узлов — из метрик Prometheus. + serviceMap: + datasourceUid: prometheus + nodeGraph: + enabled: true diff --git a/deploy/observability/otel-collector.yml b/deploy/observability/otel-collector.yml new file mode 100644 index 0000000..7077488 --- /dev/null +++ b/deploy/observability/otel-collector.yml @@ -0,0 +1,40 @@ +# OpenTelemetry Collector — приёмник трейсов Deal-процессов (профиль observability). +# +# Процессы шлют OTLP (gRPC :4317 / HTTP :4318) на этот сервис; коллектор батчит и перекладывает +# трейсы в Tempo (OTLP). Запускается только профилем observability (deploy/compose.prod.yml); +# наружу порты не публикуются (внутри compose-сети). +# +# Конфиг монтируется в /etc/otelcol-contrib/config.yaml. Health-эндпоинт :13133 — для healthcheck. + +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + # Батчинг снижает число сетевых вызовов к Tempo (стандартный приём OTel). + batch: {} + +exporters: + otlp/tempo: + endpoint: tempo:4317 + tls: + insecure: true + +extensions: + health_check: + endpoint: 0.0.0.0:13133 + +service: + extensions: [health_check] + telemetry: + logs: + level: warn + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp/tempo] diff --git a/deploy/observability/prometheus-resource-rules.yml b/deploy/observability/prometheus-resource-rules.yml new file mode 100644 index 0000000..6d64d53 --- /dev/null +++ b/deploy/observability/prometheus-resource-rules.yml @@ -0,0 +1,73 @@ +# Правила алертов Prometheus по потреблению ресурсов — ОТКЛЮЧЕНЫ ПО УМОЛЧАНИЮ. +# +# Заготовка: включить, добавив эту строку в `rule_files` файла prometheus.yml: +# rule_files: +# - prometheus-rules.yml +# - prometheus-resource-rules.yml +# +# Пороги выносятся в env (DEAL_ALERT_*); Prometheus не раскрывает значения env в конфиге, поэтому при +# включении файл рендерится из шаблона (подстановка порогов) или пороги проставляются вручную: +# DEAL_ALERT_HOST_CPU_PERCENT — загрузка CPU хоста, % (дефолт 90) +# DEAL_ALERT_HOST_MEMORY_PERCENT — занятая память хоста, % (дефолт 90) +# DEAL_ALERT_HOST_DISK_PERCENT — минимум свободного места, % (дефолт 15) +# DEAL_ALERT_CONTAINER_CPU — CPU контейнера (ядра) (дефолт 1.5) +# DEAL_ALERT_CONTAINER_MEMORY_PERCENT — память контейнера к лимиту, %(дефолт 90) +# +# Источники: node-exporter (хост) и cAdvisor (контейнеры), scrape — jobs node-exporter/cadvisor. + +groups: + - name: deal-resources + rules: + # Хост: загрузка CPU. Дефолт порога — 90%. + - alert: DealHostHighCpu + expr: 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90 + for: 10m + labels: + severity: warning + annotations: + summary: "Высокая загрузка CPU хоста" + description: "Средняя загрузка CPU хоста выше 90% за 5 минут более 10 минут." + + # Хост: занятая память. Дефолт порога — 90%. + - alert: DealHostHighMemory + expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.9 + for: 10m + labels: + severity: warning + annotations: + summary: "Высокое потребление памяти хостом" + description: "Свободной памяти меньше 10% более 10 минут." + + # Хост: свободное место на разделе. Дефолт порога — 15%. + - alert: DealHostDiskLow + expr: | + min by (mountpoint) (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) < 0.15 + for: 15m + labels: + severity: warning + annotations: + summary: "Мало места на диске ({{ $labels.mountpoint }})" + description: "Свободно менее 15% на разделе {{ $labels.mountpoint }} более 15 минут." + + # Контейнеры Deal: CPU (ядра). Дефолт порога — 1.5. + - alert: DealContainerHighCpu + expr: | + sum by (name) (rate(container_cpu_usage_seconds_total{name=~".*deal.*|.*core.*|.*service.*"}[5m])) > 1.5 + for: 10m + labels: + severity: warning + annotations: + summary: "Высокий CPU контейнера {{ $labels.name }}" + description: "Контейнер {{ $labels.name }} держит > 1.5 CPU за 5 минут более 10 минут." + + # Контейнеры Deal: память к лимиту. Дефолт порога — 90%. + - alert: DealContainerHighMemory + expr: | + (container_memory_working_set_bytes{name=~".*deal.*|.*core.*|.*service.*"} + / clamp_min(container_spec_memory_limit_bytes{name=~".*deal.*|.*core.*|.*service.*"}, 1)) > 0.9 + for: 10m + labels: + severity: warning + annotations: + summary: "Контейнер {{ $labels.name }} близок к лимиту памяти" + description: "Контейнер {{ $labels.name }} использует более 90% лимита памяти более 10 минут." diff --git a/deploy/observability/prometheus-rules.yml b/deploy/observability/prometheus-rules.yml index 3ad8a02..fbc56c7 100644 --- a/deploy/observability/prometheus-rules.yml +++ b/deploy/observability/prometheus-rules.yml @@ -79,3 +79,4 @@ groups: annotations: summary: "Растёт очередь обучения ML (outbox)" description: "Суммарная глубина MlOutbox держится выше 100 более 15 минут ({{ $value }})." + diff --git a/deploy/observability/prometheus.yml b/deploy/observability/prometheus.yml index 498ab5a..c5fe888 100644 --- a/deploy/observability/prometheus.yml +++ b/deploy/observability/prometheus.yml @@ -46,3 +46,18 @@ scrape_configs: - targets: ["ml-service:9464"] labels: service: ml-service + + # Ресурсы контейнеров (cAdvisor) и хоста (node-exporter) — этап «observability-стек». + # cAdvisor отдаёт метрики контейнеров (CPU/RAM/сеть/диск), node-exporter — хоста. + - job_name: cadvisor + static_configs: + - targets: ["cadvisor:8080"] + + - job_name: node-exporter + static_configs: + - targets: ["node-exporter:9100"] + + # Темпо (хранилище трейсов) — само-мониторинг: метрики приёма/отдачи трейсов. + - job_name: tempo + static_configs: + - targets: ["tempo:3200"] diff --git a/deploy/observability/tempo.yml b/deploy/observability/tempo.yml new file mode 100644 index 0000000..bbce321 --- /dev/null +++ b/deploy/observability/tempo.yml @@ -0,0 +1,28 @@ +# Grafana Tempo — хранилище трейсов Deal (профиль observability). +# +# Принимает OTLP-трейсы от OpenTelemetry Collector (grpc :4317), хранит локально (volume +# deal_tempo_data), отдаёт запросы Grafana на :3200 (внутри compose-сети). Block retention — 7 суток +# (трейсы объёмны; логи/метрики живут дольше). + +server: + http_listen_port: 3200 + log_level: warn + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + +storage: + trace: + backend: local + local: + path: /var/tempo/traces + wal: + path: /var/tempo/wal + +compactor: + compaction: + block_retention: 168h diff --git a/docs/technical/Техническая-документация-Дейл.md b/docs/technical/Техническая-документация-Дейл.md index 1d282f5..76dce27 100644 --- a/docs/technical/Техническая-документация-Дейл.md +++ b/docs/technical/Техническая-документация-Дейл.md @@ -22,7 +22,7 @@ | Файлы | MinIO (S3-совместимое хранилище) | | Фронтенд | Vue 3 + Vite + Tailwind | | Межсервисно | gRPC + Protobuf (mTLS — за флагом `DEAL_MTLS_*`, §10/§13.8) | -| Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus → Grafana | +| Наблюдаемость | Serilog (JSON: консоль + rolling-файл) → Promtail → Loki → Grafana; метрики OTel → Prometheus; трейсы OTel → Collector → Tempo; ресурсы cAdvisor/node-exporter → Prometheus; единый UI — Grafana | | Прокси/edge | Caddy (TLS, security-заголовки); Cloudflare/k8s — вне этапа (§10/§11) | | Контейнеры | Docker / docker compose (VPS); k8s — позже | | Бэкапы | Ежедневные: pg_dump + MinIO + сессии | @@ -236,7 +236,9 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) -- (нужен `DEAL_GRAFANA_ADMIN_PASSWORD` в `.env.prod`; порты Grafana/Prometheus — только loopback). Остановка — `docker compose -f deploy/compose.prod.yml --profile observability down`. - **Провижининг Grafana — как код** (`deploy/observability/grafana/provisioning`, монтируется в - контейнер): `datasources/datasources.yml` — датасорс Loki (uid `loki`, URL `http://loki:3100`); + контейнер): `datasources/datasources.yml` — датасорсы Loki (uid `loki`, default), Prometheus + (uid `prometheus`) и Tempo (uid `tempo`); у Loki — `derivedFields` TraceID → Tempo (клик по traceId + в логе открывает трейс), у Tempo — `tracesToLogsV2` → Loki и `serviceMap`/`nodeGraph` по метрикам; `dashboards/dashboards.yml` — папка `Дейл` из `/var/lib/grafana/dashboards`. Дашборды — файлы `deploy/observability/grafana/dashboards/*.json`: правки только в репозитории, UI-изменения не сохраняются (`allowUiUpdates: false`). @@ -251,7 +253,10 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) -- access-лога core) и активация инвайтов (`/api/join`); - `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента; - `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса; - - `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram). + - `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram); + - `Deal-Traces` — поиск трейсов (Tempo, TraceQL), спаны по сервисам, переход к логам по traceId; + - `Deal-Resources` — потребление ресурсов контейнерами (cAdvisor) и хостом (node-exporter): CPU/RAM, + свободное место на дисках. **Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и `tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через `GET /api/operator/audit` / экран «Аудит» оператор-консоли. @@ -295,9 +300,37 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) -- `deal_ml_outbox_depth`), пропажа метрик ядра (`absent(deal_sessions_active)`). Замечание: правила бюджета токенов нет — метрика бюджета в Prometheus отсутствует (см. §6/§10), поэтому алерт не вводится. - Как поднять/проверить: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml - --profile observability up -d` → Prometheus `/targets` (все 4 UP) → Grafana → папка «Дейл» → - `Deal-Metrics-Overview`. Быстрая проверка экспортёра без Grafana: `curl http://<процесс>:9464/metrics` - изнутри сети. + --profile observability up -d` → Prometheus `/targets` (все UP) → Grafana → папка «Дейл» → + `Deal-Metrics-Overview`/`Deal-Resources`/`Deal-Traces`. Быстрая проверка экспортёра без Grafana: + `curl http://<процесс>:9464/metrics` изнутри сети. + +### Трейсы (OpenTelemetry Collector + Tempo) + +- **Экспорт из 5 процессов**: OpenTelemetry SDK → OTLP → **OpenTelemetry Collector** (`otel-collector: + 4317`) → **Tempo** (`tempo:4317`, хранилище трейсов, retention 7 суток). Настройка — общая в + `Deal.Grpc.Hosting` (`DealTracingHosting`) для telegram/ai/ml/storage и `Deal.Api/Observability/ + DealTracingHosting.cs` для ядра. Инструментируется входящий HTTP/gRPC (AspNetCore), исходящие + HTTP-клиенты и gRPC-клиенты (GrpcNetClient) — трейсы сквозные от входа до БД/внешних сервисов. +- **Включение — опт-ин через env** `OTEL_EXPORTER_OTLP_ENDPOINT` (адрес коллектора, напр. + `http://otel-collector:4317`); без него трейсинг выключен. В compose env задан пустым + (`${DEAL_OTEL_ENDPOINT:-}`) — чтобы включить, задайте `DEAL_OTEL_ENDPOINT` в `.env`. Имя сервиса в + трейсах — `OTEL_SERVICE_NAME` (дефолт по процессу: `core`, `telegram-service`, `ai-service`, + `ml-service`, `storage-service`). +- **Корреляция с логами**: Serilog обогащается `TraceId`/`SpanId` из `Activity.Current` + (`TraceContextEnricher`) — в Loki-логе есть `TraceId`, а датасорс Loki `derivedFields` даёт переход + из лога в трейс Tempo (и обратно — `tracesToLogsV2`). +- Сервисы профиля: `otel-collector` (`otel/opentelemetry-collector-contrib:0.160.0`, конфиг + `deploy/observability/otel-collector.yml`) и `tempo` (`grafana/tempo:2.8.1`, конфиг + `deploy/observability/tempo.yml`, volume `deal_tempo_data`). Наружу порты не публикуются (dev — для отладки). + +### Ресурсы (cAdvisor + node-exporter) + +- **cAdvisor** (`gcr.io/cadvisor/cadvisor:v0.52.1`) — потребление ресурсов **контейнерами** + (CPU/RAM/сеть/диск); **node-exporter** (`prom/node-exporter:v1.9.1`) — ресурсы **хоста** (CPU/RAM/ + диски/сеть). Оба scrape'ит Prometheus (jobs `cadvisor`, `node-exporter` в `prometheus.yml`). +- Дашборд `Deal-Resources` (uid `deal-resources`): CPU/RAM контейнеров, CPU/RAM хоста, свободное место + на дисках. Правила алертов по ресурсам — в отдельном файле `prometheus-resource-rules.yml`, + **отключены по умолчанию** (не входят в `rule_files`); пороги — через env `DEAL_ALERT_*` при включении. --- @@ -330,10 +363,11 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) -- - Одна внутренняя сеть; наружу — только **caddy** (80/443): TLS (шапка `deploy/caddy/Caddyfile` — `tls internal` для dev/интранет, для реального домена заменить на Cloudflare-origin/сертификаты), статика `src/frontend/dist`, `reverse_proxy /api → core:5080`, security-заголовки (CSP/HSTS — здесь). -- `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml-service` (mTLS-env, Ruling 6), +- `core` (:5080 http + :5082 gRPC-ингресс), `telegram/ai/ml/storage-service` (mTLS-env, Ruling 6), `postgres`/`minio` **без host-портов**; healthcheck'и — `grpc_health_probe` (при mTLS — TLS-проба с PEM `deal-client.crt/.key`)/`pg_isready`. -- Профиль `observability`: `loki`/`promtail`/`grafana` + `prometheus` (метрики — этап 12, пакет A; см. §7). +- Профиль `observability`: `otel-collector`/`tempo` (трейсы), `loki`/`promtail` (логи), + `prometheus`/`cadvisor`/`node-exporter` (метрики и ресурсы), `grafana` (UI); см. §7. Секреты — только из `.env.prod` (шаблон `deploy/.env.prod.example`, без дефолтных паролей; отсутствие → fail-fast `:?`). Rate limiting включён (`RateLimit__Enabled: true`), CORS — явный `Security__AllowedOrigins` @@ -1152,11 +1186,12 @@ docker compose -f deploy/compose.dev.yml down # погасить ст (RpcCallLoggingInterceptor; gRPC-health не логируется). **Метрики** — OTel → Prometheus: `/metrics` (HTTP/1.1 :9464) + прикладные `deal.*` (токены/вызовы AI/ML, аудит, глубины очередей, сессии) — см. §7. PROD-стек: docker-логи → Promtail → Loki (retention 7 сут.) → Grafana (`127.0.0.1:3001`, SSH-туннель), - метрики → Prometheus (`127.0.0.1:9090`) → Grafana; профиль `observability` compose.prod. + метрики → Prometheus (`127.0.0.1:9090`) → Grafana, трейсы OTel → otel-collector → Tempo, ресурсы + cAdvisor/node-exporter → Prometheus; профиль `observability` compose.prod. - **compose.prod** (Ruling 9): `deploy/compose.prod.yml` — postgres/minio (без host-портов), core + telegram/ai/ml (mTLS env; healthcheck — `grpc_health_probe`, при mTLS — TLS-проба с PEM), `caddy` (80/443: статика `src/frontend/dist` + `reverse_proxy /api → core:5080`, security-заголовки; домен/TLS/Cloudflare — шапка - `deploy/caddy/Caddyfile`), профиль `observability` (loki/promtail/grafana/prometheus). Секреты — только из `.env.prod` + `deploy/caddy/Caddyfile`), профиль `observability` (otel-collector/tempo/loki/promtail/prometheus/cadvisor/node-exporter/grafana). Секреты — только из `.env.prod` (шаблон `deploy/.env.prod.example`, без дефолтных паролей, fail-fast `:?`). Запуск: `docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml up -d --build` (+ `--profile observability`); авто-проверка — `... config` rc=0. @@ -1305,7 +1340,8 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service (`container_created`, `container_updated`, `container_deleted`), `settings_updated`, `channel_enabled`, `telegram_linked` (таблица — в контракте; `channel_created` зарезервирован, но не эмитится). - **Наблюдаемость** (Grafana provisioning + promtail-лейблы, дашборды `Deal-Auth/Errors/Rps/Logs`; с этапа 12 — - метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`, см. §7). + метрики OTel → Prometheus и дашборд `Deal-Metrics-Overview`; трейсы OTel → Collector → Tempo (`Deal-Traces`) + и ресурсы cAdvisor/node-exporter (`Deal-Resources`), см. §7). - **Как открыть (dev):** `docker compose -f deploy/compose.dev.yml up -d --build` (или core на `:5080` с Postgres `:5433`, AI в Local-режиме) → фронт `cd src/frontend && npm run dev` (`:5173`, прокси `/api`) → **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production — diff --git a/src/ai-service/Deal.Ai/Program.cs b/src/ai-service/Deal.Ai/Program.cs index 08b6a63..95a6ffd 100644 --- a/src/ai-service/Deal.Ai/Program.cs +++ b/src/ai-service/Deal.Ai/Program.cs @@ -18,6 +18,7 @@ WebApplication app = AiServiceHost.Create( { DealLogging.Configure(builder, aiProcessName); DealMetricsHosting.AddDealMetrics(builder, metricsPort); + DealTracingHosting.AddDealTracing(builder, aiProcessName); }); DealMetricsHosting.MapDealMetrics(app); diff --git a/src/core/Deal.Api/Deal.Api.csproj b/src/core/Deal.Api/Deal.Api.csproj index 7383b7e..8a91930 100644 --- a/src/core/Deal.Api/Deal.Api.csproj +++ b/src/core/Deal.Api/Deal.Api.csproj @@ -42,6 +42,7 @@ Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент только pre-release-линией; остальные — 1.17.0 stable). Настройка — Deal.Api/Observability/DealMetricsHosting.cs. --> + diff --git a/src/core/Deal.Api/Logging/DealLogging.cs b/src/core/Deal.Api/Logging/DealLogging.cs index 7bd21e6..c998210 100644 --- a/src/core/Deal.Api/Logging/DealLogging.cs +++ b/src/core/Deal.Api/Logging/DealLogging.cs @@ -62,7 +62,8 @@ internal static class DealLogging .MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey])) .MinimumLevel.Override(EntityFrameworkCoreCategory, LogEventLevel.Warning) .MinimumLevel.Override(GrpcCategory, LogEventLevel.Information) - .Enrich.FromLogContext(); + .Enrich.FromLogContext() + .Enrich.With(); string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]); Directory.CreateDirectory(logsDirectory); diff --git a/src/core/Deal.Api/Logging/TraceContextEnricher.cs b/src/core/Deal.Api/Logging/TraceContextEnricher.cs new file mode 100644 index 0000000..d8c0ee4 --- /dev/null +++ b/src/core/Deal.Api/Logging/TraceContextEnricher.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; +using Serilog.Core; +using Serilog.Events; + +namespace Deal.Api.Logging; + +internal sealed class TraceContextEnricher : ILogEventEnricher +{ + void ILogEventEnricher.Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + Activity? activity = Activity.Current; + if (activity is null) + { + return; + } + + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString())); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString())); + } +} diff --git a/src/core/Deal.Api/Observability/DealTracingHosting.cs b/src/core/Deal.Api/Observability/DealTracingHosting.cs new file mode 100644 index 0000000..a030b09 --- /dev/null +++ b/src/core/Deal.Api/Observability/DealTracingHosting.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Deal.Api.Observability; + +/// +/// Настройка трейсинга ядра Deal.Api +/// +public static class DealTracingHosting +{ + /// + /// Env-ключ OTLP-endpoint коллектора + /// + public const string OtlpEndpointEnvKey = "OTEL_EXPORTER_OTLP_ENDPOINT"; + + /// + /// Env-ключ имени сервиса в трейсах + /// + public const string ServiceNameEnvKey = "OTEL_SERVICE_NAME"; + + /// + /// Регистрирует трейсинг OpenTelemetry с экспортом OTLP + /// + /// Билдер ядра. + /// Имя сервиса, если env OTEL_SERVICE_NAME не задан. + public static void AddDealTracing(WebApplicationBuilder builder, string defaultServiceName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(defaultServiceName); + + // Трейсинг выключен без OTLP-endpoint (dev без профиля observability): иначе экспортёр + // вхолостую спамит ошибками соединения. + string? endpoint = Environment.GetEnvironmentVariable(OtlpEndpointEnvKey); + if (string.IsNullOrWhiteSpace(endpoint)) + { + return; + } + + string serviceName = Environment.GetEnvironmentVariable(ServiceNameEnvKey) is { Length: > 0 } configured + ? configured + : defaultServiceName; + + builder.Services + .AddOpenTelemetry() + .WithTracing(tracing => tracing + .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName)) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddGrpcClientInstrumentation() + .AddOtlpExporter(options => options.Endpoint = new Uri(endpoint))); + } +} diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs index 897ec6f..38dbbf3 100644 --- a/src/core/Deal.Api/Program.cs +++ b/src/core/Deal.Api/Program.cs @@ -66,6 +66,7 @@ DealLogging.Configure(builder, coreProcessName); int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); DealMetricsHosting.AddDealMetrics(builder, metricsPort); +DealTracingHosting.AddDealTracing(builder, coreProcessName); // Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует // (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД. diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj b/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj index b539560..2bf63ff 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Deal.Grpc.Hosting.csproj @@ -45,6 +45,7 @@ ней нет; держим для будущего трейсинга). Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент выпускаются только pre-release-линией; остальные пакеты 1.17.0 stable). --> + diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs index 52eb428..5664f75 100644 --- a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealLogging.cs @@ -67,7 +67,8 @@ public static class DealLogging loggerConfiguration .MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey])) .MinimumLevel.Override(GrpcCategory, LogEventLevel.Information) - .Enrich.FromLogContext(); + .Enrich.FromLogContext() + .Enrich.With(); string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]); Directory.CreateDirectory(logsDirectory); diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealTracingHosting.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealTracingHosting.cs new file mode 100644 index 0000000..5dbbad9 --- /dev/null +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/DealTracingHosting.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Deal.Grpc.Hosting.Services; + +/// +/// Общая настройка трейсинга Deal-сервисов +/// +public static class DealTracingHosting +{ + /// + /// Env-ключ OTLP-endpoint коллектора + /// + public const string OtlpEndpointEnvKey = "OTEL_EXPORTER_OTLP_ENDPOINT"; + + /// + /// Env-ключ имени сервиса в трейсах + /// + public const string ServiceNameEnvKey = "OTEL_SERVICE_NAME"; + + /// + /// Регистрирует трейсинг OpenTelemetry с экспортом OTLP + /// + /// Билдер хоста сервиса. + /// Имя сервиса, если env OTEL_SERVICE_NAME не задан. + public static void AddDealTracing(WebApplicationBuilder builder, string defaultServiceName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(defaultServiceName); + + // Трейсинг выключен без OTLP-endpoint (dev без профиля observability): иначе экспортёр + // вхолостую спамит ошибками соединения. + string? endpoint = Environment.GetEnvironmentVariable(OtlpEndpointEnvKey); + if (string.IsNullOrWhiteSpace(endpoint)) + { + return; + } + + string serviceName = Environment.GetEnvironmentVariable(ServiceNameEnvKey) is { Length: > 0 } configured + ? configured + : defaultServiceName; + + builder.Services + .AddOpenTelemetry() + .WithTracing(tracing => tracing + .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName)) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddGrpcClientInstrumentation() + .AddOtlpExporter(options => options.Endpoint = new Uri(endpoint))); + } +} diff --git a/src/grpc-hosting/Deal.Grpc.Hosting/Services/TraceContextEnricher.cs b/src/grpc-hosting/Deal.Grpc.Hosting/Services/TraceContextEnricher.cs new file mode 100644 index 0000000..8ff9d82 --- /dev/null +++ b/src/grpc-hosting/Deal.Grpc.Hosting/Services/TraceContextEnricher.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; +using Serilog.Core; +using Serilog.Events; + +namespace Deal.Grpc.Hosting.Services; + +internal sealed class TraceContextEnricher : ILogEventEnricher +{ + void ILogEventEnricher.Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + Activity? activity = Activity.Current; + if (activity is null) + { + return; + } + + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString())); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString())); + } +} diff --git a/src/ml-service/Deal.Ml/Program.cs b/src/ml-service/Deal.Ml/Program.cs index 4f6fcf9..aa8c09b 100644 --- a/src/ml-service/Deal.Ml/Program.cs +++ b/src/ml-service/Deal.Ml/Program.cs @@ -18,6 +18,7 @@ WebApplication app = MlServiceHost.Create( { DealLogging.Configure(builder, mlProcessName); DealMetricsHosting.AddDealMetrics(builder, metricsPort); + DealTracingHosting.AddDealTracing(builder, mlProcessName); }); DealMetricsHosting.MapDealMetrics(app); diff --git a/src/storage-service/Deal.Storage/Program.cs b/src/storage-service/Deal.Storage/Program.cs index dd40dc3..8aa8892 100644 --- a/src/storage-service/Deal.Storage/Program.cs +++ b/src/storage-service/Deal.Storage/Program.cs @@ -16,6 +16,7 @@ WebApplication app = StorageServiceHost.Create( { DealLogging.Configure(builder, storageProcessName); DealMetricsHosting.AddDealMetrics(builder, metricsPort); + DealTracingHosting.AddDealTracing(builder, storageProcessName); }); DealMetricsHosting.MapDealMetrics(app); diff --git a/src/telegram-service/Deal.Telegram/Program.cs b/src/telegram-service/Deal.Telegram/Program.cs index ec4dcdd..9a4335f 100644 --- a/src/telegram-service/Deal.Telegram/Program.cs +++ b/src/telegram-service/Deal.Telegram/Program.cs @@ -18,6 +18,7 @@ WebApplication app = TelegramServiceHost.Create( { DealLogging.Configure(builder, telegramProcessName); DealMetricsHosting.AddDealMetrics(builder, metricsPort); + DealTracingHosting.AddDealTracing(builder, telegramProcessName); }); DealMetricsHosting.MapDealMetrics(app);