fix(tempo): only log red team execution time, use pre-computed duration
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user