from __future__ import annotations import json import logging import os from datetime import UTC, datetime from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Final SCANNER_LOGGER_NAME: Final = "k1link.device_plugins.xgrids_k1" SCANNER_DIAGNOSTIC_FILE: Final = "scanner-diagnostics.jsonl" _HANDLER_MARKER: Final = "_mission_core_scanner_diagnostics_path" _EXTRA_FIELDS: Final = ( "event_code", "operation_id", "operation_stage", "connection_mode", "error_category", "error_code", "reason_code", "failed_phase", "dialogue_stage", "transport_state", "publish_attempts", "modeling_command_attempted", "outcome_unknown", "safe_to_retry", "side_effect_status", "network_change_attempted", "device_write_attempted", "device_write_confirmed", "ble_att_error_code", "ble_att_error_name", "helper_stage", "helper_elapsed_ms", "status_reconciliation", "network_change_admissible", "network_change_reconciliation", "mqtt_loop_result_code", "mqtt_loop_result_name", "mqtt_loop_phase", "automatic_retry", "camera_source_id", "evidence_session_id", "activation_trigger", "lease_generation", "lease_state", "recovery_strategy", "endpoint_reachable", "host_route_class", "address_changed", "device_write_performed", "failure_stage", "start_checkpoint_released", "stale_session_retired", "stream_id", "ui_build_id", "expected_ui_build_id", "document_instance_id", "viewer_instance_id", "lifecycle_generation", "backend_activity_sequence", "viewer_range_max_ns", "stalled_for_ms", "recovery_attempt", "camera_queue_bytes", "camera_queue_segments", "camera_queue_oldest_age_ms", "camera_queue_rejection_reason", "camera_retry_count", "camera_generation", "camera_pcl_admission_ms", "camera_authority_wait_ms", "camera_ffmpeg_prepare_ms", "camera_post_spawn_commit_ms", "camera_activation_total_ms", "device_command_sent", "websocket_close_code", "transport_epoch", "camera_append_error_name", "camera_media_source_state", "camera_video_error_code", "preferred_port", "selected_port", ) class ScannerDiagnosticJsonFormatter(logging.Formatter): """Serialize a bounded, secret-free scanner diagnostic record.""" def format(self, record: logging.LogRecord) -> str: document: dict[str, object] = { "timestamp_utc": datetime.fromtimestamp(record.created, UTC) .isoformat() .replace("+00:00", "Z"), "level": record.levelname, "logger": record.name, "message": record.getMessage()[:1000], } for field in _EXTRA_FIELDS: value = getattr(record, field, None) if value is not None and isinstance(value, (str, int, bool)): document[field] = value return json.dumps(document, ensure_ascii=False, separators=(",", ":")) def configure_scanner_diagnostics(logs_root: Path) -> Path: """Install one private rotating JSONL handler for K1 field diagnostics.""" logs_root.mkdir(mode=0o700, parents=True, exist_ok=True) os.chmod(logs_root, 0o700) target = (logs_root / SCANNER_DIAGNOSTIC_FILE).resolve() logger = logging.getLogger(SCANNER_LOGGER_NAME) for handler in logger.handlers: if getattr(handler, _HANDLER_MARKER, None) == str(target): return target handler = RotatingFileHandler( target, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8", delay=False, ) os.chmod(target, 0o600) handler.setLevel(logging.INFO) handler.setFormatter(ScannerDiagnosticJsonFormatter()) setattr(handler, _HANDLER_MARKER, str(target)) logger.addHandler(handler) logger.setLevel(logging.INFO) return target