Files
ServerKit/backend/app/api/telemetry.py
T
Juan Denis 3d3975fa3a feat(telemetry): unified system event stream with correlation
Add SystemEvent model, TelemetryService, /api/v1 telemetry endpoints, and
a /telemetry UI page. Instrument audit, notification, webhook, backup,
deployment, and monitoring paths. Add correlation_id to Notification,
EventDelivery, and DeploymentJob so related events can be grouped.

- 18 backend tests added and passing
- Frontend production build clean
2026-06-21 20:19:57 -04:00

151 lines
5.2 KiB
Python

"""Telemetry / system event stream API."""
from datetime import datetime, timedelta
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from sqlalchemy import func
from app import db
from app.middleware.rbac import admin_required
from app.models.system_event import SystemEvent
from app.services.telemetry_service import TelemetryService
telemetry_bp = Blueprint('telemetry', __name__)
@telemetry_bp.route('/events', methods=['GET'])
@jwt_required()
def list_events():
"""List system events with filtering and pagination."""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
source = request.args.get('source') or None
event_type = request.args.get('event_type') or None
severity = request.args.get('severity') or None
resource_type = request.args.get('resource_type') or None
resource_id = request.args.get('resource_id') or None
correlation_id = request.args.get('correlation_id') or None
message_query = request.args.get('q') or None
start_date = _parse_iso_datetime(request.args.get('start_date'))
end_date = _parse_iso_datetime(request.args.get('end_date'))
pagination = TelemetryService.get_events(
page=page,
per_page=min(per_page, 200),
source=source,
event_type=event_type,
severity=severity,
resource_type=resource_type,
resource_id=resource_id,
correlation_id=correlation_id,
start_date=start_date,
end_date=end_date,
message_query=message_query,
)
return jsonify({
'events': [event.to_dict(include_payload=True) for event in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'page': pagination.page,
'per_page': pagination.per_page,
}), 200
@telemetry_bp.route('/events/<int:event_id>', methods=['GET'])
@jwt_required()
def get_event(event_id):
"""Get a single system event by ID."""
event = TelemetryService.get_event_by_id(event_id)
if not event:
return jsonify({'error': 'Event not found'}), 404
return jsonify(event.to_dict(include_payload=True)), 200
@telemetry_bp.route('/events/by-correlation/<correlation_id>', methods=['GET'])
@jwt_required()
def get_events_by_correlation(correlation_id):
"""Get all events sharing a correlation ID."""
limit = request.args.get('limit', 100, type=int)
events = TelemetryService.get_events_by_correlation(correlation_id, limit=limit)
return jsonify({
'correlation_id': correlation_id,
'events': [event.to_dict(include_payload=True) for event in events],
}), 200
@telemetry_bp.route('/stats', methods=['GET'])
@jwt_required()
def get_stats():
"""Get telemetry aggregate stats."""
hours = request.args.get('hours', 24, type=int)
source = request.args.get('source') or None
stats = TelemetryService.get_stats(hours=hours, source=source)
stats['recent_event_types'] = TelemetryService.get_recent_event_types()
return jsonify(stats), 200
@telemetry_bp.route('/sources', methods=['GET'])
@jwt_required()
def list_sources():
"""Return all distinct event sources."""
sources = (db.session.query(SystemEvent.source)
.distinct()
.order_by(SystemEvent.source)
.all())
return jsonify({'sources': [s[0] for s in sources if s[0]]}), 200
@telemetry_bp.route('/event-types', methods=['GET'])
@jwt_required()
def list_event_types():
"""Return all distinct event types, optionally filtered by source."""
source = request.args.get('source') or None
query = db.session.query(SystemEvent.event_type).distinct()
if source:
query = query.filter(SystemEvent.source == source)
types = query.order_by(SystemEvent.event_type).all()
return jsonify({'event_types': [t[0] for t in types if t[0]]}), 200
@telemetry_bp.route('/events', methods=['DELETE'])
@jwt_required()
@admin_required
def cleanup_events():
"""Delete events older than the specified number of days (admin only)."""
days = request.args.get('days', 90, type=int)
deleted = TelemetryService.cleanup_old_events(days=days)
return jsonify({'deleted': deleted}), 200
@telemetry_bp.route('/events/test', methods=['POST'])
@jwt_required()
@admin_required
def emit_test_event():
"""Emit a test telemetry event (admin only)."""
data = request.get_json(silent=True) or {}
user_id = get_jwt_identity()
event = TelemetryService.emit(
source=data.get('source', 'system'),
event_type=data.get('event_type', 'telemetry.test'),
message=data.get('message', 'Test telemetry event'),
severity=data.get('severity', 'info'),
actor_user_id=user_id,
payload=data.get('payload', {'test': True}),
commit=True,
)
if event:
return jsonify(event.to_dict(include_payload=True)), 201
return jsonify({'error': 'Failed to emit test event'}), 500
def _parse_iso_datetime(value):
"""Parse an ISO datetime string, returning None on failure."""
if not value:
return None
try:
return datetime.fromisoformat(value.replace('Z', '+00:00')).replace(tzinfo=None)
except (ValueError, TypeError):
return None