Skip to content

Commit f707a51

Browse files
committed
[DEV-5540] Implement /account/health logic
1 parent 79ddaf2 commit f707a51

File tree

10 files changed

+192
-15
lines changed

10 files changed

+192
-15
lines changed

server/athenian/api/application.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,7 @@ def raise_graceful_exit():
894894
loop = asyncio.get_event_loop()
895895
loop.remove_signal_handler(signal.SIGINT)
896896
loop.remove_signal_handler(signal.SIGTERM)
897+
await self._shutdown()
897898
loop.add_signal_handler(signal.SIGTERM, raise_graceful_exit)
898899
os.kill(os.getpid(), signal.SIGTERM)
899900

server/athenian/api/controllers/backoffice_controller.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime, timezone
1+
from datetime import datetime, timedelta, timezone
22
import logging
33
from sqlite3 import IntegrityError, OperationalError
44
from typing import Optional
@@ -7,6 +7,7 @@
77
import aiohttp.web
88
import aiomcache
99
from asyncpg import IntegrityConstraintViolationError
10+
from dateutil.rrule import HOURLY, rrule
1011
import sentry_sdk
1112
from sqlalchemy import delete, insert, select, union_all, update
1213

@@ -19,6 +20,7 @@
1920
get_user_account_status_from_request,
2021
)
2122
from athenian.api.internal.account_feature import get_account_features
23+
from athenian.api.internal.data_health_metrics import measure_accounts_health
2224
from athenian.api.internal.jira import fetch_jira_installation_progress, get_jira_id
2325
from athenian.api.internal.logical_repos import coerce_logical_repos
2426
from athenian.api.internal.miners.github.branches import BranchMiner
@@ -37,6 +39,7 @@
3739
UserAccount,
3840
)
3941
from athenian.api.models.web import (
42+
AccountsHealth,
4043
DatabaseConflict,
4144
ForbiddenError,
4245
InvalidRequestError,
@@ -508,8 +511,40 @@ async def set_account_features(request: AthenianWebRequest, id: int, body: dict)
508511
async def get_account_health(
509512
request: AthenianWebRequest,
510513
id: Optional[int] = None,
511-
since: Optional[datetime] = None,
512-
until: Optional[datetime] = None,
514+
since: Optional[str] = None,
515+
until: Optional[str] = None,
513516
) -> aiohttp.web.Response:
514517
"""Return the account health metrics measured per hour."""
515-
raise NotImplementedError
518+
try:
519+
if since is not None:
520+
since = deserialize_datetime(since)
521+
else:
522+
since = datetime.now(timezone.utc) - timedelta(hours=1)
523+
if until is not None:
524+
until = deserialize_datetime(until)
525+
else:
526+
until = datetime.now(timezone.utc)
527+
except ValueError as e:
528+
e.path = "since" if isinstance(since, str) else "until"
529+
raise ResponseError(InvalidRequestError.from_validation_error(e))
530+
if id is not None:
531+
ids = [id]
532+
else:
533+
ids = (
534+
await read_sql_query(
535+
select(Account.id)
536+
.where(Account.expires_at > datetime.now(timezone.utc))
537+
.order_by(Account.id),
538+
request.sdb,
539+
[Account.id],
540+
)
541+
)[Account.id.name].values
542+
since = (since + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
543+
until = until.replace(minute=0, second=0, microsecond=0)
544+
datetimes = list(rrule(HOURLY, dtstart=since, until=until))
545+
return model_response(
546+
AccountsHealth(
547+
datetimes=datetimes,
548+
accounts=await measure_accounts_health(ids, datetimes, request.rdb),
549+
),
550+
)

server/athenian/api/internal/data_health_metrics.py

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import dataclasses
22
from dataclasses import dataclass
3-
from datetime import datetime, timezone
3+
from datetime import datetime, timedelta, timezone
4+
from typing import Collection
45

5-
from sqlalchemy import insert
6+
from sqlalchemy import insert, select
67

78
from athenian.api.db import DatabaseLike
89
from athenian.api.internal.features.entries import MinePullRequestMetrics
@@ -11,6 +12,7 @@
1112
from athenian.api.internal.miners.github.release_load import MineReleaseMetrics
1213
from athenian.api.internal.reposet import RepositorySetMetrics
1314
from athenian.api.models.persistentdata.models import HealthMetric
15+
from athenian.api.models.web import AccountHealth
1416

1517

1618
@dataclass(frozen=True, slots=True)
@@ -47,3 +49,110 @@ async def persist(self, account: int, rdb: DatabaseLike) -> None:
4749
m.created_at = now
4850
values.append(m.explode(with_primary_keys=True))
4951
await rdb.execute_many(insert(HealthMetric), values)
52+
53+
54+
async def measure_accounts_health(
55+
ids: Collection[int],
56+
time_points: list[datetime],
57+
rdb: DatabaseLike,
58+
):
59+
"""Collect all the supported health metrics for each account in `ids`."""
60+
rows = await rdb.fetch_all(
61+
select(HealthMetric)
62+
.where(
63+
HealthMetric.account_id.in_(ids),
64+
HealthMetric.created_at >= time_points[0],
65+
HealthMetric.created_at < time_points[-1] + timedelta(hours=1),
66+
)
67+
.order_by(HealthMetric.created_at),
68+
)
69+
ltp = len(time_points)
70+
result = {
71+
acc: AccountHealth(
72+
broken_branches=[0] * ltp,
73+
broken_dags=[0] * ltp,
74+
deployments=[0] * ltp,
75+
empty_releases=[0] * ltp,
76+
endpoint_p50={},
77+
endpoint_p95={},
78+
event_releases=[0] * ltp,
79+
inconsistent_nodes={},
80+
pending_fetch_branches=[0] * ltp,
81+
pending_fetch_prs=[0] * ltp,
82+
prs_count=[0] * ltp,
83+
released_prs_ratio=[0] * ltp,
84+
reposet_problems=[0] * ltp,
85+
repositories_count=[0] * ltp,
86+
unresolved_deployments=[0] * ltp,
87+
unresolved_releases=[0] * ltp,
88+
)
89+
for acc in ids
90+
}
91+
start_time = time_points[0]
92+
one_hour = timedelta(hours=1)
93+
acc_col = HealthMetric.account_id.name
94+
created_col = HealthMetric.created_at.name
95+
name_col = HealthMetric.name.name
96+
value_col = HealthMetric.value.name
97+
for row in rows:
98+
model = result[row[acc_col]]
99+
dt = row[created_col]
100+
pos = (dt - start_time) // one_hour
101+
val = row[value_col]
102+
match row[name_col]:
103+
case "branches_count":
104+
continue
105+
case ["branches_empty_count", "branches_no_default"]:
106+
model.broken_branches[pos] += val
107+
case ["commits_pristine", "commits_corrupted", "commits_orphaned"]:
108+
model.broken_dags[pos] += val
109+
case "deployments_count":
110+
model.deployments[pos] = val
111+
case "deployments_unresolved":
112+
model.unresolved_deployments[pos] = val
113+
case "releases_unresolved":
114+
model.unresolved_releases[pos] = val
115+
case "releases_by_event":
116+
model.event_releases[pos] = val
117+
case "releases_empty":
118+
model.empty_releases[pos] = val
119+
case "reposet_problems":
120+
model.empty_releases[pos] = val
121+
case "reposet_length":
122+
model.repositories_count[pos] = val
123+
case "prs_count":
124+
model.prs_count[pos] = val
125+
case "prs_done_count":
126+
model.released_prs_ratio[pos] = val
127+
case p50 if p50.startswith("p50/"):
128+
endpoint = p50[4:]
129+
try:
130+
model.endpoint_p50[endpoint][pos] = val
131+
except KeyError:
132+
vals = [None] * ltp
133+
vals[pos] = val
134+
model.endpoint_p50[endpoint] = vals
135+
case p95 if p95.startswith("p95/"):
136+
endpoint = p95[4:]
137+
try:
138+
model.endpoint_p95[endpoint][pos] = val
139+
except KeyError:
140+
vals = [None] * ltp
141+
vals[pos] = val
142+
model.endpoint_p95[endpoint] = vals
143+
case node if node.startswith("inconsistency/"):
144+
node = node[14:]
145+
try:
146+
model.inconsistent_nodes[node][pos] = val
147+
except KeyError:
148+
vals = [0] * ltp
149+
vals[pos] = val
150+
model.inconsistent_nodes[node] = vals
151+
for model in result.values():
152+
released_prs_ratio = model.released_prs_ratio
153+
for i, (done, count) in enumerate(zip(released_prs_ratio, model.prs_count)):
154+
if count == 0:
155+
released_prs_ratio[i] = 0
156+
else:
157+
released_prs_ratio[i] = done / count
158+
return result

server/athenian/api/internal/datetime_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def coarsen_time_interval(time_from: datetime, time_to: datetime) -> Tuple[date,
2121
def split_to_time_intervals(
2222
date_from: date,
2323
date_to: date,
24-
granularities: Union[str, List[str]],
24+
granularities: str | list[str],
2525
tzoffset: Optional[int],
2626
) -> Tuple[Union[List[datetime], List[List[datetime]]], timedelta]:
2727
"""Produce time interval boundaries from the min and the max dates and the interval lengths \

server/athenian/api/models/web/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from athenian.api.models.web.accepted_invitation import AcceptedInvitation
22
from athenian.api.models.web.account import Account
3+
from athenian.api.models.web.account_health import AccountHealth
34
from athenian.api.models.web.account_status import AccountStatus
45
from athenian.api.models.web.account_user_change_request import (
56
AccountUserChangeRequest,
67
UserChangeStatus,
78
)
9+
from athenian.api.models.web.accounts_health import AccountsHealth
810
from athenian.api.models.web.base_model_ import AllOf, Enum, Model, Slots
911
from athenian.api.models.web.calculated_code_check_histogram import CalculatedCodeCheckHistogram
1012
from athenian.api.models.web.calculated_code_check_metrics import CalculatedCodeCheckMetrics
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from athenian.api.models.web.base_model_ import Model
2+
3+
4+
class AccountHealth(Model):
5+
"""Map from health metric names to metric values."""
6+
7+
broken_branches: list[int]
8+
broken_dags: list[int]
9+
deployments: list[int]
10+
empty_releases: list[int]
11+
endpoint_p50: dict[str, list[float]]
12+
endpoint_p95: dict[str, list[float]]
13+
event_releases: list[int]
14+
inconsistent_nodes: dict[str, list[int]]
15+
pending_fetch_branches: list[int]
16+
pending_fetch_prs: list[int]
17+
prs_count: list[int]
18+
released_prs_ratio: list[float]
19+
reposet_problems: list[int]
20+
repositories_count: list[int]
21+
unresolved_deployments: list[int]
22+
unresolved_releases: list[int]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from datetime import datetime
2+
3+
from athenian.api.models.web.account_health import AccountHealth
4+
from athenian.api.models.web.base_model_ import Model
5+
6+
7+
class AccountsHealth(Model):
8+
"""The metric measurement timestamps and the measured metric values for each requested \
9+
account."""
10+
11+
accounts: dict[int, AccountHealth]
12+
datetimes: list[datetime]

server/athenian/api/models/web/calculated_code_check_metrics_item.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from typing import List
2-
31
from athenian.api.models.web.base_model_ import AllOf, Model
42
from athenian.api.models.web.calculated_linear_metric_values import CalculatedLinearMetricValues
53
from athenian.api.models.web.for_set_code_checks import _CalculatedCodeCheckCommon
@@ -10,7 +8,7 @@ class _CalculatedCodeCheckMetricsItem(Model, GranularityMixin, sealed=False):
108
"""Series of calculated metrics for a specific set of repositories and commit authors."""
119

1210
granularity: str
13-
values: List[CalculatedLinearMetricValues]
11+
values: list[CalculatedLinearMetricValues]
1412

1513

1614
CalculatedCodeCheckMetricsItem = AllOf(

server/athenian/api/models/web/calculated_linear_metric_values.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import datetime
1+
from datetime import date
22
from typing import Optional
33

44
from athenian.api.models.web.base_model_ import Model
@@ -7,7 +7,7 @@
77
class CalculatedLinearMetricValues(Model):
88
"""Calculated metrics: date, values, confidences."""
99

10-
date: datetime.date
10+
date: date
1111
values: list[object]
1212
confidence_scores: Optional[list[int]]
1313
confidence_mins: Optional[list[object]]

server/athenian/api/models/web/calculated_pull_request_metrics_item.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from typing import List
2-
31
from athenian.api.models.web.base_model_ import Model
42
from athenian.api.models.web.calculated_linear_metric_values import CalculatedLinearMetricValues
53
from athenian.api.models.web.for_set_pull_requests import ForSetPullRequests
@@ -11,4 +9,4 @@ class CalculatedPullRequestMetricsItem(Model, GranularityMixin):
119

1210
for_: (ForSetPullRequests, "for")
1311
granularity: str
14-
values: List[CalculatedLinearMetricValues]
12+
values: list[CalculatedLinearMetricValues]

0 commit comments

Comments
 (0)