feat(cloud-api): add correlated lifecycle logging
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
CORRELATION_HEADER = "X-Correlation-ID"
|
||||
_correlation_id: ContextVar[str | None] = ContextVar(
|
||||
"cloud_correlation_id",
|
||||
default=None,
|
||||
)
|
||||
_SENSITIVE_KEYS = {
|
||||
"authorization",
|
||||
"token",
|
||||
"bearer_token",
|
||||
"password",
|
||||
"screenshot",
|
||||
"ui_tree",
|
||||
"typed_text",
|
||||
"text_input",
|
||||
}
|
||||
|
||||
|
||||
def new_correlation_id() -> str:
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
def normalize_correlation_id(value: str | None) -> str:
|
||||
if value is None:
|
||||
return new_correlation_id()
|
||||
normalized = value.strip()
|
||||
if not normalized or len(normalized) > 128:
|
||||
return new_correlation_id()
|
||||
return normalized
|
||||
|
||||
|
||||
def bind_correlation_id(correlation_id: str) -> Token[str | None]:
|
||||
return _correlation_id.set(correlation_id)
|
||||
|
||||
|
||||
def reset_correlation_id(token: Token[str | None]) -> None:
|
||||
_correlation_id.reset(token)
|
||||
|
||||
|
||||
def current_correlation_id() -> str:
|
||||
correlation_id = _correlation_id.get()
|
||||
return correlation_id or new_correlation_id()
|
||||
|
||||
|
||||
def redact_sensitive_fields(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: "[REDACTED]"
|
||||
if key.lower() in _SENSITIVE_KEYS
|
||||
else redact_sensitive_fields(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [redact_sensitive_fields(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(redact_sensitive_fields(item) for item in value)
|
||||
return value
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
@@ -16,9 +17,13 @@ from cloud.db_models import (
|
||||
ScheduledTaskRow,
|
||||
TaskAttemptRow,
|
||||
)
|
||||
from cloud.observability import current_correlation_id
|
||||
from core.models import utc_now
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SQLAlchemyCloudRepository:
|
||||
"""SQLAlchemy adapter preserving the existing CloudStore CRUD surface."""
|
||||
|
||||
@@ -288,6 +293,7 @@ class SQLAlchemyCloudRepository:
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
_log_task_lifecycle("assigned", task)
|
||||
return _leased_assignment_from_row(task)
|
||||
|
||||
def claim_assignment(
|
||||
@@ -331,6 +337,7 @@ class SQLAlchemyCloudRepository:
|
||||
task.updated_at = _iso(now)
|
||||
attempt.status = "dispatched"
|
||||
session.flush()
|
||||
_log_task_lifecycle("claimed", task)
|
||||
return _leased_assignment_from_row(task)
|
||||
|
||||
def renew_lease(
|
||||
@@ -381,6 +388,7 @@ class SQLAlchemyCloudRepository:
|
||||
task.lease_expires_at = renewed_until
|
||||
task.updated_at = _iso(now)
|
||||
attempt_row.lease_expires_at = renewed_until
|
||||
_log_task_lifecycle("renewed", task)
|
||||
return "renewed"
|
||||
|
||||
def record_task_result(
|
||||
@@ -449,6 +457,7 @@ class SQLAlchemyCloudRepository:
|
||||
attempt_row.completed_at = completed_at_iso
|
||||
attempt_row.failure_reason = failure_reason
|
||||
attempt_row.result_json = result_json
|
||||
_log_task_lifecycle("completed" if status == "done" else "failed", task)
|
||||
return "recorded"
|
||||
|
||||
def reap_expired_leases(
|
||||
@@ -501,6 +510,10 @@ class SQLAlchemyCloudRepository:
|
||||
task.failure_reason = (
|
||||
f"lease expired after {task.attempt_count} attempts"
|
||||
)
|
||||
_log_task_lifecycle(
|
||||
"retried" if task.status == "queued" else "failed",
|
||||
task,
|
||||
)
|
||||
reaped_task_ids.append(task.id)
|
||||
return reaped_task_ids
|
||||
|
||||
@@ -641,6 +654,21 @@ def _parse_json_object(value: str | None) -> dict[str, Any] | None:
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _log_task_lifecycle(event: str, task: ScheduledTaskRow) -> None:
|
||||
logger.info(
|
||||
"cloud task lifecycle",
|
||||
extra={
|
||||
"event": event,
|
||||
"correlation_id": current_correlation_id(),
|
||||
"task_id": task.id,
|
||||
"host_id": task.assigned_host_id,
|
||||
"device_id": task.assigned_device_id,
|
||||
"attempt": task.attempt_count,
|
||||
"lease_id": task.lease_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _plugin_from_row(row: PluginRow) -> tuple[Any, bool]:
|
||||
from cloud.plugins import PluginManifest
|
||||
|
||||
|
||||
Reference in New Issue
Block a user