feat(cloud-api): add correlated lifecycle logging

This commit is contained in:
2026-07-12 18:37:51 +08:00
parent a5de7399f8
commit 8131c0124b
7 changed files with 256 additions and 1 deletions
@@ -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