From 06e8effaa460736e01e99bb1bf0e6abec592ba2f Mon Sep 17 00:00:00 2001 From: kitos Date: Wed, 3 Jun 2026 10:40:05 +0200 Subject: [PATCH] fix(metrics): use direct timestamp fields instead of audit log lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MTTD: was querying AuditLog for action names that don't match actual logged actions. Now uses red_started_at → blue_started_at directly (both stored on the Test record). Net of red_paused_seconds. MTTR: was searching for remediation_status=completed (no data). Redefined as total pipeline time: red_started_at → blue_validated_at net of all paused time. Only counts fully validated tests. Red avg time: was using red_validated_at - created_at (created_at NULL for many tests). Now uses blue_started_at - red_started_at net paused. Blue avg time: was using blue_validated_at - red_validated_at (wrong phase boundary). Now uses blue_work_started_at (or blue_started_at fallback) → blue_validated_at net of blue_paused_seconds. --- .../services/operational_metrics_service.py | 111 ++++++++---------- frontend/src/pages/ExecutiveDashboardPage.tsx | 2 +- 2 files changed, 48 insertions(+), 65 deletions(-) diff --git a/backend/app/services/operational_metrics_service.py b/backend/app/services/operational_metrics_service.py index 531bf3f..569e238 100644 --- a/backend/app/services/operational_metrics_service.py +++ b/backend/app/services/operational_metrics_service.py @@ -37,45 +37,28 @@ def _safe_stats(values: list[float]) -> dict: def calculate_mttd(db: Session) -> Optional[dict]: """Calculate Mean Time to Detect. - For each validated test: time between entering red_executing and - entering blue_evaluating (extracted from audit_log timestamps). + Uses direct timestamp fields on the Test record: + red_started_at → when Red Team started the attack + blue_started_at → when Red Team submitted to Blue (attack entered detection phase) + + MTTD = blue_started_at - red_started_at - red_paused_seconds + Represents how long Red Team spent executing before Blue received the test. """ - # Get validated tests that have both timestamps available - # Using audit log entries for state transitions tests = ( db.query(Test) - .filter(Test.state == TestState.validated) + .filter( + Test.red_started_at.isnot(None), + Test.blue_started_at.isnot(None), + ) .all() ) detection_times = [] - for test in tests: - # Find the red_executing and blue_evaluating transition timestamps - red_start = ( - db.query(AuditLog.timestamp) - .filter( - AuditLog.entity_type == "test", - AuditLog.entity_id == str(test.id), - AuditLog.action.in_(["test_start_execution", "start_execution"]), - ) - .order_by(AuditLog.timestamp.asc()) - .first() - ) - - blue_start = ( - db.query(AuditLog.timestamp) - .filter( - AuditLog.entity_type == "test", - AuditLog.entity_id == str(test.id), - AuditLog.action.in_(["test_submit_red", "submit_red"]), - ) - .order_by(AuditLog.timestamp.asc()) - .first() - ) - - if red_start and blue_start and blue_start[0] > red_start[0]: - hours = (blue_start[0] - red_start[0]).total_seconds() / 3600 - detection_times.append(hours) + for t in tests: + gross_secs = (t.blue_started_at - t.red_started_at).total_seconds() + net_secs = gross_secs - (t.red_paused_seconds or 0) + if net_secs > 0: + detection_times.append(net_secs / 3600) return _safe_stats(detection_times) @@ -86,38 +69,29 @@ def calculate_mttd(db: Session) -> Optional[dict]: def calculate_mttr(db: Session) -> Optional[dict]: """Calculate Mean Time to Respond. - For tests with remediation_status = completed: time between - detection_result being set and remediation_status = completed. + Redefined as total pipeline time from attack start to full validation: + red_started_at → blue_validated_at (net of paused time). + + Represents how long the full security testing cycle takes end-to-end. + Only uses tests that have been fully validated (both sides approved). """ - # Tests with completed remediation tests = ( db.query(Test) .filter( - Test.remediation_status == "completed", + Test.state == TestState.validated, + Test.red_started_at.isnot(None), Test.blue_validated_at.isnot(None), ) .all() ) response_times = [] - for test in tests: - # Find when remediation was completed from audit log - remediation_complete = ( - db.query(AuditLog.timestamp) - .filter( - AuditLog.entity_type == "test", - AuditLog.entity_id == str(test.id), - AuditLog.action.ilike("%remediation%"), - ) - .order_by(AuditLog.timestamp.desc()) - .first() - ) - - detection_time = test.blue_validated_at - if remediation_complete and detection_time: - hours = (remediation_complete[0] - detection_time).total_seconds() / 3600 - if hours > 0: - response_times.append(hours) + for t in tests: + gross_secs = (t.blue_validated_at - t.red_started_at).total_seconds() + paused = (t.red_paused_seconds or 0) + (t.blue_paused_seconds or 0) + net_secs = gross_secs - paused + if net_secs > 0: + response_times.append(net_secs / 3600) return _safe_stats(response_times) @@ -427,20 +401,24 @@ def get_metrics_by_team(db: Session) -> dict: red_avg_time = None red_times = [] - # Time for red team to complete their phase + # Red team avg execution time: red_started_at → blue_started_at (net of paused) tests_with_red = ( db.query(Test) - .filter(Test.red_validated_at.isnot(None), Test.created_at.isnot(None)) + .filter( + Test.red_started_at.isnot(None), + Test.blue_started_at.isnot(None), + ) .all() ) for t in tests_with_red: - hours = (t.red_validated_at - t.created_at).total_seconds() / 3600 - if hours > 0: - red_times.append(hours) + gross = (t.blue_started_at - t.red_started_at).total_seconds() + net = gross - (t.red_paused_seconds or 0) + if net > 0: + red_times.append(net / 3600) if red_times: red_avg_time = round(sum(red_times) / len(red_times), 1) - # Blue team metrics + # Blue team: count tests that reached the blue evaluation phase blue_tests_completed = ( db.query(func.count(Test.id)) .filter(Test.state.in_([ @@ -451,20 +429,25 @@ def get_metrics_by_team(db: Session) -> dict: .scalar() ) or 0 + # Blue avg evaluation time: + # Prefer blue_work_started_at (actual pick-up) → blue_validated_at. + # Fall back to blue_started_at if blue_work_started_at is not set. blue_avg_time = None blue_times = [] tests_with_blue = ( db.query(Test) .filter( + Test.blue_started_at.isnot(None), Test.blue_validated_at.isnot(None), - Test.red_validated_at.isnot(None), ) .all() ) for t in tests_with_blue: - hours = (t.blue_validated_at - t.red_validated_at).total_seconds() / 3600 - if hours > 0: - blue_times.append(hours) + phase_start = t.blue_work_started_at or t.blue_started_at + gross = (t.blue_validated_at - phase_start).total_seconds() + net = gross - (t.blue_paused_seconds or 0) + if net > 0: + blue_times.append(net / 3600) if blue_times: blue_avg_time = round(sum(blue_times) / len(blue_times), 1) diff --git a/frontend/src/pages/ExecutiveDashboardPage.tsx b/frontend/src/pages/ExecutiveDashboardPage.tsx index 61c2c97..187997f 100644 --- a/frontend/src/pages/ExecutiveDashboardPage.tsx +++ b/frontend/src/pages/ExecutiveDashboardPage.tsx @@ -581,7 +581,7 @@ export default function ExecutiveDashboardPage() { label="MTTR" value={opMetrics?.mttr?.mean_hours ?? "N/A"} unit={opMetrics?.mttr ? "hrs" : undefined} - tooltip={{ description: "Mean Time To Respond — average hours for a test to go from execution through the full Red/Blue validation and reach a final result.", context: "Lower is better. Reflects process efficiency and team responsiveness." }} + tooltip={{ description: "Mean Time To Respond — average hours from Red Team attack start to full validation (both Red and Blue leads approved). Measures the total security test cycle time end-to-end.", context: "Lower is better. Long MTTR may indicate bottlenecks in the review pipeline." }} />