diff --git a/kernel_ai/config.py b/kernel_ai/config.py index 372b4f2..ff071c3 100644 --- a/kernel_ai/config.py +++ b/kernel_ai/config.py @@ -16,3 +16,6 @@ class Config: API_PREFIX = "/api" SEND_FILE_MAX_AGE_DEFAULT = 0 if not DEBUG else 31536000 PROJECT_ROOT = PROJECT_ROOT + LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + LOG_FORMAT = os.getenv("LOG_FORMAT", "json") + LOG_SERVICE_NAME = os.getenv("LOG_SERVICE_NAME", "kernel-ai-backend") diff --git a/kernel_ai/hooks.py b/kernel_ai/hooks.py index 20874fe..bb177d5 100644 --- a/kernel_ai/hooks.py +++ b/kernel_ai/hooks.py @@ -1,12 +1,45 @@ -"""Global Flask hooks (CORS, cache headers).""" -from flask import current_app +"""Global Flask hooks (CORS, cache headers, request id).""" + +import logging +import time +from uuid import uuid4 + +from flask import current_app, g, request + +_http_access_logger = logging.getLogger("kernel_ai.http.access") def register_hooks(app): """Register after_request handler for CORS and static/HTML cache control.""" + @app.before_request + def assign_request_id(): + incoming = (request.headers.get("X-Request-ID") or "").strip() + g.request_id = incoming or uuid4().hex + g.request_started_ns = time.time_ns() + @app.after_request def add_headers(response): + request_id = getattr(g, "request_id", None) + if request_id: + response.headers["X-Request-ID"] = request_id + + started_ns = getattr(g, "request_started_ns", None) + duration_ns = (time.time_ns() - started_ns) if started_ns else None + _http_access_logger.info( + "http_request", + extra={ + "event_data": { + "event.dataset": "kernel_ai.http", + "request.id": request_id, + "http.request.method": request.method, + "url.path": request.path, + "http.response.status_code": response.status_code, + "event.duration": duration_ns, + "network.client.ip": request.headers.get("X-Forwarded-For", request.remote_addr), + } + }, + ) response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type" diff --git a/kernel_ai/logging_setup.py b/kernel_ai/logging_setup.py new file mode 100644 index 0000000..bd7755f --- /dev/null +++ b/kernel_ai/logging_setup.py @@ -0,0 +1,59 @@ +"""Application logging setup for ops/SIEM.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone + + +class JsonLogFormatter(logging.Formatter): + """Compact ECS-like JSON formatter.""" + + def __init__(self, service_name: str): + super().__init__() + self._service_name = service_name + + def format(self, record: logging.LogRecord) -> str: + payload = { + "@timestamp": datetime.now(timezone.utc).isoformat(), + "log.level": record.levelname.lower(), + "message": record.getMessage(), + "service.name": self._service_name, + "logger.name": record.name, + } + event_data = getattr(record, "event_data", None) + if isinstance(event_data, dict): + payload.update(event_data) + if record.exc_info: + payload["error.stack_trace"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=True) + + +def configure_logging(app) -> None: + """Configure process logging once (safe for create_app calls in tests).""" + root_logger = logging.getLogger() + level_name = str(app.config.get("LOG_LEVEL", "INFO")).upper() + level = getattr(logging, level_name, logging.INFO) + root_logger.setLevel(level) + + for handler in root_logger.handlers: + if getattr(handler, "_kernel_ai_managed", False): + handler.setLevel(level) + return + + handler = logging.StreamHandler() + handler._kernel_ai_managed = True # type: ignore[attr-defined] + handler.setLevel(level) + + log_format = str(app.config.get("LOG_FORMAT", "json")).strip().lower() + service_name = str(app.config.get("LOG_SERVICE_NAME", "kernel-ai-backend")) + if log_format == "json": + handler.setFormatter(JsonLogFormatter(service_name=service_name)) + else: + handler.setFormatter( + logging.Formatter( + "%(asctime)s %(levelname)s %(name)s - %(message)s", + ) + ) + root_logger.addHandler(handler) diff --git a/kernel_ai/prometheus_setup.py b/kernel_ai/prometheus_setup.py index b166c1a..cfc6f74 100644 --- a/kernel_ai/prometheus_setup.py +++ b/kernel_ai/prometheus_setup.py @@ -22,14 +22,15 @@ _REQUEST_COUNT = None _REQUEST_LATENCY = None +_REQUEST_ERRORS = None _METRICS_LOCK = threading.Lock() def _get_or_create_http_metrics(): """Create Prometheus metric objects once per process.""" - global _REQUEST_COUNT, _REQUEST_LATENCY - if _REQUEST_COUNT is not None and _REQUEST_LATENCY is not None: - return _REQUEST_COUNT, _REQUEST_LATENCY + global _REQUEST_COUNT, _REQUEST_LATENCY, _REQUEST_ERRORS + if _REQUEST_COUNT is not None and _REQUEST_LATENCY is not None and _REQUEST_ERRORS is not None: + return _REQUEST_COUNT, _REQUEST_LATENCY, _REQUEST_ERRORS with _METRICS_LOCK: if _REQUEST_COUNT is None: _REQUEST_COUNT = Counter( @@ -43,7 +44,13 @@ def _get_or_create_http_metrics(): "HTTP request latency in seconds", buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, float("inf")), ) - return _REQUEST_COUNT, _REQUEST_LATENCY + if _REQUEST_ERRORS is None: + _REQUEST_ERRORS = Counter( + "http_errors_total", + "Total HTTP error responses", + ["method", "endpoint", "status"], + ) + return _REQUEST_COUNT, _REQUEST_LATENCY, _REQUEST_ERRORS def init_prometheus(app): @@ -58,7 +65,7 @@ def prometheus_metrics_disabled(): return - request_count, request_latency = _get_or_create_http_metrics() + request_count, request_latency, request_errors = _get_or_create_http_metrics() @app.before_request def _prometheus_before_request(): @@ -78,6 +85,12 @@ def _prometheus_after_request(response): endpoint=endpoint_label, status=str(response.status_code), ).inc() + if response.status_code >= 400: + request_errors.labels( + method=request.method, + endpoint=endpoint_label, + status=str(response.status_code), + ).inc() except Exception: pass return response diff --git a/kernel_ai/webapp.py b/kernel_ai/webapp.py index 70d84b6..e2cbda0 100644 --- a/kernel_ai/webapp.py +++ b/kernel_ai/webapp.py @@ -9,7 +9,9 @@ from kernel_ai.config import Config from kernel_ai.hooks import register_hooks +from kernel_ai.http.common import build_error_payload from kernel_ai.http.register import register_http_routes +from kernel_ai.logging_setup import configure_logging from kernel_ai.prometheus_setup import init_prometheus from kernel_ai.state import attach_state_container @@ -23,11 +25,11 @@ def _ensure_prometheus_mpdir(): def _register_error_handlers(app): @app.errorhandler(404) def not_found(error): - return jsonify({"error": "Not found"}), 404 + return jsonify(build_error_payload("Not found", "not_found")), 404 @app.errorhandler(500) def internal_error(error): - return jsonify({"error": "Internal server error"}), 500 + return jsonify(build_error_payload("Internal server error", "internal_error")), 500 def create_app(): @@ -40,6 +42,7 @@ def create_app(): template_folder=os.path.join(root, "templates"), ) app.config.from_object(Config) + configure_logging(app) attach_state_container(app) init_prometheus(app) register_hooks(app)