Files
NODEDC_MISSION_CORE/src/k1link/web/runtime_diagnostics.py
T
DCCONSTRUCTIONS 1c7dd29d8a chore(node): preserve pre-canonicalization experiment snapshot
Historical working copy retained for audit before consolidation into main. The canonicalized plugin architecture and later fixes already live in main; this snapshot is not a release or a request to restore obsolete source layout.
2026-09-21 08:45:34 +03:00

168 lines
5.4 KiB
Python

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",
"transport_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",
"resolved_write_mode",
"max_write_without_response_size",
"mtu_size",
"frame_length",
"bridge_frame_length",
"quick_connect_frame_length",
"scan_elapsed_ms",
"scanner_start_ms",
"initial_window_ms",
"first_candidate_ms",
"scan_extended",
"candidate_count",
"likely_k1_candidate_count",
"discovery_generation",
"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
if record.exc_info is not None:
exception_type, _exception, traceback = record.exc_info
if exception_type is not None:
document["exception_type"] = exception_type.__name__[:128]
if traceback is not None:
while traceback.tb_next is not None:
traceback = traceback.tb_next
code = traceback.tb_frame.f_code
# Preserve the failure location, never exception text, locals,
# source lines or absolute paths that may contain private data.
document["exception_site"] = (
f"{Path(code.co_filename).name}:{traceback.tb_lineno}:{code.co_name}"
)[:256]
properties = getattr(record, "write_characteristic_properties", None)
if isinstance(properties, (list, tuple)) and all(
isinstance(value, str)
and value in {"read", "write", "write-without-response", "notify", "indicate"}
for value in properties
):
document["write_characteristic_properties"] = sorted(set(properties))
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