Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions kernel_ai/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
37 changes: 35 additions & 2 deletions kernel_ai/hooks.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
59 changes: 59 additions & 0 deletions kernel_ai/logging_setup.py
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 18 additions & 5 deletions kernel_ai/prometheus_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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):
Expand All @@ -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():
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions kernel_ai/webapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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():
Expand All @@ -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)
Expand Down
Loading