From 0e6cec4d07e8bd3339a4cbe1ca790027c92c34bd Mon Sep 17 00:00:00 2001 From: kitos Date: Wed, 27 May 2026 11:38:44 +0200 Subject: [PATCH] fix(tempo): only log red team execution time, use pre-computed duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed: 1. Blue team evaluation was also sent to Tempo. Only operator (red team) execution time should be logged — blue team time is tracked internally in Aegis but does NOT represent billable operator work. Added a whitelist (_TEMPO_ACTIVITY_TYPES = {"red_team_execution"}). 2. _calculate_duration() re-computed duration from red_started_at to datetime.utcnow() at call time, without subtracting paused seconds. This caused inflated times (e.g. 45 min instead of 5 min) when there was any delay between the workflow transition and the Tempo call. Now the duration_seconds already computed by _create_phase_worklog (gross elapsed - paused) is passed directly to auto_log_test_worklog and used as-is, so Aegis and Tempo always agree on the duration. Also: use red_started_at as the worklog date (not submission timestamp) so the Tempo entry reflects when the work actually happened. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/tempo_service.py | 83 ++++++++++--------- backend/app/services/test_workflow_service.py | 5 +- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/backend/app/services/tempo_service.py b/backend/app/services/tempo_service.py index 6919fe2..dba1724 100644 --- a/backend/app/services/tempo_service.py +++ b/backend/app/services/tempo_service.py @@ -8,6 +8,13 @@ Obtain a Tempo token at: Jira → Apps → Tempo → Settings → API Integratio The global ``settings.TEMPO_ENABLED`` flag acts as a kill-switch. When False, all Tempo calls are silently skipped regardless of whether users have tokens. + +What goes to Tempo +------------------ +Only **red team execution** time is logged to Tempo. This reflects the time +the operator (red_tech) spends executing the attack technique — from the moment +they click "Start" to when they click "Done". Blue team evaluation time is +tracked internally in Aegis but is NOT sent to Tempo. """ import logging @@ -21,6 +28,11 @@ from app.models.jira_link import JiraLink, JiraLinkEntityType logger = logging.getLogger(__name__) +# Only these activity types are forwarded to Tempo. +# "blue_team_evaluation" is intentionally excluded — it is tracked in Aegis +# but does not represent operator execution work. +_TEMPO_ACTIVITY_TYPES = {"red_team_execution"} + def has_tempo_configured(user) -> bool: """Return True if *user* has a personal Tempo API token stored.""" @@ -78,16 +90,37 @@ def auto_log_test_worklog( test, user, activity_type: str, + duration_seconds: int, ) -> Optional[dict]: - """If the test has a Jira link and *user* has a Tempo token, log time. + """Log *duration_seconds* to Tempo for the given test if conditions are met. - Returns the Tempo worklog response, or None if skipped. + ``duration_seconds`` must be the value already computed by the workflow + layer (gross elapsed time minus any paused time). It is used as-is so + the Tempo entry always matches the Aegis worklog — no re-calculation. + + Only ``red_team_execution`` activities are forwarded to Tempo. + ``blue_team_evaluation`` is tracked internally but not sent. + + Returns the Tempo worklog response dict, or ``None`` if skipped. Completely non-fatal — errors are logged and swallowed. """ + # Only operator execution time goes to Tempo + if activity_type not in _TEMPO_ACTIVITY_TYPES: + logger.debug( + "Skipping Tempo sync for activity_type=%s (not in whitelist)", activity_type + ) + return None + # Global kill-switch if not settings.TEMPO_ENABLED: return None + if duration_seconds <= 0: + logger.debug( + "Skipping Tempo sync for test %s: duration=%ds", test.id, duration_seconds + ) + return None + # Per-user token required if not has_tempo_configured(user): logger.debug( @@ -118,22 +151,24 @@ def auto_log_test_worklog( ) return None - duration = _calculate_duration(test, activity_type) - if duration <= 0: - return None - try: + # Use red_started_at date as the worklog date so it matches when the + # work actually happened (not the submission timestamp). + work_date = ( + (test.red_started_at or getattr(test, "updated_at", None) or test.created_at) + .strftime("%Y-%m-%d") + ) result = log_worklog( user=user, jira_issue_id=int(link.jira_issue_id), author_account_id=jira_account_id, - date=(getattr(test, "updated_at", None) or test.created_at).strftime("%Y-%m-%d"), - time_spent_seconds=duration, - description=f"[Aegis] {activity_type}: {test.name}", + date=work_date, + time_spent_seconds=duration_seconds, + description=f"[Aegis] Red Team execution: {test.name}", ) logger.info( - "Tempo worklog created for test %s by user %s, %ds", - test.id, getattr(user, "username", user), duration, + "Tempo worklog created for test %s by user %s: %ds on %s", + test.id, getattr(user, "username", user), duration_seconds, work_date, ) return result except Exception as e: @@ -142,29 +177,3 @@ def auto_log_test_worklog( test.id, getattr(user, "username", user), e, exc_info=True, ) return None - - -def _calculate_duration(test, activity_type: str) -> int: - """Calculate real duration in seconds from the phase timing fields. - - Uses the actual start/end timestamps recorded by the workflow buttons, - so the data cannot be falsified. - """ - from datetime import datetime - - now = datetime.utcnow() - - if activity_type == "red_team_execution" and test.red_started_at: - delta = now - test.red_started_at - return max(int(delta.total_seconds()), 1) - - if activity_type == "blue_team_evaluation" and test.blue_started_at: - delta = now - test.blue_started_at - return max(int(delta.total_seconds()), 1) - - # Fallback for legacy activity types - if activity_type == "execution" and test.execution_date and test.created_at: - delta = test.execution_date - test.created_at - return max(int(delta.total_seconds()), 0) - - return 0 diff --git a/backend/app/services/test_workflow_service.py b/backend/app/services/test_workflow_service.py index ed3b1ad..889a1e4 100644 --- a/backend/app/services/test_workflow_service.py +++ b/backend/app/services/test_workflow_service.py @@ -327,10 +327,11 @@ def _create_phase_worklog( test.id, activity_type, duration_seconds, wl.id, ) - # Sync to Tempo if enabled + # Sync to Tempo: only red_team_execution, using the already-computed + # duration so the Tempo entry is identical to the Aegis worklog. try: from app.services.tempo_service import auto_log_test_worklog - auto_log_test_worklog(db, test, user, activity_type) + auto_log_test_worklog(db, test, user, activity_type, duration_seconds) except Exception as e: logger.warning("Tempo sync failed for worklog: %s", e, exc_info=True)