Добавлен сбор телеметрии на ВМ2
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Non-secret VM2 deployment manifest. Never add DSNs, passwords, tokens or keys.
|
||||
APP_ENV=production-like
|
||||
RELEASE_VERSION=<immutable-release>
|
||||
LOG_LEVEL=INFO
|
||||
SECRETS_SOURCE=selectel
|
||||
MESSAGE_SAFETY_IMAGE=<registry>/han-message-safety@sha256:<digest>
|
||||
BITRIX_SYNC_IMAGE=<registry>/han-bitrix-sync@sha256:<digest>
|
||||
@@ -8,6 +9,8 @@ NGINX_IMAGE=nginxinc/nginx-unprivileged@sha256:<reviewed-digest>
|
||||
REDIS_IMAGE=redis@sha256:<reviewed-digest>
|
||||
CLAMAV_IMAGE=clamav/clamav@sha256:<reviewed-digest>
|
||||
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib@sha256:<reviewed-digest>
|
||||
REDIS_EXPORTER_IMAGE=oliver006/redis_exporter@sha256:<reviewed-digest>
|
||||
NGINX_EXPORTER_IMAGE=nginx/nginx-prometheus-exporter@sha256:<reviewed-digest>
|
||||
|
||||
PROCESSING_PUBLIC_HOST=<processing-public-host>
|
||||
PROCESSING_PRIVATE_BIND_ADDRESS=<vm2-private-ip>
|
||||
|
||||
@@ -15,3 +15,8 @@ BITRIX_SYNC_CONTACT_SOURCE=WEB
|
||||
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS=203.0.113.0/24
|
||||
BITRIX_SYNC_HTTP_TIMEOUT_SEC=10
|
||||
BITRIX_SYNC_DB_POOL_SIZE=5
|
||||
# Optional: without this endpoint the service keeps JSON stdout and runs normally.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
RELEASE_VERSION=unknown
|
||||
APP_ENV=production-like
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
@@ -16,6 +16,22 @@ Disabled mode требует только `BITRIX_SYNC_ENABLED=false` и
|
||||
валидирует весь каталог secret files, portal identity, custom fields, HTTPS host
|
||||
lock и непустой CIDR allow-list до startup.
|
||||
|
||||
## Наблюдаемость
|
||||
|
||||
JSON stdout включён всегда. Если задан стандартный
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT`, traces, metrics и логи дополнительно отправляются
|
||||
напрямую по OTLP/gRPC через bounded batch queues. Ошибка инициализации, экспорта
|
||||
или остановки телеметрии не меняет readiness и не останавливает API, worker или
|
||||
reconciliation. Процессы различаются как `bitrix-sync-api`,
|
||||
`bitrix-sync-worker` и `bitrix-sync-reconciliation`; namespace остаётся
|
||||
`han-chat`.
|
||||
|
||||
FastAPI (кроме `/health/live`), SQLAlchemy и HTTPX инструментированы. Перед
|
||||
stdout и OTLP выполняется application redaction: query/token/credential URL,
|
||||
headers, payload, PII, object keys и SQL не экспортируются. Бизнес-метрики
|
||||
используют только закрытые множества labels; UUID и внешние идентификаторы в
|
||||
labels/spans не записываются.
|
||||
|
||||
## Границы безопасности
|
||||
|
||||
- CRM credential URL используется как единый секрет; redirect выключен, TLS
|
||||
|
||||
@@ -5,10 +5,14 @@ import ssl
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
from opentelemetry.trace import SpanKind
|
||||
|
||||
from app.telemetry import record_crm, record_retry, safe_span
|
||||
|
||||
|
||||
class CrmOutcome(StrEnum):
|
||||
@@ -49,6 +53,28 @@ class CrmClient:
|
||||
await self._client.aclose()
|
||||
|
||||
async def call(self, method: str, params: dict[str, Any], *, mutating: bool) -> CrmResult:
|
||||
started = monotonic()
|
||||
outcome = "error"
|
||||
operation = method.rsplit(".", 1)[-1]
|
||||
try:
|
||||
with safe_span(
|
||||
"bitrix_sync.crm.request",
|
||||
kind=SpanKind.CLIENT,
|
||||
attributes={"crm.operation": operation},
|
||||
):
|
||||
result = await self._call(method, params, mutating=mutating)
|
||||
outcome = result.outcome.value
|
||||
if result.outcome in {
|
||||
CrmOutcome.RETRY,
|
||||
CrmOutcome.UNCERTAIN,
|
||||
CrmOutcome.RATE_LIMITED,
|
||||
}:
|
||||
record_retry("crm")
|
||||
return result
|
||||
finally:
|
||||
record_crm(method, outcome, monotonic() - started)
|
||||
|
||||
async def _call(self, method: str, params: dict[str, Any], *, mutating: bool) -> CrmResult:
|
||||
if method not in ALLOWED_METHODS:
|
||||
raise ValueError("unapproved CRM method")
|
||||
url = urljoin(self._base_url, method + ".json")
|
||||
|
||||
@@ -20,6 +20,13 @@ from app.domain import (
|
||||
validate_phone,
|
||||
)
|
||||
from app.repository import LeasedTask, LeasedWebhook, Profile, Repository
|
||||
from app.telemetry import (
|
||||
record_business_alert,
|
||||
record_dead_letter,
|
||||
record_limiter,
|
||||
record_retry,
|
||||
record_transition,
|
||||
)
|
||||
|
||||
INSERT_CRM_COMMAND = text(
|
||||
"""
|
||||
@@ -81,6 +88,7 @@ class WorkflowEngine:
|
||||
await self._manual(workflow_id, exc.code, task.user_id)
|
||||
await self.repository.complete_task(task, workflow_id)
|
||||
except RetryableWorkflow as exc:
|
||||
record_retry("task")
|
||||
delay = exc.retry_after or full_jitter_delay(
|
||||
task.attempt_count + 1,
|
||||
self.settings.retry_base_seconds,
|
||||
@@ -508,10 +516,12 @@ class WorkflowEngine:
|
||||
)
|
||||
if limiter_delay <= 0:
|
||||
break
|
||||
record_limiter(limiter_delay)
|
||||
await asyncio.sleep(limiter_delay)
|
||||
async with self._in_flight:
|
||||
result = await self.crm.call(method, params, mutating=mutating)
|
||||
status = result.outcome.value
|
||||
record_transition("command", status)
|
||||
async with self.repository.transaction() as connection:
|
||||
await connection.execute(
|
||||
UPDATE_CRM_COMMAND,
|
||||
@@ -580,6 +590,7 @@ class WorkflowEngine:
|
||||
*,
|
||||
selected_external_id: str | None = None,
|
||||
) -> None:
|
||||
record_business_alert(alert_type)
|
||||
fingerprint = safe_hash(f"{alert_type}:{user_id}")
|
||||
assert fingerprint is not None
|
||||
async with self.repository.transaction() as connection:
|
||||
@@ -727,6 +738,7 @@ class WorkflowEngine:
|
||||
)
|
||||
|
||||
async def _technical_failure(self, workflow_id: uuid.UUID, code: str) -> None:
|
||||
record_dead_letter("crm_command")
|
||||
async with self.repository.transaction() as connection:
|
||||
await connection.execute(
|
||||
text(
|
||||
|
||||
@@ -12,20 +12,38 @@ from sqlalchemy import text
|
||||
from app.config import Settings, load_settings
|
||||
from app.repository import Repository
|
||||
from app.security import WebhookValidationError, parse_bounded_form, validate_webhook
|
||||
from app.telemetry import (
|
||||
init_telemetry,
|
||||
instrument_fastapi,
|
||||
log_event,
|
||||
record_webhook,
|
||||
shutdown_telemetry,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = load_settings()
|
||||
app.state.settings = settings
|
||||
app.state.repository = (
|
||||
Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||
if settings.enabled and settings.database_url
|
||||
else None
|
||||
)
|
||||
yield
|
||||
if app.state.repository:
|
||||
await app.state.repository.close()
|
||||
init_telemetry("bitrix-sync-api")
|
||||
app.state.repository = None
|
||||
try:
|
||||
settings = load_settings()
|
||||
app.state.settings = settings
|
||||
app.state.repository = (
|
||||
Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||
if settings.enabled and settings.database_url
|
||||
else None
|
||||
)
|
||||
log_event(
|
||||
"service.started",
|
||||
"Bitrix sync API started",
|
||||
attributes={"mode": settings.mode},
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
if app.state.repository:
|
||||
await app.state.repository.close()
|
||||
log_event("service.stopped", "Bitrix sync API stopped")
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -35,6 +53,7 @@ app = FastAPI(
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
instrument_fastapi(app)
|
||||
|
||||
|
||||
def settings(request: Request) -> Settings:
|
||||
@@ -107,29 +126,30 @@ async def alert_webhook(
|
||||
|
||||
|
||||
async def _receive(request: Request, receiver: str, query: dict[str, str]) -> Response:
|
||||
config = settings(request)
|
||||
if not config.enabled:
|
||||
raise HTTPException(status_code=503, detail="sync_disabled")
|
||||
if request.headers.get("content-type", "").split(";", 1)[0].lower() != (
|
||||
"application/x-www-form-urlencoded"
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="invalid_content_type")
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and (
|
||||
not content_length.isdigit() or int(content_length) > config.webhook_max_body_bytes
|
||||
):
|
||||
raise HTTPException(status_code=413, detail="body_too_large")
|
||||
body = await request.body()
|
||||
if len(body) > config.webhook_max_body_bytes:
|
||||
raise HTTPException(status_code=413, detail="body_too_large")
|
||||
form = parse_bounded_form(body, max_fields=config.webhook_max_fields)
|
||||
# The container is reachable only from the trusted VM2 nginx network.
|
||||
# nginx overwrites X-Real-IP from the TCP peer after its CIDR check.
|
||||
source_ip = request.headers.get("x-real-ip") or (request.client.host if request.client else "")
|
||||
alert_entity_type_id = (
|
||||
await _alert_entity_type(repository(request)) if receiver == "alert" else None
|
||||
)
|
||||
try:
|
||||
config = settings(request)
|
||||
if not config.enabled:
|
||||
raise HTTPException(status_code=503, detail="sync_disabled")
|
||||
if request.headers.get("content-type", "").split(";", 1)[0].lower() != (
|
||||
"application/x-www-form-urlencoded"
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="invalid_content_type")
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and (
|
||||
not content_length.isdigit() or int(content_length) > config.webhook_max_body_bytes
|
||||
):
|
||||
raise HTTPException(status_code=413, detail="body_too_large")
|
||||
body = await request.body()
|
||||
if len(body) > config.webhook_max_body_bytes:
|
||||
raise HTTPException(status_code=413, detail="body_too_large")
|
||||
form = parse_bounded_form(body, max_fields=config.webhook_max_fields)
|
||||
# nginx overwrites X-Real-IP from the TCP peer after its CIDR check.
|
||||
source_ip = request.headers.get("x-real-ip") or (
|
||||
request.client.host if request.client else ""
|
||||
)
|
||||
alert_entity_type_id = (
|
||||
await _alert_entity_type(repository(request)) if receiver == "alert" else None
|
||||
)
|
||||
event = validate_webhook(
|
||||
receiver,
|
||||
query,
|
||||
@@ -139,12 +159,21 @@ async def _receive(request: Request, receiver: str, query: dict[str, str]) -> Re
|
||||
alert_entity_type_id=alert_entity_type_id,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
record_webhook(receiver, "rejected")
|
||||
raise HTTPException(status_code=403, detail="forbidden") from exc
|
||||
except WebhookValidationError as exc:
|
||||
record_webhook(receiver, "rejected")
|
||||
raise HTTPException(status_code=400, detail="malformed_webhook") from exc
|
||||
except HTTPException:
|
||||
record_webhook(receiver, "rejected")
|
||||
raise
|
||||
except Exception:
|
||||
record_webhook(receiver, "error")
|
||||
raise
|
||||
await repository(request).insert_webhook(
|
||||
event.receiver_type, event.event_type, event.entity_id, event.source_ip
|
||||
)
|
||||
record_webhook(receiver, "accepted")
|
||||
return Response(status_code=202)
|
||||
|
||||
|
||||
@@ -170,4 +199,5 @@ def run() -> None:
|
||||
host="0.0.0.0", # noqa: S104 - container-only port, not host-published
|
||||
port=8080,
|
||||
proxy_headers=False,
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
@@ -9,6 +10,13 @@ from sqlalchemy import text
|
||||
from app.config import load_settings
|
||||
from app.crm import CrmClient, CrmOutcome
|
||||
from app.repository import Repository
|
||||
from app.telemetry import (
|
||||
init_telemetry,
|
||||
log_event,
|
||||
record_reconciliation,
|
||||
safe_span,
|
||||
shutdown_telemetry,
|
||||
)
|
||||
|
||||
|
||||
class IncrementalReconciler:
|
||||
@@ -127,25 +135,44 @@ class IncrementalReconciler:
|
||||
|
||||
|
||||
async def reconciliation_main() -> None:
|
||||
settings = load_settings()
|
||||
if not settings.enabled:
|
||||
return
|
||||
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
|
||||
assert settings.contact_registered_field
|
||||
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||
crm = CrmClient(
|
||||
settings.crm_rest_webhook_url.get_secret_value(),
|
||||
settings.portal_host,
|
||||
settings.http_timeout_sec,
|
||||
)
|
||||
reconciler = IncrementalReconciler(
|
||||
repository, crm, settings.rest_field_name(settings.contact_registered_field)
|
||||
)
|
||||
init_telemetry("bitrix-sync-reconciliation")
|
||||
repository: Repository | None = None
|
||||
crm: CrmClient | None = None
|
||||
started = monotonic()
|
||||
outcome = "error"
|
||||
count = 0
|
||||
try:
|
||||
await reconciler.run_once(settings.reconciliation_overlap_seconds)
|
||||
settings = load_settings()
|
||||
if not settings.enabled:
|
||||
outcome = "skipped"
|
||||
log_event("reconciliation.disabled", "Bitrix sync reconciliation is disabled")
|
||||
return
|
||||
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
|
||||
assert settings.contact_registered_field
|
||||
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||
crm = CrmClient(
|
||||
settings.crm_rest_webhook_url.get_secret_value(),
|
||||
settings.portal_host,
|
||||
settings.http_timeout_sec,
|
||||
)
|
||||
reconciler = IncrementalReconciler(
|
||||
repository, crm, settings.rest_field_name(settings.contact_registered_field)
|
||||
)
|
||||
with safe_span("bitrix_sync.reconciliation.run"):
|
||||
count = await reconciler.run_once(settings.reconciliation_overlap_seconds)
|
||||
outcome = "success"
|
||||
log_event(
|
||||
"reconciliation.completed",
|
||||
"Bitrix sync reconciliation completed",
|
||||
attributes={"scanned_count": count},
|
||||
)
|
||||
finally:
|
||||
await crm.close()
|
||||
await repository.close()
|
||||
record_reconciliation(outcome, count, started)
|
||||
if crm is not None:
|
||||
await crm.close()
|
||||
if repository is not None:
|
||||
await repository.close()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
||||
|
||||
from app.domain import safe_hash
|
||||
from app.telemetry import record_status_snapshot
|
||||
|
||||
|
||||
def postgres_ssl_context() -> ssl.SSLContext:
|
||||
@@ -541,6 +542,10 @@ class Repository:
|
||||
"SELECT status, count(*) count "
|
||||
"FROM bitrix_sync.crm_commands GROUP BY status"
|
||||
),
|
||||
"queue_oldest_age_seconds": (
|
||||
"SELECT coalesce(extract(epoch from now()-min(created_at)),0) "
|
||||
"FROM han_app.sync_queue WHERE status IN ('pending','retry_wait','leased')"
|
||||
),
|
||||
"webhook_lag_seconds": (
|
||||
"SELECT coalesce(extract(epoch from now()-min(received_at)),0) "
|
||||
"FROM bitrix_sync.webhook_inbox WHERE status IN ('received','retry_wait')"
|
||||
@@ -559,4 +564,5 @@ class Repository:
|
||||
else:
|
||||
output[key] = result.scalar_one_or_none()
|
||||
output["generated_at"] = datetime.now(UTC).isoformat()
|
||||
record_status_snapshot(output)
|
||||
return output
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from opentelemetry import metrics, trace
|
||||
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||||
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._logs import LoggerProvider, LoggingHandler
|
||||
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
||||
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.trace import SpanKind, Status, StatusCode
|
||||
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||
|
||||
SERVICE_NAMES = frozenset(
|
||||
{"bitrix-sync-api", "bitrix-sync-worker", "bitrix-sync-reconciliation"}
|
||||
)
|
||||
_SECRET_KEYS = re.compile(
|
||||
r"(authorization|cookie|token|secret|password|phone|email|full.?name|"
|
||||
r"message|payload|body|query|url|dsn|statement|object.?key|filename)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SENSITIVE_VALUES = (
|
||||
re.compile(r"(?i)\bbearer\s+\S+"),
|
||||
re.compile(r"(?i)\b(?:token|password|secret|code)=\S+"),
|
||||
re.compile(r"https?://[^\s?#]+[?#]\S+"),
|
||||
re.compile(r"\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\b"),
|
||||
re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b"),
|
||||
re.compile(r"(?<!\w)\+?\d[\d ()-]{8,}\d(?!\w)"),
|
||||
)
|
||||
_SAFE_CODES = frozenset(
|
||||
{
|
||||
"success",
|
||||
"retry",
|
||||
"uncertain",
|
||||
"permanent",
|
||||
"rate_limited",
|
||||
"error",
|
||||
"disabled",
|
||||
"skipped",
|
||||
}
|
||||
)
|
||||
_SAFE_TASK_TYPES = frozenset(
|
||||
{"contact.map_or_create", "contact.update", "contact.deactivate", "contact.webhook", "rebind"}
|
||||
)
|
||||
_SAFE_RECEIVERS = frozenset({"contact", "alert", "unknown"})
|
||||
_SAFE_CRM_OPERATIONS = frozenset({"batch", "get", "add", "update", "list", "findbycomm"})
|
||||
_SAFE_QUEUE_STATES = frozenset(
|
||||
{"pending", "retry_wait", "leased", "processed", "received", "processing", "failed"}
|
||||
)
|
||||
_SAFE_ALERT_TYPES = frozenset(
|
||||
{
|
||||
"duplicate_contacts",
|
||||
"contact_owned_by_other_user",
|
||||
"mapping_identity_mismatch",
|
||||
"rebind_target_owned",
|
||||
"unknown_citizenship_enum",
|
||||
"technical_configuration_failure",
|
||||
}
|
||||
)
|
||||
_configured_service_name = "bitrix-sync-api"
|
||||
_runtime: TelemetryRuntime | None = None
|
||||
_logging_configured = False
|
||||
|
||||
|
||||
def _safe_scalar(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
text = str(value)
|
||||
for pattern in _SENSITIVE_VALUES:
|
||||
text = pattern.sub("[REDACTED]", text)
|
||||
return text[:512]
|
||||
|
||||
|
||||
def redact(value: Any, *, key: str = "") -> Any:
|
||||
"""Return a bounded telemetry-safe copy without mutating application data."""
|
||||
if key and _SECRET_KEYS.search(key):
|
||||
return "[REDACTED]"
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(item_key)[:64]: redact(item, key=str(item_key))
|
||||
for item_key, item in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [redact(item) for item in list(value)[:32]]
|
||||
return _safe_scalar(value)
|
||||
|
||||
|
||||
def _trace_fields() -> dict[str, str]:
|
||||
context = trace.get_current_span().get_span_context()
|
||||
if not context.is_valid:
|
||||
return {}
|
||||
return {
|
||||
"trace_id": format(context.trace_id, "032x"),
|
||||
"span_id": format(context.span_id, "016x"),
|
||||
}
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
event = {
|
||||
"timestamp": (
|
||||
datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
),
|
||||
"level": record.levelname,
|
||||
"service.name": _configured_service_name,
|
||||
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
|
||||
"environment": os.getenv("APP_ENV", "production-like"),
|
||||
"module": record.name,
|
||||
"event": getattr(
|
||||
record,
|
||||
"event_name",
|
||||
"log",
|
||||
),
|
||||
"message": record.getMessage(),
|
||||
**_trace_fields(),
|
||||
}
|
||||
attributes = getattr(record, "telemetry_attributes", None)
|
||||
if isinstance(attributes, Mapping):
|
||||
event.update(attributes)
|
||||
if record.exc_info:
|
||||
event["error.type"] = record.exc_info[0].__name__
|
||||
return json.dumps(redact(event), ensure_ascii=False, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
class RedactionFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.msg = redact(record.getMessage()) if hasattr(record, "event_name") else "[REDACTED]"
|
||||
record.args = ()
|
||||
attributes = getattr(record, "telemetry_attributes", None)
|
||||
safe_attributes = dict(attributes) if isinstance(attributes, Mapping) else {}
|
||||
if record.exc_info:
|
||||
safe_attributes["error.type"] = record.exc_info[0].__name__
|
||||
record.exc_info = None
|
||||
record.exc_text = None
|
||||
record.telemetry_attributes = redact(safe_attributes)
|
||||
return True
|
||||
|
||||
|
||||
class RedactingBatchSpanProcessor(BatchSpanProcessor):
|
||||
"""Remove sensitive auto-instrumentation attributes before queueing/export."""
|
||||
|
||||
def on_end(self, span: Any) -> None:
|
||||
attributes = getattr(span, "_attributes", None)
|
||||
if isinstance(attributes, Mapping):
|
||||
sanitized = dict(attributes)
|
||||
for key in list(sanitized):
|
||||
key_text = str(key)
|
||||
if _SECRET_KEYS.search(key_text) or key_text in {
|
||||
"http.target",
|
||||
"url.full",
|
||||
"db.statement",
|
||||
}:
|
||||
sanitized[key] = "[REDACTED]"
|
||||
else:
|
||||
sanitized[key] = _safe_scalar(sanitized[key])
|
||||
span._attributes = sanitized
|
||||
super().on_end(span)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TelemetryRuntime:
|
||||
tracer_provider: TracerProvider | None = None
|
||||
meter_provider: MeterProvider | None = None
|
||||
logger_provider: LoggerProvider | None = None
|
||||
|
||||
def shutdown(self) -> None:
|
||||
for provider in (self.logger_provider, self.meter_provider, self.tracer_provider):
|
||||
if provider is None:
|
||||
continue
|
||||
try:
|
||||
provider.shutdown()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
"Telemetry shutdown failed",
|
||||
extra={"event_name": "telemetry.shutdown.failed"},
|
||||
)
|
||||
|
||||
|
||||
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 _configure_stdout(service_name: str) -> None:
|
||||
global _configured_service_name, _logging_configured
|
||||
_configured_service_name = service_name
|
||||
if _logging_configured:
|
||||
return
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
handler.addFilter(RedactionFilter())
|
||||
root = logging.getLogger()
|
||||
root.addHandler(handler)
|
||||
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
root.setLevel(getattr(logging, level_name, logging.INFO))
|
||||
_logging_configured = True
|
||||
|
||||
|
||||
def init_telemetry(service_name: str) -> TelemetryRuntime:
|
||||
"""Initialize all signals; exporter failures never stop the business process."""
|
||||
global _runtime
|
||||
if service_name not in SERVICE_NAMES:
|
||||
raise ValueError("unregistered bitrix-sync process service.name")
|
||||
_configure_stdout(service_name)
|
||||
if _runtime is not None:
|
||||
return _runtime
|
||||
runtime = TelemetryRuntime()
|
||||
_runtime = runtime
|
||||
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
|
||||
if not endpoint:
|
||||
return runtime
|
||||
|
||||
try:
|
||||
resource = _resource(service_name)
|
||||
insecure = endpoint.startswith("http://")
|
||||
set_global_textmap(TraceContextTextMapPropagator())
|
||||
|
||||
tracer_provider = TracerProvider(resource=resource)
|
||||
tracer_provider.add_span_processor(
|
||||
RedactingBatchSpanProcessor(
|
||||
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)
|
||||
runtime.tracer_provider = tracer_provider
|
||||
|
||||
metric_reader = PeriodicExportingMetricReader(
|
||||
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||
export_interval_millis=30_000,
|
||||
export_timeout_millis=3000,
|
||||
)
|
||||
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||
metrics.set_meter_provider(meter_provider)
|
||||
runtime.meter_provider = meter_provider
|
||||
|
||||
logger_provider = LoggerProvider(resource=resource)
|
||||
logger_provider.add_log_record_processor(
|
||||
BatchLogRecordProcessor(
|
||||
OTLPLogExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||
max_queue_size=2048,
|
||||
schedule_delay_millis=5000,
|
||||
max_export_batch_size=512,
|
||||
export_timeout_millis=3000,
|
||||
)
|
||||
)
|
||||
otlp_handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
|
||||
otlp_handler.addFilter(RedactionFilter())
|
||||
logging.getLogger().addHandler(otlp_handler)
|
||||
runtime.logger_provider = logger_provider
|
||||
|
||||
def sanitize_httpx_request(span: trace.Span, request: Any) -> None:
|
||||
if not span.is_recording():
|
||||
return
|
||||
url = request[1]
|
||||
host = getattr(url, "host", "")
|
||||
scheme = getattr(url, "scheme", "https")
|
||||
safe_url = f"{scheme}://{host}/[REDACTED]"
|
||||
span.set_attribute("url.full", safe_url)
|
||||
span.set_attribute("http.url", safe_url)
|
||||
|
||||
HTTPXClientInstrumentor().instrument(request_hook=sanitize_httpx_request)
|
||||
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception(
|
||||
"Telemetry initialization failed; stdout fallback remains active",
|
||||
extra={"event_name": "telemetry.init.failed"},
|
||||
)
|
||||
return runtime
|
||||
|
||||
|
||||
def instrument_fastapi(app: FastAPI) -> None:
|
||||
try:
|
||||
FastAPIInstrumentor.instrument_app(
|
||||
app,
|
||||
excluded_urls="/health/live",
|
||||
http_capture_headers_server_request=[],
|
||||
http_capture_headers_server_response=[],
|
||||
)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception(
|
||||
"FastAPI instrumentation failed",
|
||||
extra={"event_name": "telemetry.fastapi.failed"},
|
||||
)
|
||||
|
||||
|
||||
def shutdown_telemetry() -> None:
|
||||
global _runtime
|
||||
if _runtime is not None:
|
||||
_runtime.shutdown()
|
||||
_runtime = None
|
||||
|
||||
|
||||
def log_event(
|
||||
event: str,
|
||||
message: str,
|
||||
*,
|
||||
level: int = logging.INFO,
|
||||
attributes: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
logging.getLogger("bitrix_sync").log(
|
||||
level,
|
||||
message,
|
||||
extra={"event_name": event, "telemetry_attributes": redact(attributes or {})},
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def safe_span(
|
||||
name: str,
|
||||
*,
|
||||
kind: SpanKind = SpanKind.INTERNAL,
|
||||
attributes: Mapping[str, Any] | None = None,
|
||||
) -> Iterator[trace.Span]:
|
||||
safe_attributes: dict[str, bool | int | float | str] = {}
|
||||
for key, value in redact(attributes or {}).items():
|
||||
if not isinstance(value, (bool, int, float, str)):
|
||||
continue
|
||||
key_text = str(key)[:64]
|
||||
if key_text == "workflow.type":
|
||||
value = _bounded(str(value), _SAFE_TASK_TYPES)
|
||||
elif key_text == "crm.operation":
|
||||
value = _bounded(str(value), _SAFE_CRM_OPERATIONS)
|
||||
elif key_text == "receiver":
|
||||
value = _bounded(str(value), _SAFE_RECEIVERS, "unknown")
|
||||
safe_attributes[key_text] = value
|
||||
with trace.get_tracer("han.bitrix_sync").start_as_current_span(
|
||||
name[:128], kind=kind, attributes=safe_attributes
|
||||
) as span:
|
||||
try:
|
||||
yield span
|
||||
except Exception as exc:
|
||||
span.set_status(Status(StatusCode.ERROR, type(exc).__name__))
|
||||
raise
|
||||
|
||||
|
||||
def _bounded(value: str, allowed: frozenset[str], fallback: str = "other") -> str:
|
||||
return value if value in allowed else fallback
|
||||
|
||||
|
||||
def _metric_fail_open(function: Any) -> Any:
|
||||
def wrapped(*args: Any, **kwargs: Any) -> None:
|
||||
try:
|
||||
function(*args, **kwargs)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_claim(queue: str, count: int) -> None:
|
||||
queue_name = _bounded(queue, frozenset({"task", "webhook", "rebind"}))
|
||||
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||
"bitrix_sync_claimed_items", unit="{item}"
|
||||
).add(max(0, count), {"queue": queue_name})
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_workflow(task_type: str, outcome: str, duration_seconds: float) -> None:
|
||||
attrs = {
|
||||
"workflow.type": _bounded(task_type, _SAFE_TASK_TYPES),
|
||||
"outcome": _bounded(outcome, _SAFE_CODES),
|
||||
}
|
||||
meter = metrics.get_meter("han.bitrix_sync")
|
||||
meter.create_counter("bitrix_sync_workflows", unit="{workflow}").add(1, attrs)
|
||||
meter.create_histogram("bitrix_sync_workflow_duration", unit="s").record(
|
||||
max(0.0, duration_seconds), attrs
|
||||
)
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_webhook(receiver: str, outcome: str) -> None:
|
||||
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||
"bitrix_sync_webhooks", unit="{webhook}"
|
||||
).add(
|
||||
1,
|
||||
{
|
||||
"receiver": _bounded(receiver, _SAFE_RECEIVERS, "unknown"),
|
||||
"outcome": _bounded(outcome, frozenset({"accepted", "rejected", "error"})),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_crm(method: str, outcome: str, duration_seconds: float) -> None:
|
||||
operation = method.rsplit(".", 1)[-1]
|
||||
operation = _bounded(operation, _SAFE_CRM_OPERATIONS)
|
||||
attrs = {"operation": operation, "outcome": _bounded(outcome, _SAFE_CODES)}
|
||||
meter = metrics.get_meter("han.bitrix_sync")
|
||||
meter.create_counter("bitrix_sync_crm_calls", unit="{call}").add(1, attrs)
|
||||
meter.create_histogram("bitrix_sync_crm_duration", unit="s").record(
|
||||
max(0.0, duration_seconds), attrs
|
||||
)
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_limiter(delay_seconds: float) -> None:
|
||||
metrics.get_meter("han.bitrix_sync").create_histogram(
|
||||
"bitrix_sync_limiter_delay", unit="s"
|
||||
).record(max(0.0, delay_seconds))
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_transition(kind: str, state: str) -> None:
|
||||
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||
"bitrix_sync_transitions", unit="{transition}"
|
||||
).add(
|
||||
1,
|
||||
{
|
||||
"kind": _bounded(kind, frozenset({"workflow", "command", "webhook"})),
|
||||
"state": _bounded(
|
||||
state,
|
||||
frozenset(
|
||||
{
|
||||
"succeeded",
|
||||
"retry",
|
||||
"uncertain",
|
||||
"permanent",
|
||||
"rate_limited",
|
||||
"waiting_manual",
|
||||
"processed",
|
||||
"error",
|
||||
}
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_retry(kind: str) -> None:
|
||||
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||
"bitrix_sync_retries", unit="{retry}"
|
||||
).add(1, {"kind": _bounded(kind, frozenset({"task", "webhook", "rebind", "crm"}))})
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_dead_letter(operation: str) -> None:
|
||||
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||
"bitrix_sync_dead_letters", unit="{item}"
|
||||
).add(1, {"operation": _bounded(operation, frozenset({"crm_command"}))})
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_business_alert(alert_type: str) -> None:
|
||||
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||
"bitrix_sync_business_alerts", unit="{alert}"
|
||||
).add(1, {"alert.type": _bounded(alert_type, _SAFE_ALERT_TYPES)})
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_status_snapshot(status: Mapping[str, Any]) -> None:
|
||||
meter = metrics.get_meter("han.bitrix_sync")
|
||||
depth = meter.create_histogram("bitrix_sync_queue_depth", unit="{item}")
|
||||
for queue_name in ("queue", "workflows", "commands"):
|
||||
values = status.get(queue_name)
|
||||
if not isinstance(values, Mapping):
|
||||
continue
|
||||
for state, count in values.items():
|
||||
if isinstance(count, int):
|
||||
depth.record(
|
||||
max(0, count),
|
||||
{
|
||||
"queue": queue_name,
|
||||
"state": _bounded(str(state), _SAFE_QUEUE_STATES),
|
||||
},
|
||||
)
|
||||
for key in ("queue_oldest_age_seconds", "webhook_lag_seconds"):
|
||||
value = status.get(key)
|
||||
if isinstance(value, (int, float)):
|
||||
meter.create_histogram(f"bitrix_sync_{key}", unit="s").record(max(0.0, value))
|
||||
|
||||
|
||||
@_metric_fail_open
|
||||
def record_reconciliation(outcome: str, count: int, started: float) -> None:
|
||||
attrs = {"outcome": _bounded(outcome, frozenset({"success", "error", "skipped"}))}
|
||||
meter = metrics.get_meter("han.bitrix_sync")
|
||||
meter.create_counter("bitrix_sync_reconciliation_runs", unit="{run}").add(1, attrs)
|
||||
meter.create_histogram("bitrix_sync_reconciliation_duration", unit="s").record(
|
||||
max(0.0, monotonic() - started), attrs
|
||||
)
|
||||
meter.create_histogram("bitrix_sync_reconciliation_items", unit="{item}").record(
|
||||
max(0, count), attrs
|
||||
)
|
||||
@@ -4,43 +4,62 @@ import asyncio
|
||||
import signal
|
||||
import socket
|
||||
import uuid
|
||||
from time import monotonic
|
||||
|
||||
from app.config import load_settings
|
||||
from app.crm import CrmClient
|
||||
from app.domain import full_jitter_delay
|
||||
from app.engine import RetryableWorkflow, WorkflowEngine
|
||||
from app.repository import Repository
|
||||
from app.telemetry import (
|
||||
init_telemetry,
|
||||
log_event,
|
||||
record_claim,
|
||||
record_retry,
|
||||
record_workflow,
|
||||
safe_span,
|
||||
shutdown_telemetry,
|
||||
)
|
||||
|
||||
|
||||
async def worker_main() -> None:
|
||||
settings = load_settings()
|
||||
if not settings.enabled:
|
||||
return
|
||||
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
|
||||
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||
crm = CrmClient(
|
||||
settings.crm_rest_webhook_url.get_secret_value(),
|
||||
settings.portal_host,
|
||||
settings.http_timeout_sec,
|
||||
)
|
||||
engine = WorkflowEngine(repository, crm, settings)
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for event in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(event, stop.set)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
worker_id = f"{socket.gethostname()}:{uuid.uuid4()}"
|
||||
init_telemetry("bitrix-sync-worker")
|
||||
repository: Repository | None = None
|
||||
crm: CrmClient | None = None
|
||||
try:
|
||||
settings = load_settings()
|
||||
if not settings.enabled:
|
||||
log_event("worker.disabled", "Bitrix sync worker is disabled")
|
||||
return
|
||||
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
|
||||
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||
crm = CrmClient(
|
||||
settings.crm_rest_webhook_url.get_secret_value(),
|
||||
settings.portal_host,
|
||||
settings.http_timeout_sec,
|
||||
)
|
||||
engine = WorkflowEngine(repository, crm, settings)
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for event in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(event, stop.set)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
worker_id = f"{socket.gethostname()}:{uuid.uuid4()}"
|
||||
log_event("worker.started", "Bitrix sync worker started")
|
||||
while not stop.is_set():
|
||||
tasks = await repository.claim_tasks(
|
||||
worker_id, settings.claim_size, settings.lease_seconds
|
||||
)
|
||||
webhooks = await repository.claim_webhooks(
|
||||
worker_id, settings.claim_size, settings.lease_seconds
|
||||
)
|
||||
rebind_ids = await repository.pending_rebind_ids(settings.claim_size)
|
||||
with safe_span("bitrix_sync.worker.claim"):
|
||||
tasks = await repository.claim_tasks(
|
||||
worker_id, settings.claim_size, settings.lease_seconds
|
||||
)
|
||||
webhooks = await repository.claim_webhooks(
|
||||
worker_id, settings.claim_size, settings.lease_seconds
|
||||
)
|
||||
rebind_ids = await repository.pending_rebind_ids(settings.claim_size)
|
||||
record_claim("task", len(tasks))
|
||||
record_claim("webhook", len(webhooks))
|
||||
record_claim("rebind", len(rebind_ids))
|
||||
if not tasks and not webhooks and not rebind_ids:
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=1)
|
||||
@@ -49,29 +68,68 @@ async def worker_main() -> None:
|
||||
for task in tasks:
|
||||
if stop.is_set():
|
||||
break
|
||||
await engine.process(task)
|
||||
started = monotonic()
|
||||
outcome = "success"
|
||||
try:
|
||||
with safe_span(
|
||||
"bitrix_sync.worker.process",
|
||||
attributes={"workflow.type": task.task_type},
|
||||
):
|
||||
await engine.process(task)
|
||||
except Exception:
|
||||
outcome = "error"
|
||||
raise
|
||||
finally:
|
||||
record_workflow(task.task_type, outcome, monotonic() - started)
|
||||
for item in webhooks:
|
||||
if stop.is_set():
|
||||
break
|
||||
started = monotonic()
|
||||
outcome = "success"
|
||||
try:
|
||||
await engine.process_webhook(item)
|
||||
with safe_span(
|
||||
"bitrix_sync.worker.webhook",
|
||||
attributes={"receiver": item.receiver_type},
|
||||
):
|
||||
await engine.process_webhook(item)
|
||||
except RetryableWorkflow as exc:
|
||||
outcome = "retry"
|
||||
record_retry("webhook")
|
||||
delay = exc.retry_after or full_jitter_delay(
|
||||
item.attempt_count + 1,
|
||||
settings.retry_base_seconds,
|
||||
settings.retry_max_seconds,
|
||||
)
|
||||
await repository.retry_webhook(item, exc.code, delay)
|
||||
except Exception:
|
||||
outcome = "error"
|
||||
raise
|
||||
finally:
|
||||
record_workflow("contact.webhook", outcome, monotonic() - started)
|
||||
for request_id in rebind_ids:
|
||||
if stop.is_set():
|
||||
break
|
||||
started = monotonic()
|
||||
outcome = "success"
|
||||
try:
|
||||
await engine.process_rebind(request_id)
|
||||
with safe_span("bitrix_sync.worker.rebind"):
|
||||
await engine.process_rebind(request_id)
|
||||
except RetryableWorkflow:
|
||||
outcome = "retry"
|
||||
record_retry("rebind")
|
||||
continue
|
||||
except Exception:
|
||||
outcome = "error"
|
||||
raise
|
||||
finally:
|
||||
record_workflow("rebind", outcome, monotonic() - started)
|
||||
finally:
|
||||
await crm.close()
|
||||
await repository.close()
|
||||
if crm is not None:
|
||||
await crm.close()
|
||||
if repository is not None:
|
||||
await repository.close()
|
||||
log_event("worker.stopped", "Bitrix sync worker stopped")
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
|
||||
@@ -8,6 +8,12 @@ dependencies = [
|
||||
"asyncpg>=0.30,<1",
|
||||
"fastapi>=0.116,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"opentelemetry-api>=1.36,<2",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.36,<2",
|
||||
"opentelemetry-instrumentation-fastapi>=0.57b0,<1",
|
||||
"opentelemetry-instrumentation-httpx>=0.57b0,<1",
|
||||
"opentelemetry-instrumentation-sqlalchemy>=0.57b0,<1",
|
||||
"opentelemetry-sdk>=1.36,<2",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"python-multipart>=0.0.20,<1",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
|
||||
from app import telemetry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"canary",
|
||||
[
|
||||
"Bearer canary-authorization-value",
|
||||
"person@example.test",
|
||||
"+7 (999) 123-45-67",
|
||||
"https://portal.example/path?token=canary",
|
||||
"aaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbb.cccccccccc",
|
||||
],
|
||||
)
|
||||
def test_redaction_removes_canaries_from_json_stdout(canary: str) -> None:
|
||||
record = logging.LogRecord(
|
||||
"test",
|
||||
logging.INFO,
|
||||
__file__,
|
||||
1,
|
||||
"safe event %s",
|
||||
(canary,),
|
||||
None,
|
||||
)
|
||||
record.event_name = "test.canary"
|
||||
record.telemetry_attributes = {
|
||||
"authorization": canary,
|
||||
"nested": {"email": canary, "safe": canary},
|
||||
}
|
||||
|
||||
telemetry.RedactionFilter().filter(record)
|
||||
output = telemetry.JsonFormatter().format(record)
|
||||
|
||||
assert canary not in output
|
||||
assert json.loads(output)["event"] == "test.canary"
|
||||
|
||||
|
||||
def test_redaction_blocks_sensitive_keys_and_bounds_collections() -> None:
|
||||
result = telemetry.redact(
|
||||
{
|
||||
"db.statement": "SELECT secret FROM users",
|
||||
"object_key": "private/file.txt",
|
||||
"safe": list(range(100)),
|
||||
}
|
||||
)
|
||||
|
||||
assert result["db.statement"] == "[REDACTED]"
|
||||
assert result["object_key"] == "[REDACTED]"
|
||||
assert len(result["safe"]) == 32
|
||||
|
||||
|
||||
def test_redaction_drops_exception_text_before_otlp() -> None:
|
||||
try:
|
||||
raise RuntimeError("secret-token-in-exception")
|
||||
except RuntimeError:
|
||||
record = logging.LogRecord(
|
||||
"test",
|
||||
logging.ERROR,
|
||||
__file__,
|
||||
1,
|
||||
"operation failed",
|
||||
(),
|
||||
exc_info=__import__("sys").exc_info(),
|
||||
)
|
||||
|
||||
telemetry.RedactionFilter().filter(record)
|
||||
|
||||
assert record.exc_info is None
|
||||
assert record.telemetry_attributes["error.type"] == "RuntimeError"
|
||||
assert "secret-token-in-exception" not in telemetry.JsonFormatter().format(record)
|
||||
|
||||
|
||||
def test_span_processor_replaces_immutable_sdk_attributes() -> None:
|
||||
exporter = InMemorySpanExporter()
|
||||
processor = telemetry.RedactingBatchSpanProcessor(exporter)
|
||||
span = ReadableSpan(
|
||||
name="crm.request",
|
||||
attributes={"url.full": "https://crm.example/?token=secret", "safe.outcome": "success"},
|
||||
)
|
||||
|
||||
processor.on_end(span)
|
||||
processor.shutdown()
|
||||
|
||||
assert span.attributes["url.full"] == "[REDACTED]"
|
||||
assert span.attributes["safe.outcome"] == "success"
|
||||
|
||||
|
||||
def test_init_without_endpoint_is_backward_compatible(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
||||
monkeypatch.setattr(telemetry, "_runtime", None)
|
||||
|
||||
runtime = telemetry.init_telemetry("bitrix-sync-worker")
|
||||
|
||||
assert runtime.tracer_provider is None
|
||||
assert runtime.meter_provider is None
|
||||
assert runtime.logger_provider is None
|
||||
telemetry.shutdown_telemetry()
|
||||
|
||||
|
||||
def test_each_process_has_distinct_service_resource() -> None:
|
||||
names = {
|
||||
telemetry._resource(name).attributes["service.name"] # noqa: SLF001
|
||||
for name in telemetry.SERVICE_NAMES
|
||||
}
|
||||
|
||||
assert names == {
|
||||
"bitrix-sync-api",
|
||||
"bitrix-sync-worker",
|
||||
"bitrix-sync-reconciliation",
|
||||
}
|
||||
|
||||
|
||||
def test_init_is_fail_open_when_exporter_construction_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4317")
|
||||
monkeypatch.setattr(telemetry, "_runtime", None)
|
||||
|
||||
def fail_exporter(**_kwargs: Any) -> None:
|
||||
raise RuntimeError("collector unavailable")
|
||||
|
||||
monkeypatch.setattr(telemetry, "OTLPSpanExporter", fail_exporter)
|
||||
|
||||
runtime = telemetry.init_telemetry("bitrix-sync-reconciliation")
|
||||
|
||||
assert runtime.tracer_provider is None
|
||||
telemetry.shutdown_telemetry()
|
||||
|
||||
|
||||
def test_business_metric_attributes_are_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: list[dict[str, str]] = []
|
||||
|
||||
class Instrument:
|
||||
def add(self, _value: int, attributes: dict[str, str]) -> None:
|
||||
captured.append(attributes)
|
||||
|
||||
def record(self, _value: float, attributes: dict[str, str]) -> None:
|
||||
captured.append(attributes)
|
||||
|
||||
class Meter:
|
||||
def create_counter(self, *_args: Any, **_kwargs: Any) -> Instrument:
|
||||
return Instrument()
|
||||
|
||||
def create_histogram(self, *_args: Any, **_kwargs: Any) -> Instrument:
|
||||
return Instrument()
|
||||
|
||||
monkeypatch.setattr(telemetry.metrics, "get_meter", lambda _name: Meter())
|
||||
|
||||
telemetry.record_workflow("user-2f3c0a5e-identifier", "novel-state", 0.5)
|
||||
telemetry.record_crm("unknown.dynamic.method", "novel-state", 0.1)
|
||||
|
||||
assert captured
|
||||
assert all("user-2f3c0a5e-identifier" not in values.values() for values in captured)
|
||||
assert all("novel-state" not in values.values() for values in captured)
|
||||
|
||||
|
||||
def test_business_metrics_are_fail_open(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def unavailable(_name: str) -> None:
|
||||
raise RuntimeError("metrics unavailable")
|
||||
|
||||
monkeypatch.setattr(telemetry.metrics, "get_meter", unavailable)
|
||||
|
||||
telemetry.record_claim("task", 1)
|
||||
@@ -34,8 +34,11 @@ The full step-by-step procedure with gates and copy-paste commands is in
|
||||
expose unauthenticated `PING` only for health and a password-protected
|
||||
`safety` user limited to required `han:safety:*` keys/commands. The password
|
||||
in `MESSAGE_SAFETY_REDIS_URL` must match. Start from
|
||||
`redis/redis-safety.acl.template`, replace
|
||||
`REPLACE_WITH_LONG_RANDOM_PASSWORD`, and never commit the password.
|
||||
`redis/redis-safety.acl.template`, replace both placeholders, and add the
|
||||
`exporter` user limited to `PING`/`INFO`. Its ACL password must exactly match
|
||||
the separate `REDIS_EXPORTER_PASSWORD` secret. That secret is a JSON password
|
||||
map, `{"redis://redis-safety:6379":"<THE_SAME_PASSWORD>"}`, not a raw password
|
||||
string. Never commit either password.
|
||||
8. Provision distinct runtime and migration DB credentials.
|
||||
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` may migrate/activate policy while
|
||||
`MESSAGE_SAFETY_DATABASE_URL` cannot; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
|
||||
@@ -76,13 +79,15 @@ Compose first:
|
||||
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
||||
3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record
|
||||
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, clamav,
|
||||
otel-collector).
|
||||
otel-collector, Redis exporter and nginx exporter).
|
||||
4. **Selectel Secrets Manager** — populate all remote names from
|
||||
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
|
||||
`REDIS_SAFETY_ACL`, internal TLS PEM for `8443`). Dedicated VM2 IAM principal
|
||||
with read-only access to those names only.
|
||||
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM for `8443`).
|
||||
Dedicated VM2 IAM principal with read-only access to those names only.
|
||||
5. **S3 quarantine bucket** and SigNoz OTLP endpoint — non-secret values in
|
||||
`.env`.
|
||||
`.env`. Current self-hosted SigNoz accepts private plaintext OTLP without
|
||||
authentication; do not provision a fake auth secret or mandatory non-empty
|
||||
header.
|
||||
6. **Internal TLS** — internal-CA certificate with SAN = VM2 private DNS; PEM
|
||||
stored in Secrets Manager, not in the release tree.
|
||||
|
||||
@@ -136,6 +141,11 @@ Copy `.env.example` → `.env`, install loader config as
|
||||
password with `systemd-creds`, edit nginx allow-lists. Details:
|
||||
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §4.
|
||||
|
||||
The local Collector receives logs directly over OTLP (no `filelog`), scrapes
|
||||
only itself plus the Redis/nginx exporters, and collects host metrics through
|
||||
read-only `/hostfs`. Exporters and nginx `stub_status` use internal networks and
|
||||
`expose` only; they have no host-published ports.
|
||||
|
||||
## 5. PostgreSQL CA and initial public TLS
|
||||
|
||||
Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
|
||||
|
||||
@@ -37,8 +37,12 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
|
||||
7. `REDIS_SAFETY_ACL` — полный ACL-файл, а не просто пароль. Он должен
|
||||
открывать неаутентифицированный `PING` только для health и
|
||||
защищённого паролем пользователя `safety`, ограниченного необходимыми
|
||||
ключами/командами `han:safety:*`.
|
||||
(Пароль в `MESSAGE_SAFETY_REDIS_URL` должен совпадать. Используйте `redis/redis-safety.acl.template`, заменив `REPLACE_WITH_LONG_RANDOM_PASSWORD)
|
||||
ключами/командами `han:safety:*`. Добавьте пользователя `exporter` только с
|
||||
`PING`/`INFO`; его пароль в ACL должен в точности совпадать с отдельным
|
||||
`REDIS_EXPORTER_PASSWORD`. Этот secret хранится в формате JSON password map:
|
||||
`{"redis://redis-safety:6379":"<ТОТ_ЖЕ_ПАРОЛЬ>"}`, а не как голая строка.
|
||||
Пароль `safety` в `MESSAGE_SAFETY_REDIS_URL` также должен совпадать с ACL. Используйте
|
||||
`redis/redis-safety.acl.template`, заменив оба плейсхолдера.
|
||||
8. Выделите отдельные учётные данные БД для runtime и миграций.
|
||||
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` может мигрировать/активировать
|
||||
политику, а `MESSAGE_SAFETY_DATABASE_URL` — нет; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
|
||||
@@ -83,12 +87,14 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
|
||||
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
||||
3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`;
|
||||
получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis,
|
||||
clamav, otel-collector).
|
||||
clamav, otel-collector, Redis exporter, nginx exporter).
|
||||
4. **Selectel Secrets Manager** — заполнить все remote names из
|
||||
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
|
||||
`REDIS_SAFETY_ACL`, internal TLS PEM для `8443`). Отдельный IAM principal
|
||||
VM2 с read-only доступом только к этим именам.
|
||||
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM для `8443`).
|
||||
Отдельный IAM principal VM2 с read-only доступом только к этим именам.
|
||||
5. **S3 quarantine bucket** и SigNoz OTLP endpoint — значения в `.env`.
|
||||
Текущий self-hosted SigNoz принимает private plaintext OTLP без auth, поэтому
|
||||
не создавайте фиктивный auth-secret или обязательный непустой header.
|
||||
6. **Internal TLS** — сертификат внутренней CA с SAN = private DNS VM2;
|
||||
PEM хранится в Secrets Manager, не в каталоге релиза.
|
||||
|
||||
@@ -301,6 +307,13 @@ DSN, token, password, access/secret key туда не записываются.
|
||||
создайте отдельный VM2 IAM principal с read-only доступом только к remote names
|
||||
из mapping.
|
||||
|
||||
Локальный Collector принимает traces, metrics и logs напрямую по OTLP; чтение
|
||||
Docker JSON через `filelog` не используется. Prometheus receiver собирает только
|
||||
метрики самого Collector, `redis-exporter` и `nginx-exporter`, а `hostmetrics` —
|
||||
метрики VM через read-only `/hostfs`. Exporter-контейнеры имеют только `expose`
|
||||
во внутренних сетях и не публикуют host ports. В nginx endpoint
|
||||
`/stub_status` слушает только внутренний `8081`.
|
||||
|
||||
Зашифруйте пароль Selectel service user через systemd credentials, не помещая
|
||||
его в аргументы или history:
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ if [ -f "$ENV_FILE" ]; then
|
||||
true|false) ;;
|
||||
*) fail "OTEL_REMOTE_TLS_INSECURE must be exactly true or false" ;;
|
||||
esac
|
||||
for image_key in MESSAGE_SAFETY_IMAGE BITRIX_SYNC_IMAGE NGINX_IMAGE REDIS_IMAGE CLAMAV_IMAGE OTEL_COLLECTOR_IMAGE; do
|
||||
for image_key in MESSAGE_SAFETY_IMAGE BITRIX_SYNC_IMAGE NGINX_IMAGE REDIS_IMAGE CLAMAV_IMAGE OTEL_COLLECTOR_IMAGE REDIS_EXPORTER_IMAGE NGINX_EXPORTER_IMAGE; do
|
||||
image=$(/usr/bin/awk -F= -v key="$image_key" '$1 == key {print substr($0, index($0, "=") + 1)}' "$ENV_FILE")
|
||||
echo "$image" | /usr/bin/grep -Eq '@sha256:[0-9a-f]{64}$' ||
|
||||
fail "$image_key must be pinned by sha256 digest"
|
||||
@@ -70,6 +70,25 @@ if [ -f "$ENV_FILE" ]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
collector_config="$ROOT/observability/otel-collector.yaml"
|
||||
[ -f "$collector_config" ] || fail "OpenTelemetry Collector config is missing"
|
||||
if [ -f "$collector_config" ]; then
|
||||
/usr/bin/grep -Fq 'service.namespace, value: han-chat' "$collector_config" ||
|
||||
fail "Collector must enforce service.namespace=han-chat"
|
||||
/usr/bin/grep -Fq 'receivers: [otlp, prometheus, hostmetrics]' "$collector_config" ||
|
||||
fail "Collector metrics pipeline is incomplete"
|
||||
/usr/bin/grep -Fq 'receivers: [otlp]' "$collector_config" ||
|
||||
fail "Collector direct OTLP logs pipeline is missing"
|
||||
! /usr/bin/grep -Fq 'filelog' "$collector_config" ||
|
||||
fail "Collector filelog receiver is forbidden for direct OTLP logging"
|
||||
/usr/bin/grep -Fq 'redis-exporter:9121' "$collector_config" ||
|
||||
fail "Collector Redis exporter scrape target is missing"
|
||||
/usr/bin/grep -Fq 'nginx-exporter:9113' "$collector_config" ||
|
||||
fail "Collector nginx exporter scrape target is missing"
|
||||
/usr/bin/grep -Fq 'tail_sampling:' "$collector_config" ||
|
||||
fail "Collector tail sampling is missing"
|
||||
fi
|
||||
|
||||
bitrix_allowlist="$ROOT/nginx/allowlists/bitrix-webhook-allowlist.conf"
|
||||
private_allowlist="$ROOT/nginx/allowlists/private-caller-allowlist.conf"
|
||||
for allowlist in "$bitrix_allowlist" "$private_allowlist"; do
|
||||
@@ -106,7 +125,8 @@ BITRIX_SYNC_CRM_REST_WEBHOOK_URL
|
||||
BITRIX_SYNC_CONTACT_RECEIVER_TOKEN
|
||||
BITRIX_SYNC_ALERT_RECEIVER_TOKEN
|
||||
BITRIX_SYNC_SERVICE_TOKEN
|
||||
REDIS_SAFETY_ACL'
|
||||
REDIS_SAFETY_ACL
|
||||
REDIS_EXPORTER_PASSWORD'
|
||||
|
||||
if [ -f "$MANIFEST" ]; then
|
||||
old_ifs=$IFS
|
||||
@@ -123,6 +143,37 @@ if [ -f "$MANIFEST" ]; then
|
||||
done
|
||||
IFS=$old_ifs
|
||||
|
||||
redis_acl=$(/usr/bin/awk -F= \
|
||||
'$1 == "REDIS_SAFETY_ACL" {print substr($0, index($0, "=") + 1)}' \
|
||||
"$MANIFEST")
|
||||
redis_exporter_password_file=$(/usr/bin/awk -F= \
|
||||
'$1 == "REDIS_EXPORTER_PASSWORD" {print substr($0, index($0, "=") + 1)}' \
|
||||
"$MANIFEST")
|
||||
if [ -f "$redis_acl" ] && [ -f "$redis_exporter_password_file" ]; then
|
||||
/usr/bin/grep -Eq '^user exporter reset on >[^[:space:]]+ -@all \+ping \+info$' "$redis_acl" ||
|
||||
fail "Redis ACL must contain the restricted exporter user"
|
||||
if exporter_password=$(/usr/bin/python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
target = "redis://redis-safety:6379"
|
||||
with open(sys.argv[1], encoding="utf-8") as source:
|
||||
values = json.load(source)
|
||||
if set(values) != {target}:
|
||||
raise SystemExit("password map must contain exactly redis://redis-safety:6379")
|
||||
password = values[target]
|
||||
if not isinstance(password, str) or not password or any(char.isspace() for char in password):
|
||||
raise SystemExit("password must be a non-empty whitespace-free string")
|
||||
print(password, end="")
|
||||
' "$redis_exporter_password_file"); then
|
||||
/usr/bin/grep -Fq -- ">$exporter_password " "$redis_acl" ||
|
||||
fail "Redis exporter password map does not match REDIS_SAFETY_ACL"
|
||||
unset exporter_password
|
||||
else
|
||||
fail "REDIS_EXPORTER_PASSWORD must be a valid redis_exporter JSON password map"
|
||||
fi
|
||||
fi
|
||||
|
||||
internal_cert=$(/usr/bin/awk -F= \
|
||||
'$1 == "VM2_INTERNAL_TLS_CERTIFICATE" {print substr($0, index($0, "=") + 1)}' \
|
||||
"$MANIFEST")
|
||||
|
||||
@@ -90,6 +90,11 @@
|
||||
"remote": "vm2/REDIS_SAFETY_ACL",
|
||||
"consumers": ["redis-safety"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"REDIS_EXPORTER_PASSWORD": {
|
||||
"remote": "vm2/REDIS_EXPORTER_PASSWORD",
|
||||
"consumers": ["redis-exporter"],
|
||||
"max_bytes": 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ x-postgres-ca-volume: &postgres-ca-volume
|
||||
|
||||
x-message-safety-environment: &message-safety-environment
|
||||
APP_ENV: ${APP_ENV:?set APP_ENV}
|
||||
RELEASE_VERSION: ${RELEASE_VERSION:?set RELEASE_VERSION}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
MESSAGE_SAFETY_HOST: ${MESSAGE_SAFETY_HOST:-0.0.0.0}
|
||||
MESSAGE_SAFETY_PORT: ${MESSAGE_SAFETY_PORT:-8080}
|
||||
MESSAGE_SAFETY_WORKER_CONCURRENCY: ${MESSAGE_SAFETY_WORKER_CONCURRENCY:-5}
|
||||
@@ -36,6 +38,8 @@ x-message-safety-environment: &message-safety-environment
|
||||
|
||||
x-bitrix-sync-environment: &bitrix-sync-environment
|
||||
APP_ENV: ${APP_ENV:?set APP_ENV}
|
||||
RELEASE_VERSION: ${RELEASE_VERSION:?set RELEASE_VERSION}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
BITRIX_SYNC_ENABLED: ${BITRIX_SYNC_ENABLED:-false}
|
||||
BITRIX_SYNC_MODE: ${BITRIX_SYNC_MODE:-disabled}
|
||||
BITRIX_SYNC_PORTAL_HOST: ${BITRIX_SYNC_PORTAL_HOST:?set approved portal}
|
||||
@@ -91,7 +95,8 @@ services:
|
||||
nofile:
|
||||
soft: 4096
|
||||
hard: 4096
|
||||
networks: [public, backend]
|
||||
networks: [public, backend, observability]
|
||||
expose: ["8081"]
|
||||
depends_on:
|
||||
message-safety-api:
|
||||
condition: service_started
|
||||
@@ -130,6 +135,40 @@ services:
|
||||
mem_limit: 640m
|
||||
cpus: 1.0
|
||||
|
||||
redis-exporter:
|
||||
<<: *hardening
|
||||
image: ${REDIS_EXPORTER_IMAGE:?set immutable Redis exporter image digest}
|
||||
environment:
|
||||
REDIS_ADDR: redis://redis-safety:6379
|
||||
REDIS_USER: exporter
|
||||
REDIS_PASSWORD_FILE: /run/secrets/redis_exporter_password
|
||||
secrets:
|
||||
- redis_exporter_password
|
||||
networks: [backend, observability]
|
||||
expose: ["9121"]
|
||||
depends_on:
|
||||
redis-safety:
|
||||
condition: service_healthy
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=16m
|
||||
pids_limit: 50
|
||||
mem_limit: 64m
|
||||
cpus: 0.25
|
||||
|
||||
nginx-exporter:
|
||||
<<: *hardening
|
||||
image: ${NGINX_EXPORTER_IMAGE:?set immutable nginx exporter image digest}
|
||||
command:
|
||||
- --nginx.scrape-uri=http://nginx:8081/stub_status
|
||||
networks: [observability]
|
||||
expose: ["9113"]
|
||||
depends_on:
|
||||
nginx:
|
||||
condition: service_healthy
|
||||
pids_limit: 50
|
||||
mem_limit: 64m
|
||||
cpus: 0.25
|
||||
|
||||
clamd:
|
||||
<<: *hardening
|
||||
image: ${CLAMAV_IMAGE:?set immutable ClamAV image digest}
|
||||
@@ -203,6 +242,7 @@ services:
|
||||
volumes:
|
||||
- ./observability/otel-collector.yaml:/etc/otelcol-contrib/config.yaml:ro
|
||||
- otel-queue:/var/lib/otelcol/queue
|
||||
- /:/hostfs:ro
|
||||
networks:
|
||||
observability: {}
|
||||
telemetry-egress:
|
||||
@@ -210,9 +250,9 @@ services:
|
||||
depends_on:
|
||||
otel-queue-init:
|
||||
condition: service_completed_successfully
|
||||
expose: ["4317", "4318"]
|
||||
expose: ["4317", "4318", "13133", "8888"]
|
||||
healthcheck:
|
||||
test: ["CMD", "/otelcol-contrib", "components"]
|
||||
test: ["CMD", "/otelcol-contrib", "validate", "--config=/etc/otelcol-contrib/config.yaml"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -465,3 +505,5 @@ secrets:
|
||||
file: /run/han-chat/secrets/BITRIX_SYNC_SERVICE_TOKEN
|
||||
redis_safety_acl:
|
||||
file: /run/han-chat/secrets/REDIS_SAFETY_ACL
|
||||
redis_exporter_password:
|
||||
file: /run/han-chat/secrets/REDIS_EXPORTER_PASSWORD
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"""store safe async trace context on safety tasks
|
||||
|
||||
Revision ID: 0002_task_trace_context
|
||||
Revises: 0001_message_safety_v2
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0002_task_trace_context"
|
||||
down_revision = "0001_message_safety_v2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE message_safety.safety_tasks "
|
||||
"ADD COLUMN IF NOT EXISTS origin_trace_id bytea NULL"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE message_safety.safety_tasks "
|
||||
"ADD COLUMN IF NOT EXISTS origin_span_id bytea NULL"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE message_safety.safety_tasks "
|
||||
"ADD COLUMN IF NOT EXISTS origin_trace_flags integer NULL"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE message_safety.safety_tasks "
|
||||
"ADD COLUMN IF NOT EXISTS origin_tracestate varchar(512) NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE message_safety.safety_tasks DROP COLUMN IF EXISTS origin_tracestate"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE message_safety.safety_tasks DROP COLUMN IF EXISTS origin_trace_flags"
|
||||
)
|
||||
op.execute("ALTER TABLE message_safety.safety_tasks DROP COLUMN IF EXISTS origin_span_id")
|
||||
op.execute("ALTER TABLE message_safety.safety_tasks DROP COLUMN IF EXISTS origin_trace_id")
|
||||
@@ -8,11 +8,14 @@ import boto3
|
||||
import dns.asyncresolver
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
from opentelemetry import trace
|
||||
|
||||
from app.contracts import Attachment
|
||||
from app.file_pipeline import DependencyFailure, ObjectChanged
|
||||
from app.url_policy import DnsError, DnsNxDomain
|
||||
|
||||
tracer = trace.get_tracer("message-safety.dependencies")
|
||||
|
||||
|
||||
class TrustedDnsResolver:
|
||||
def __init__(self, nameservers: list[str]) -> None:
|
||||
@@ -22,6 +25,14 @@ class TrustedDnsResolver:
|
||||
|
||||
async def resolve(
|
||||
self, hostname: str
|
||||
) -> tuple[ipaddress.IPv4Address | ipaddress.IPv6Address, ...]:
|
||||
with tracer.start_as_current_span(
|
||||
"message_safety.dns.resolve", attributes={"server.address.type": "domain"}
|
||||
):
|
||||
return await self._resolve(hostname)
|
||||
|
||||
async def _resolve(
|
||||
self, hostname: str
|
||||
) -> tuple[ipaddress.IPv4Address | ipaddress.IPv6Address, ...]:
|
||||
found: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
||||
try:
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import hmac
|
||||
import json
|
||||
import uuid
|
||||
from time import perf_counter
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, Request
|
||||
@@ -14,6 +15,7 @@ from app.db import TaskStatus
|
||||
from app.rate_limit import RateLimited
|
||||
from app.repository import ConflictError
|
||||
from app.service import CapabilityUnavailable, SafetyService, TaskFailed
|
||||
from app.telemetry import logger, record_poll
|
||||
|
||||
CHECK_ADAPTER = TypeAdapter(CheckRequest)
|
||||
MAX_BODY = 16_384
|
||||
@@ -53,14 +55,39 @@ def create_app(service: SafetyService, token: str) -> FastAPI:
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_context(request: Request, call_next):
|
||||
started = perf_counter()
|
||||
supplied = request.headers.get("X-Request-ID")
|
||||
try:
|
||||
request.state.request_id = str(uuid.UUID(supplied)) if supplied else str(uuid.uuid4())
|
||||
except ValueError:
|
||||
request.state.request_id = str(uuid.uuid4())
|
||||
response = await call_next(request)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Safety request failed",
|
||||
extra={
|
||||
"event": "http_request_failed",
|
||||
"request_id": request.state.request_id,
|
||||
"route": _route_template(request),
|
||||
"method": request.method,
|
||||
},
|
||||
)
|
||||
raise
|
||||
response.headers["X-Request-ID"] = request.state.request_id
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
if request.url.path != "/health/live":
|
||||
logger.info(
|
||||
"Safety request completed",
|
||||
extra={
|
||||
"event": "http_request_completed",
|
||||
"request_id": request.state.request_id,
|
||||
"route": _route_template(request),
|
||||
"method": request.method,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round((perf_counter() - started) * 1000, 3),
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
@app.get("/health/live")
|
||||
@@ -197,8 +224,10 @@ def create_app(service: SafetyService, token: str) -> FastAPI:
|
||||
)
|
||||
task = await service.repository.task(parsed)
|
||||
if not task:
|
||||
record_poll("not_found")
|
||||
return error(404, "task_not_found", request.state.request_id)
|
||||
if task.status == TaskStatus.failed:
|
||||
record_poll("failed")
|
||||
return error(
|
||||
503,
|
||||
"task_failed",
|
||||
@@ -211,6 +240,7 @@ def create_app(service: SafetyService, token: str) -> FastAPI:
|
||||
},
|
||||
)
|
||||
if task.status in {TaskStatus.pending, TaskStatus.processing}:
|
||||
record_poll(task.status.value)
|
||||
result: Pending | Verdict = Pending(
|
||||
config_version=task.config_version,
|
||||
task_id=task.id,
|
||||
@@ -218,6 +248,7 @@ def create_app(service: SafetyService, token: str) -> FastAPI:
|
||||
rules_version=task.rules_version,
|
||||
)
|
||||
else:
|
||||
record_poll("terminal")
|
||||
result = service._verdict(
|
||||
task.status == TaskStatus.allowed,
|
||||
task.processing_mode,
|
||||
@@ -233,3 +264,8 @@ def create_app(service: SafetyService, token: str) -> FastAPI:
|
||||
return response
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _route_template(request: Request) -> str:
|
||||
route = request.scope.get("route")
|
||||
return getattr(route, "path", "unmatched")
|
||||
|
||||
@@ -145,6 +145,10 @@ class SafetyTask(Base):
|
||||
detector_version: Mapped[str] = mapped_column(String(128))
|
||||
scanner_engine: Mapped[str] = mapped_column(String(32))
|
||||
signatures_version: Mapped[str] = mapped_column(String(128))
|
||||
origin_trace_id: Mapped[bytes | None] = mapped_column(LargeBinary(16))
|
||||
origin_span_id: Mapped[bytes | None] = mapped_column(LargeBinary(8))
|
||||
origin_trace_flags: Mapped[int | None] = mapped_column(Integer)
|
||||
origin_tracestate: Mapped[str | None] = mapped_column(String(512))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -10,12 +10,14 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from opentelemetry import trace
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from pillow_heif import register_heif_opener
|
||||
|
||||
from app.contracts import Attachment
|
||||
|
||||
register_heif_opener()
|
||||
tracer = trace.get_tracer("message-safety.dependencies")
|
||||
|
||||
|
||||
class ObjectChanged(RuntimeError):
|
||||
@@ -122,6 +124,13 @@ class ClamAvInstream:
|
||||
self.host, self.port, self.timeout = host, port, timeout
|
||||
|
||||
async def scan(self, chunks: AsyncIterator[bytes]) -> str | None:
|
||||
with tracer.start_as_current_span(
|
||||
"message_safety.clamav.scan",
|
||||
attributes={"server.address.type": "clamav"},
|
||||
):
|
||||
return await self._scan(chunks)
|
||||
|
||||
async def _scan(self, chunks: AsyncIterator[bytes]) -> str | None:
|
||||
async def operation() -> str | None:
|
||||
reader, writer = await asyncio.open_connection(self.host, self.port)
|
||||
try:
|
||||
@@ -148,6 +157,10 @@ class ClamAvInstream:
|
||||
raise DependencyFailure("ClamAV unavailable") from exc
|
||||
|
||||
async def signatures_version(self) -> str:
|
||||
with tracer.start_as_current_span("message_safety.clamav.version"):
|
||||
return await self._signatures_version()
|
||||
|
||||
async def _signatures_version(self) -> str:
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self.host, self.port), 2.0
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.file_pipeline import ClamAvInstream, DependencyFailure
|
||||
from app.repository import Repository
|
||||
from app.service import SafetyService
|
||||
from app.settings import BootstrapSettings, EmergencyMode
|
||||
from app.telemetry import init_telemetry, instrument_fastapi, shutdown_telemetry
|
||||
|
||||
|
||||
async def build_runtime() -> tuple[object, object]:
|
||||
@@ -48,19 +49,24 @@ async def build_runtime() -> tuple[object, object]:
|
||||
signatures_version=signatures_version,
|
||||
)
|
||||
app = create_app(service, settings.service_token.get_secret_value())
|
||||
instrument_fastapi(app)
|
||||
return app, engine
|
||||
|
||||
|
||||
async def serve() -> None:
|
||||
settings = BootstrapSettings()
|
||||
app, engine = await build_runtime()
|
||||
init_telemetry("api")
|
||||
engine = None
|
||||
try:
|
||||
app, engine = await build_runtime()
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(app, host=settings.host, port=settings.port, proxy_headers=False)
|
||||
)
|
||||
await server.serve()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from time import perf_counter
|
||||
|
||||
from app.config import ActiveConfig
|
||||
from app.contracts import CheckRequest, FileCheck, Pending, TextCheck, Verdict
|
||||
@@ -19,6 +20,14 @@ from app.normalization import normalize_text
|
||||
from app.rate_limit import ConservativeRateLimiter
|
||||
from app.repository import QueueFull, Repository
|
||||
from app.settings import EmergencyMode
|
||||
from app.telemetry import (
|
||||
current_trace_fields,
|
||||
record_cache,
|
||||
record_check,
|
||||
record_dependency,
|
||||
record_file_rejection,
|
||||
record_runtime_state,
|
||||
)
|
||||
from app.url_policy import DnsError, Resolver, canonicalize, check_url, extract_urls
|
||||
|
||||
|
||||
@@ -54,6 +63,7 @@ class SafetyService:
|
||||
self.rate_limiter = ConservativeRateLimiter(
|
||||
config.document["rate"]["text_rps"], config.document["rate"]["file_rps"]
|
||||
)
|
||||
record_runtime_state("mock" if mode.mock else "standard", config.version)
|
||||
|
||||
def _verdict(
|
||||
self,
|
||||
@@ -74,6 +84,22 @@ class SafetyService:
|
||||
)
|
||||
|
||||
async def check(self, request: CheckRequest) -> Verdict | Pending:
|
||||
started = perf_counter()
|
||||
outcome = "error"
|
||||
try:
|
||||
result = await self._check(request)
|
||||
outcome = "pending" if isinstance(result, Pending) else result.verdict
|
||||
return result
|
||||
finally:
|
||||
record_check(
|
||||
request.content_kind,
|
||||
"mock" if self.mode.mock else "standard",
|
||||
outcome,
|
||||
(perf_counter() - started) * 1000,
|
||||
self.config.version,
|
||||
)
|
||||
|
||||
async def _check(self, request: CheckRequest) -> Verdict | Pending:
|
||||
digest = fingerprint(request)
|
||||
existing = await self.repository.get_request(request.message_id)
|
||||
if existing:
|
||||
@@ -101,6 +127,7 @@ class SafetyService:
|
||||
cache = await self.repository.text_cache(
|
||||
normalized.analysis_sha256, self.config.rules_version
|
||||
)
|
||||
record_cache("text_rules", cache is not None)
|
||||
if cache:
|
||||
deny_rule = cache.deny_rule_id
|
||||
else:
|
||||
@@ -145,6 +172,7 @@ class SafetyService:
|
||||
cached_link = await self.repository.link_cache(
|
||||
canonical.digest, self.config.rules_version, self.config.version
|
||||
)
|
||||
record_cache("link_verdict", cached_link is not None)
|
||||
if cached_link and cached_link.verdict == "deny":
|
||||
return await self._persist_sync(
|
||||
request,
|
||||
@@ -160,7 +188,9 @@ class SafetyService:
|
||||
_, rule = await check_url(
|
||||
raw, self.resolver, self.config.document["link"]["dns_lookup_timeout_sec"]
|
||||
)
|
||||
record_dependency("dns", "resolve", "success")
|
||||
except DnsError as exc:
|
||||
record_dependency("dns", "resolve", "error")
|
||||
raise CapabilityUnavailable("dns") from exc
|
||||
if rule != "url.nxdomain" and not cached_link:
|
||||
now = datetime.now(UTC)
|
||||
@@ -200,6 +230,7 @@ class SafetyService:
|
||||
set(self.config.document["file_policy"]["enabled_mime_types"]),
|
||||
)
|
||||
if rule:
|
||||
record_file_rejection(rule)
|
||||
return await self._persist_sync(
|
||||
request, digest, self._verdict(False, "standard", rule, self.config.rules_version)
|
||||
)
|
||||
@@ -207,6 +238,7 @@ class SafetyService:
|
||||
cached = await self.repository.file_cache(
|
||||
content_digest, self.config, self.signatures_version
|
||||
)
|
||||
record_cache("file_verdict", cached is not None)
|
||||
if cached:
|
||||
return await self._persist_sync(
|
||||
request,
|
||||
@@ -220,6 +252,7 @@ class SafetyService:
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
task_id = uuid.uuid4()
|
||||
trace_id, span_id, trace_flags, tracestate = current_trace_fields()
|
||||
task = SafetyTask(
|
||||
id=task_id,
|
||||
message_id=request.message_id,
|
||||
@@ -243,6 +276,10 @@ class SafetyService:
|
||||
detector_version=self.config.detector.version,
|
||||
scanner_engine="clamav",
|
||||
signatures_version=self.signatures_version,
|
||||
origin_trace_id=trace_id,
|
||||
origin_span_id=span_id,
|
||||
origin_trace_flags=trace_flags,
|
||||
origin_tracestate=tracestate,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -262,6 +299,7 @@ class SafetyService:
|
||||
row, task, max_pending=self.config.document["task"]["max_pending"]
|
||||
)
|
||||
except QueueFull as exc:
|
||||
record_dependency("postgresql", "queue_capacity", "full")
|
||||
raise CapabilityUnavailable("queue_capacity") from exc
|
||||
if created:
|
||||
await self._audit(
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from opentelemetry import metrics, trace
|
||||
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||||
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._logs import LoggerProvider, LoggingHandler
|
||||
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
||||
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.trace import Link, SpanContext, TraceFlags, TraceState
|
||||
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||
|
||||
SERVICE_NAME = "message-safety"
|
||||
SERVICE_NAMESPACE = "han-chat"
|
||||
_SENSITIVE_KEY = re.compile(
|
||||
r"(authorization|cookie|token|secret|password|phone|email|message|text|filename|"
|
||||
r"object[_-]?key|url|dsn|statement)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SENSITIVE_VALUE = re.compile(
|
||||
r"(?:https?://\S+\?\S+)|(?:[\w.+-]+@[\w.-]+\.[A-Za-z]{2,})|"
|
||||
r"(?:\+?\d[\d ()-]{8,}\d)|(?:bearer\s+\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_STANDARD_LOG_RECORD = set(logging.makeLogRecord({}).__dict__)
|
||||
_SAFE_STRUCTURED_KEYS = {
|
||||
"deployment.environment",
|
||||
"duration_ms",
|
||||
"environment",
|
||||
"event",
|
||||
"level",
|
||||
"method",
|
||||
"outcome",
|
||||
"process_role",
|
||||
"request_id",
|
||||
"route",
|
||||
"service.name",
|
||||
"service.version",
|
||||
"span_id",
|
||||
"status_code",
|
||||
"timestamp",
|
||||
"trace_id",
|
||||
}
|
||||
_runtime: TelemetryRuntime | None = None
|
||||
|
||||
|
||||
def _redact(value: Any, key: str = "") -> Any:
|
||||
if key in _SAFE_STRUCTURED_KEYS:
|
||||
return value
|
||||
if _SENSITIVE_KEY.search(key):
|
||||
return "[REDACTED]"
|
||||
if isinstance(value, dict):
|
||||
return {str(k)[:64]: _redact(v, str(k)) for k, v in list(value.items())[:64]}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_redact(item) for item in value[:32]]
|
||||
if isinstance(value, str):
|
||||
return _SENSITIVE_VALUE.sub("[REDACTED]", value)
|
||||
return value
|
||||
|
||||
|
||||
class RedactionFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.msg = _redact(record.getMessage())
|
||||
record.args = ()
|
||||
for key in tuple(record.__dict__):
|
||||
if key not in _STANDARD_LOG_RECORD:
|
||||
record.__dict__[key] = _redact(record.__dict__[key], key)
|
||||
return True
|
||||
|
||||
|
||||
def _redact_span_attributes(attributes: MutableMapping[str, Any]) -> None:
|
||||
for key in tuple(attributes):
|
||||
key_text = str(key)
|
||||
if _SENSITIVE_KEY.search(key_text) or key_text in {
|
||||
"http.target",
|
||||
"http.url",
|
||||
"url.full",
|
||||
"url.query",
|
||||
"db.statement",
|
||||
"db.query.text",
|
||||
}:
|
||||
attributes[key] = "[REDACTED]"
|
||||
else:
|
||||
attributes[key] = _redact(attributes[key], key_text)
|
||||
|
||||
|
||||
class RedactingBatchSpanProcessor(BatchSpanProcessor):
|
||||
"""Redact auto-instrumentation attributes before they enter the export queue."""
|
||||
|
||||
def on_end(self, span: Any) -> None:
|
||||
attributes = getattr(span, "_attributes", None)
|
||||
if isinstance(attributes, Mapping):
|
||||
sanitized = dict(attributes)
|
||||
_redact_span_attributes(sanitized)
|
||||
span._attributes = sanitized
|
||||
super().on_end(span)
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
context = trace.get_current_span().get_span_context()
|
||||
payload: dict[str, Any] = {
|
||||
"timestamp": datetime.now(UTC)
|
||||
.isoformat(timespec="milliseconds")
|
||||
.replace("+00:00", "Z"),
|
||||
"level": record.levelname,
|
||||
"service.name": SERVICE_NAME,
|
||||
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
|
||||
"environment": os.getenv("APP_ENV", "development"),
|
||||
"event": getattr(record, "event", "application_log"),
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if context.is_valid:
|
||||
payload["trace_id"] = f"{context.trace_id:032x}"
|
||||
payload["span_id"] = f"{context.span_id:016x}"
|
||||
for key in (
|
||||
"request_id",
|
||||
"route",
|
||||
"method",
|
||||
"status_code",
|
||||
"duration_ms",
|
||||
"process_role",
|
||||
"outcome",
|
||||
):
|
||||
value = getattr(record, key, None)
|
||||
if value is not None:
|
||||
payload[key] = value
|
||||
return json.dumps(_redact(payload), ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def configure_logging() -> logging.Logger:
|
||||
logger = logging.getLogger("message_safety")
|
||||
logger.setLevel(os.getenv("LOG_LEVEL", "INFO").upper())
|
||||
logger.propagate = False
|
||||
if not any(getattr(handler, "_message_safety_stdout", False) for handler in logger.handlers):
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler._message_safety_stdout = True # type: ignore[attr-defined]
|
||||
handler.addFilter(RedactionFilter())
|
||||
handler.setFormatter(JsonFormatter())
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
logger = configure_logging()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TelemetryRuntime:
|
||||
tracer_provider: TracerProvider
|
||||
meter_provider: MeterProvider
|
||||
logger_provider: LoggerProvider
|
||||
|
||||
def shutdown(self) -> None:
|
||||
for provider in (self.logger_provider, self.meter_provider, self.tracer_provider):
|
||||
try:
|
||||
provider.shutdown()
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Telemetry provider shutdown failed",
|
||||
extra={"event": "telemetry_shutdown_failed"},
|
||||
)
|
||||
|
||||
|
||||
def _resource(role: str) -> Resource:
|
||||
return Resource.create(
|
||||
{
|
||||
"service.name": SERVICE_NAME,
|
||||
"service.namespace": SERVICE_NAMESPACE,
|
||||
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
|
||||
"deployment.environment": os.getenv("APP_ENV", "development"),
|
||||
"process.role": role,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def init_telemetry(role: str) -> TelemetryRuntime | None:
|
||||
global _runtime
|
||||
if _runtime is not None:
|
||||
return _runtime
|
||||
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
|
||||
if not endpoint:
|
||||
logger.info(
|
||||
"OTLP endpoint is not configured; using JSON stdout",
|
||||
extra={"event": "telemetry_stdout_only", "process_role": role},
|
||||
)
|
||||
return None
|
||||
try:
|
||||
resource = _resource(role)
|
||||
insecure = endpoint.startswith("http://")
|
||||
set_global_textmap(TraceContextTextMapPropagator())
|
||||
|
||||
tracer_provider = TracerProvider(resource=resource)
|
||||
tracer_provider.add_span_processor(
|
||||
RedactingBatchSpanProcessor(
|
||||
OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||
max_queue_size=2048,
|
||||
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=30_000,
|
||||
export_timeout_millis=3000,
|
||||
)
|
||||
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||
metrics.set_meter_provider(meter_provider)
|
||||
|
||||
logger_provider = LoggerProvider(resource=resource)
|
||||
logger_provider.add_log_record_processor(
|
||||
BatchLogRecordProcessor(
|
||||
OTLPLogExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||
max_queue_size=2048,
|
||||
max_export_batch_size=512,
|
||||
export_timeout_millis=3000,
|
||||
)
|
||||
)
|
||||
otlp_handler = LoggingHandler(level=logging.INFO, logger_provider=logger_provider)
|
||||
otlp_handler.addFilter(RedactionFilter())
|
||||
logger.addHandler(otlp_handler)
|
||||
|
||||
HTTPXClientInstrumentor().instrument()
|
||||
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||
RedisInstrumentor().instrument()
|
||||
BotocoreInstrumentor().instrument()
|
||||
_runtime = TelemetryRuntime(tracer_provider, meter_provider, logger_provider)
|
||||
logger.info(
|
||||
"OpenTelemetry initialized",
|
||||
extra={"event": "telemetry_initialized", "process_role": role},
|
||||
)
|
||||
return _runtime
|
||||
except Exception:
|
||||
logger.error(
|
||||
"OpenTelemetry initialization failed; continuing with JSON stdout",
|
||||
extra={"event": "telemetry_init_failed", "process_role": role},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def instrument_fastapi(app: FastAPI) -> None:
|
||||
if _runtime is None:
|
||||
return
|
||||
try:
|
||||
FastAPIInstrumentor.instrument_app(app, excluded_urls="/health/live,/health/ready")
|
||||
except Exception:
|
||||
logger.error(
|
||||
"FastAPI instrumentation failed; continuing",
|
||||
extra={"event": "telemetry_instrumentation_failed"},
|
||||
)
|
||||
|
||||
|
||||
def shutdown_telemetry() -> None:
|
||||
global _runtime
|
||||
if _runtime is not None:
|
||||
_runtime.shutdown()
|
||||
_runtime = None
|
||||
|
||||
|
||||
_meter = metrics.get_meter("message-safety.business")
|
||||
checks = _meter.create_counter("message_safety.checks", description="Safety checks by outcome")
|
||||
check_duration = _meter.create_histogram(
|
||||
"message_safety.check.duration", unit="ms", description="Safety check duration"
|
||||
)
|
||||
cache_access = _meter.create_counter(
|
||||
"message_safety.cache.access", description="Safety cache accesses"
|
||||
)
|
||||
worker_tasks = _meter.create_counter(
|
||||
"message_safety.worker.tasks", description="Worker task outcomes"
|
||||
)
|
||||
worker_age = _meter.create_histogram(
|
||||
"message_safety.worker.task.age", unit="s", description="Age of claimed worker tasks"
|
||||
)
|
||||
dependency_calls = _meter.create_counter(
|
||||
"message_safety.dependency.calls", description="Dependency call outcomes"
|
||||
)
|
||||
polls = _meter.create_counter("message_safety.polls", description="Task poll outcomes")
|
||||
file_rejections = _meter.create_counter(
|
||||
"message_safety.file.rejections", description="File rejection categories"
|
||||
)
|
||||
runtime_state = _meter.create_up_down_counter(
|
||||
"message_safety.runtime.state", description="Current runtime mode and config"
|
||||
)
|
||||
|
||||
|
||||
def record_check(
|
||||
kind: str, mode: str, outcome: str, duration_ms: float, config_version: int
|
||||
) -> None:
|
||||
attributes = {
|
||||
"content_kind": kind,
|
||||
"processing_mode": mode,
|
||||
"outcome": outcome,
|
||||
"config_version": config_version,
|
||||
}
|
||||
checks.add(1, attributes)
|
||||
check_duration.record(duration_ms, attributes)
|
||||
|
||||
|
||||
def record_cache(cache: str, hit: bool) -> None:
|
||||
cache_access.add(1, {"cache": cache, "result": "hit" if hit else "miss"})
|
||||
|
||||
|
||||
def record_worker(outcome: str, age_seconds: float | None = None) -> None:
|
||||
worker_tasks.add(1, {"outcome": outcome})
|
||||
if age_seconds is not None:
|
||||
worker_age.record(max(age_seconds, 0), {"outcome": outcome})
|
||||
|
||||
|
||||
def record_dependency(dependency: str, operation: str, outcome: str) -> None:
|
||||
dependency_calls.add(
|
||||
1, {"dependency": dependency, "operation": operation, "outcome": outcome}
|
||||
)
|
||||
|
||||
|
||||
def record_poll(outcome: str) -> None:
|
||||
polls.add(1, {"outcome": outcome})
|
||||
|
||||
|
||||
def record_file_rejection(reason: str) -> None:
|
||||
file_rejections.add(1, {"reason": reason})
|
||||
|
||||
|
||||
def record_runtime_state(mode: str, config_version: int) -> None:
|
||||
runtime_state.add(1, {"processing_mode": mode, "config_version": config_version})
|
||||
|
||||
|
||||
def current_trace_fields() -> tuple[bytes | None, bytes | None, int | None, str | None]:
|
||||
context = trace.get_current_span().get_span_context()
|
||||
if not context.is_valid:
|
||||
return None, None, None, None
|
||||
return (
|
||||
context.trace_id.to_bytes(16, "big"),
|
||||
context.span_id.to_bytes(8, "big"),
|
||||
int(context.trace_flags),
|
||||
str(context.trace_state) or None,
|
||||
)
|
||||
|
||||
|
||||
def task_link(task: Any) -> Link | None:
|
||||
trace_id = getattr(task, "origin_trace_id", None)
|
||||
span_id = getattr(task, "origin_span_id", None)
|
||||
if not trace_id or not span_id:
|
||||
return None
|
||||
try:
|
||||
tracestate = getattr(task, "origin_tracestate", None)
|
||||
context = SpanContext(
|
||||
trace_id=int.from_bytes(trace_id, "big"),
|
||||
span_id=int.from_bytes(span_id, "big"),
|
||||
is_remote=True,
|
||||
trace_flags=TraceFlags(getattr(task, "origin_trace_flags", 0) or 0),
|
||||
trace_state=TraceState.from_header([tracestate] if tracestate else []),
|
||||
)
|
||||
return Link(context) if context.is_valid else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -5,6 +5,8 @@ import socket
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
from app.adapters import S3VersionReader
|
||||
from app.config import validate_config
|
||||
from app.contracts import Attachment
|
||||
@@ -19,6 +21,15 @@ from app.file_pipeline import (
|
||||
)
|
||||
from app.repository import Repository
|
||||
from app.settings import BootstrapSettings
|
||||
from app.telemetry import (
|
||||
init_telemetry,
|
||||
record_dependency,
|
||||
record_worker,
|
||||
shutdown_telemetry,
|
||||
task_link,
|
||||
)
|
||||
|
||||
tracer = trace.get_tracer("message-safety.worker")
|
||||
|
||||
|
||||
class Worker:
|
||||
@@ -34,14 +45,30 @@ class Worker:
|
||||
self.owner = f"{socket.gethostname()}:{uuid.uuid4()}"
|
||||
|
||||
async def once(self) -> bool:
|
||||
task = await self.repository.claim(self.owner)
|
||||
with tracer.start_as_current_span("message_safety.worker.claim"):
|
||||
task = await self.repository.claim(self.owner)
|
||||
if not task:
|
||||
return False
|
||||
link = task_link(task)
|
||||
with tracer.start_as_current_span(
|
||||
"message_safety.worker.process",
|
||||
links=[link] if link else (),
|
||||
attributes={
|
||||
"messaging.operation.type": "process",
|
||||
"message_safety.attempt": task.attempt_count,
|
||||
},
|
||||
):
|
||||
await self._process(task)
|
||||
return True
|
||||
|
||||
async def _process(self, task) -> None:
|
||||
task_age = (datetime.now(UTC) - task.created_at).total_seconds()
|
||||
row = await self.repository.config_version(task.config_version)
|
||||
rules, detector, digest = validate_config(row.config, self.artifacts)
|
||||
if digest != row.config_sha256:
|
||||
await self.repository.retry_or_fail(task, row.config["task"]["max_attempts"])
|
||||
return True
|
||||
record_worker("config_error", task_age)
|
||||
return
|
||||
stop = asyncio.Event()
|
||||
heartbeat = asyncio.create_task(
|
||||
self._heartbeat(
|
||||
@@ -65,18 +92,22 @@ class Worker:
|
||||
body, _ = await collect_and_hash(
|
||||
self.reader, attachment, max_size=row.config["file_policy"]["max_size_bytes"]
|
||||
)
|
||||
record_dependency("s3", "get_object", "success")
|
||||
rule = detect_format(body, attachment.mime_type)
|
||||
if not rule:
|
||||
malware = await self.antivirus.scan(one_chunk(body))
|
||||
record_dependency("clamav", "scan", "success")
|
||||
rule = "file.malware_detected" if malware else None
|
||||
finished = await self.repository.finish(
|
||||
task.id,
|
||||
self.owner,
|
||||
task.lease_generation,
|
||||
allow=rule is None,
|
||||
rule_id=rule or "safety.all_checks_passed",
|
||||
)
|
||||
with tracer.start_as_current_span("message_safety.worker.finalize"):
|
||||
finished = await self.repository.finish(
|
||||
task.id,
|
||||
self.owner,
|
||||
task.lease_generation,
|
||||
allow=rule is None,
|
||||
rule_id=rule or "safety.all_checks_passed",
|
||||
)
|
||||
if finished:
|
||||
record_worker("allow" if rule is None else "deny", task_age)
|
||||
now = datetime.now(UTC)
|
||||
await self.repository.put_file_cache(
|
||||
FileVerdictCache(
|
||||
@@ -111,19 +142,23 @@ class Worker:
|
||||
)
|
||||
)
|
||||
except ObjectChanged:
|
||||
await self.repository.finish(
|
||||
task.id,
|
||||
self.owner,
|
||||
task.lease_generation,
|
||||
allow=False,
|
||||
rule_id="file.object_changed",
|
||||
)
|
||||
record_dependency("s3", "get_object", "object_changed")
|
||||
with tracer.start_as_current_span("message_safety.worker.finalize"):
|
||||
await self.repository.finish(
|
||||
task.id,
|
||||
self.owner,
|
||||
task.lease_generation,
|
||||
allow=False,
|
||||
rule_id="file.object_changed",
|
||||
)
|
||||
record_worker("deny", task_age)
|
||||
except DependencyFailure:
|
||||
record_dependency("file_pipeline", "scan", "error")
|
||||
await self.repository.retry_or_fail(task, row.config["task"]["max_attempts"])
|
||||
record_worker("retry_or_fail", task_age)
|
||||
finally:
|
||||
stop.set()
|
||||
await heartbeat
|
||||
return True
|
||||
|
||||
async def _heartbeat(
|
||||
self, task_id, generation: int, interval: int, lease: int, stop: asyncio.Event
|
||||
@@ -144,25 +179,29 @@ class Worker:
|
||||
|
||||
async def serve() -> None:
|
||||
settings = BootstrapSettings()
|
||||
assert settings.database_url and settings.s3_access_key and settings.s3_secret_key
|
||||
engine, sessions = engine_and_sessions(settings.database_url.get_secret_value())
|
||||
worker = Worker(
|
||||
Repository(sessions),
|
||||
S3VersionReader(
|
||||
settings.s3_endpoint_url,
|
||||
settings.s3_bucket,
|
||||
settings.s3_access_key.get_secret_value(),
|
||||
settings.s3_secret_key.get_secret_value(),
|
||||
),
|
||||
ClamAvInstream(settings.clamav_host, settings.clamav_port),
|
||||
settings.artifacts_dir,
|
||||
)
|
||||
init_telemetry("worker")
|
||||
engine = None
|
||||
try:
|
||||
assert settings.database_url and settings.s3_access_key and settings.s3_secret_key
|
||||
engine, sessions = engine_and_sessions(settings.database_url.get_secret_value())
|
||||
worker = Worker(
|
||||
Repository(sessions),
|
||||
S3VersionReader(
|
||||
settings.s3_endpoint_url,
|
||||
settings.s3_bucket,
|
||||
settings.s3_access_key.get_secret_value(),
|
||||
settings.s3_secret_key.get_secret_value(),
|
||||
),
|
||||
ClamAvInstream(settings.clamav_host, settings.clamav_port),
|
||||
settings.artifacts_dir,
|
||||
)
|
||||
async with asyncio.TaskGroup() as group:
|
||||
for _ in range(settings.worker_concurrency):
|
||||
group.create_task(worker.loop())
|
||||
finally:
|
||||
await engine.dispose()
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
|
||||
@@ -11,6 +11,14 @@ dependencies = [
|
||||
"httpx>=0.27",
|
||||
"idna>=3.7",
|
||||
"jsonschema>=4.23",
|
||||
"opentelemetry-api>=1.36,<2",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.36,<2",
|
||||
"opentelemetry-instrumentation-botocore>=0.57b0,<1",
|
||||
"opentelemetry-instrumentation-fastapi>=0.57b0,<1",
|
||||
"opentelemetry-instrumentation-httpx>=0.57b0,<1",
|
||||
"opentelemetry-instrumentation-redis>=0.57b0,<1",
|
||||
"opentelemetry-instrumentation-sqlalchemy>=0.57b0,<1",
|
||||
"opentelemetry-sdk>=1.36,<2",
|
||||
"pillow>=10.4",
|
||||
"pillow-heif>=0.18",
|
||||
"pydantic-settings>=2.5",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags, TraceState
|
||||
|
||||
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("api") is None
|
||||
|
||||
|
||||
def test_resource_uses_canonical_namespace(monkeypatch) -> None:
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
resource = telemetry._resource("worker")
|
||||
|
||||
assert resource.attributes["service.name"] == "message-safety"
|
||||
assert resource.attributes["service.namespace"] == "han-chat"
|
||||
assert resource.attributes["deployment.environment"] == "test"
|
||||
assert resource.attributes["process.role"] == "worker"
|
||||
|
||||
|
||||
def test_json_stdout_redacts_sensitive_data_and_adds_trace_context() -> None:
|
||||
record = logging.LogRecord(
|
||||
"message_safety",
|
||||
logging.INFO,
|
||||
__file__,
|
||||
1,
|
||||
"Bearer top-secret user@example.org +79991234567 https://host/path?token=x",
|
||||
(),
|
||||
None,
|
||||
)
|
||||
record.event = "redaction_canary"
|
||||
record.authorization = "secret"
|
||||
telemetry.RedactionFilter().filter(record)
|
||||
context = SpanContext(
|
||||
trace_id=1,
|
||||
span_id=2,
|
||||
is_remote=False,
|
||||
trace_flags=TraceFlags(1),
|
||||
trace_state=TraceState(),
|
||||
)
|
||||
with trace.use_span(NonRecordingSpan(context)):
|
||||
payload = json.loads(telemetry.JsonFormatter().format(record))
|
||||
|
||||
serialized = json.dumps(payload)
|
||||
assert "top-secret" not in serialized
|
||||
assert "user@example.org" not in serialized
|
||||
assert "79991234567" not in serialized
|
||||
assert "?token=x" not in serialized
|
||||
assert payload["trace_id"] == f"{1:032x}"
|
||||
assert payload["span_id"] == f"{2:016x}"
|
||||
assert payload["service.name"] == "message-safety"
|
||||
|
||||
|
||||
def test_span_attributes_are_redacted_before_export() -> None:
|
||||
attributes = {
|
||||
"url.full": "https://storage.example/private?token=secret",
|
||||
"db.statement": "SELECT private_value FROM safety_tasks",
|
||||
"safe.outcome": "allow",
|
||||
}
|
||||
|
||||
telemetry._redact_span_attributes(attributes)
|
||||
|
||||
assert attributes["url.full"] == "[REDACTED]"
|
||||
assert attributes["db.statement"] == "[REDACTED]"
|
||||
assert attributes["safe.outcome"] == "allow"
|
||||
|
||||
|
||||
def test_span_processor_replaces_immutable_sdk_attributes() -> None:
|
||||
exporter = InMemorySpanExporter()
|
||||
processor = telemetry.RedactingBatchSpanProcessor(exporter)
|
||||
span = ReadableSpan(
|
||||
name="database.connect",
|
||||
attributes={"db.statement": "SELECT secret", "safe.outcome": "allow"},
|
||||
)
|
||||
|
||||
processor.on_end(span)
|
||||
processor.shutdown()
|
||||
|
||||
assert span.attributes["db.statement"] == "[REDACTED]"
|
||||
assert span.attributes["safe.outcome"] == "allow"
|
||||
|
||||
|
||||
def test_task_link_rebuilds_valid_remote_context() -> None:
|
||||
task = SimpleNamespace(
|
||||
origin_trace_id=(123).to_bytes(16, "big"),
|
||||
origin_span_id=(456).to_bytes(8, "big"),
|
||||
origin_trace_flags=1,
|
||||
origin_tracestate=None,
|
||||
)
|
||||
|
||||
link = telemetry.task_link(task)
|
||||
|
||||
assert link is not None
|
||||
assert link.context.is_remote
|
||||
assert link.context.trace_id == 123
|
||||
assert link.context.span_id == 456
|
||||
|
||||
|
||||
def test_current_trace_fields_round_trip_into_task_link() -> None:
|
||||
context = SpanContext(
|
||||
trace_id=123,
|
||||
span_id=456,
|
||||
is_remote=False,
|
||||
trace_flags=TraceFlags(1),
|
||||
trace_state=TraceState(),
|
||||
)
|
||||
with trace.use_span(NonRecordingSpan(context)):
|
||||
trace_id, span_id, flags, tracestate = telemetry.current_trace_fields()
|
||||
|
||||
link = telemetry.task_link(
|
||||
SimpleNamespace(
|
||||
origin_trace_id=trace_id,
|
||||
origin_span_id=span_id,
|
||||
origin_trace_flags=flags,
|
||||
origin_tracestate=tracestate,
|
||||
)
|
||||
)
|
||||
assert link is not None
|
||||
assert link.context.trace_id == context.trace_id
|
||||
assert link.context.span_id == context.span_id
|
||||
@@ -14,6 +14,10 @@ server {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
location = /stub_status {
|
||||
access_log off;
|
||||
stub_status;
|
||||
}
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,43 @@ receivers:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
prometheus:
|
||||
config:
|
||||
scrape_configs:
|
||||
- job_name: otel-collector
|
||||
scrape_interval: 30s
|
||||
static_configs:
|
||||
- targets: ["127.0.0.1:8888"]
|
||||
labels:
|
||||
service_name: otel-collector
|
||||
- job_name: redis-safety
|
||||
scrape_interval: 30s
|
||||
static_configs:
|
||||
- targets: ["redis-exporter:9121"]
|
||||
labels:
|
||||
service_name: redis
|
||||
- job_name: nginx
|
||||
scrape_interval: 30s
|
||||
static_configs:
|
||||
- targets: ["nginx-exporter:9113"]
|
||||
labels:
|
||||
service_name: nginx
|
||||
hostmetrics:
|
||||
root_path: /hostfs
|
||||
collection_interval: 30s
|
||||
scrapers:
|
||||
cpu:
|
||||
disk:
|
||||
filesystem:
|
||||
exclude_mount_points:
|
||||
mount_points:
|
||||
- /hostfs/(dev|proc|sys|run)($|/)
|
||||
match_type: regexp
|
||||
load:
|
||||
memory:
|
||||
network:
|
||||
paging:
|
||||
processes:
|
||||
|
||||
processors:
|
||||
memory_limiter:
|
||||
@@ -19,7 +56,7 @@ processors:
|
||||
spike_limit_mib: 96
|
||||
resource/vm2:
|
||||
attributes:
|
||||
- {key: service.namespace, value: han-processing, action: upsert}
|
||||
- {key: service.namespace, value: han-chat, action: upsert}
|
||||
- {key: deployment.environment, value: "${env:APP_ENV}", action: upsert}
|
||||
- {key: service.version, value: "${env:RELEASE_VERSION}", action: upsert}
|
||||
attributes/redact:
|
||||
@@ -39,6 +76,32 @@ processors:
|
||||
- {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"'
|
||||
logs:
|
||||
log_record:
|
||||
- 'severity_number < SEVERITY_NUMBER_INFO'
|
||||
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: 1000
|
||||
- name: baseline
|
||||
type: probabilistic
|
||||
probabilistic:
|
||||
sampling_percentage: 10
|
||||
batch:
|
||||
timeout: 5s
|
||||
send_batch_size: 1024
|
||||
@@ -64,13 +127,16 @@ service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [memory_limiter, resource/vm2, attributes/redact, batch]
|
||||
processors: [memory_limiter, resource/vm2, attributes/redact, filter/noise, tail_sampling, batch]
|
||||
exporters: [otlp/remote]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
receivers: [otlp, prometheus, hostmetrics]
|
||||
processors: [memory_limiter, resource/vm2, attributes/redact, batch]
|
||||
exporters: [otlp/remote]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [memory_limiter, resource/vm2, attributes/redact, batch]
|
||||
processors: [memory_limiter, resource/vm2, attributes/redact, filter/noise, batch]
|
||||
exporters: [otlp/remote]
|
||||
telemetry:
|
||||
metrics:
|
||||
address: 0.0.0.0:8888
|
||||
|
||||
@@ -3,3 +3,6 @@ user default reset on nopass -@all +ping
|
||||
|
||||
# Runtime cache user: replace the placeholder with a long random password.
|
||||
user safety reset on >REPLACE_WITH_LONG_RANDOM_PASSWORD ~han:safety:* -@all +ping +get +set
|
||||
|
||||
# Metrics user: use the password stored in the REDIS_EXPORTER_PASSWORD JSON map.
|
||||
user exporter reset on >REPLACE_WITH_EXPORTER_RANDOM_PASSWORD -@all +ping +info
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(relative: str) -> str:
|
||||
return (ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def service_block(compose: str, name: str) -> str:
|
||||
match = re.search(
|
||||
rf"^ {re.escape(name)}:\n(?P<body>(?:^(?: |\s*$).*\n?)*)",
|
||||
compose,
|
||||
re.MULTILINE,
|
||||
)
|
||||
assert match, f"missing Compose service {name}"
|
||||
return match.group("body")
|
||||
|
||||
|
||||
def test_collector_has_only_approved_prometheus_targets() -> None:
|
||||
config = read("observability/otel-collector.yaml")
|
||||
|
||||
targets = re.findall(r'targets: \["([^"]+)"\]', config)
|
||||
assert targets == [
|
||||
"127.0.0.1:8888",
|
||||
"redis-exporter:9121",
|
||||
"nginx-exporter:9113",
|
||||
]
|
||||
assert "service_name: otel-collector" in config
|
||||
assert "service_name: redis" in config
|
||||
assert "service_name: nginx" in config
|
||||
assert "hostmetrics:" in config
|
||||
assert "service.namespace, value: han-chat" in config
|
||||
assert "filter/noise:" in config
|
||||
assert "tail_sampling:" in config
|
||||
|
||||
|
||||
def test_logs_are_direct_otlp_without_filelog() -> None:
|
||||
config = read("observability/otel-collector.yaml")
|
||||
logs = config.split(" logs:\n", 1)[1]
|
||||
|
||||
assert "receivers: [otlp]" in logs
|
||||
assert "otlp/remote" in logs
|
||||
assert "filelog" not in config
|
||||
|
||||
|
||||
def test_apps_receive_release_and_logging_context() -> None:
|
||||
compose = read("docker-compose.yml")
|
||||
|
||||
assert compose.count("RELEASE_VERSION: ${RELEASE_VERSION:?set RELEASE_VERSION}") >= 3
|
||||
assert compose.count("LOG_LEVEL: ${LOG_LEVEL:-INFO}") == 2
|
||||
|
||||
|
||||
def test_collector_exposes_health_and_self_metrics_and_validates_config() -> None:
|
||||
compose = read("docker-compose.yml")
|
||||
collector = service_block(compose, "otel-collector")
|
||||
|
||||
assert 'expose: ["4317", "4318", "13133", "8888"]' in collector
|
||||
assert '"/otelcol-contrib", "validate"' in collector
|
||||
|
||||
|
||||
def test_exporters_are_internal_only_and_digest_pinned() -> None:
|
||||
compose = read("docker-compose.yml")
|
||||
|
||||
for name, port in (("redis-exporter", "9121"), ("nginx-exporter", "9113")):
|
||||
block = service_block(compose, name)
|
||||
assert f'expose: ["{port}"]' in block
|
||||
assert "\n ports:" not in block
|
||||
assert "observability" in block
|
||||
assert "${REDIS_EXPORTER_IMAGE:?set immutable Redis exporter image digest}" in compose
|
||||
assert "${NGINX_EXPORTER_IMAGE:?set immutable nginx exporter image digest}" in compose
|
||||
|
||||
|
||||
def test_nginx_status_is_internal_and_query_free() -> None:
|
||||
template = read("nginx/templates/10-vm2.conf.template")
|
||||
|
||||
status = template.split("location = /stub_status", 1)[1].split("}", 1)[0]
|
||||
assert "stub_status;" in status
|
||||
assert "access_log off;" in status
|
||||
assert "listen 8081;" in template
|
||||
assert ":8081:8081" not in read("docker-compose.yml")
|
||||
|
||||
|
||||
def test_redis_exporter_uses_restricted_acl_and_secret_file() -> None:
|
||||
acl = read("redis/redis-safety.acl.template")
|
||||
compose = read("docker-compose.yml")
|
||||
secret_config = json.loads(read("deployment/secrets/config.example.json"))
|
||||
|
||||
exporter_line = next(line for line in acl.splitlines() if line.startswith("user exporter "))
|
||||
assert "-@all +ping +info" in exporter_line
|
||||
assert "~*" not in exporter_line
|
||||
assert "REDIS_PASSWORD_FILE: /run/secrets/redis_exporter_password" in compose
|
||||
assert secret_config["secrets"]["REDIS_EXPORTER_PASSWORD"]["consumers"] == [
|
||||
"redis-exporter"
|
||||
]
|
||||
|
||||
|
||||
def test_self_hosted_signoz_does_not_require_fake_auth() -> None:
|
||||
collector = read("observability/otel-collector.yaml")
|
||||
env_example = read(".env.example")
|
||||
|
||||
assert "OTEL_REMOTE_AUTH" not in collector
|
||||
assert "OTEL_REMOTE_AUTH" not in env_example
|
||||
assert "headers:" not in collector
|
||||
|
||||
|
||||
def test_preflight_covers_observability_images_and_acl_pair() -> None:
|
||||
preflight = read("deployment/preflight.sh")
|
||||
|
||||
assert "REDIS_EXPORTER_IMAGE NGINX_EXPORTER_IMAGE" in preflight
|
||||
assert "REDIS_EXPORTER_PASSWORD" in preflight
|
||||
assert 'target = "redis://redis-safety:6379"' in preflight
|
||||
assert "Redis exporter password map does not match REDIS_SAFETY_ACL" in preflight
|
||||
assert "Collector must enforce service.namespace=han-chat" in preflight
|
||||
assert "Collector direct OTLP logs pipeline is missing" in preflight
|
||||
assert "Collector filelog receiver is forbidden" in preflight
|
||||
Reference in New Issue
Block a user