Добавлен сбор телеметрии на ВМ2
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user