Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/metrics/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from osf.metrics.monthly_reports import (
BaseMonthlyReport,
MonthlySpamSummaryReport,
MonthlyAngularSSRMetricsReport,
)
from osf.metrics.openapi import get_metrics_openapi_json_dict
from osf.models import AbstractNode
Expand All @@ -78,6 +79,7 @@
'storage_addon_usage': DailyStorageAddonUsageReport,
'user_summary': DailyUserSummaryReport,
'spam_summary': MonthlySpamSummaryReport,
'angular_ssr_metrics': MonthlyAngularSSRMetricsReport,
'new_user_domains': DailyNewUserDomainReport,
}

Expand Down
25 changes: 25 additions & 0 deletions osf/metrics/monthly_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
'MonthlyPrivateSpamMetricsReport',
'MonthlyPublicItemUsageReport',
'MonthlySpamSummaryReport',
'MonthlyAngularSSRMetricsReport',
)


Expand Down Expand Up @@ -198,3 +199,27 @@ class MonthlyPrivateSpamMetricsReport(BaseMonthlyReport):

class Meta:
timeseries_index_timedepth = YEARLY


class SSRContentTypeCount(esdsl.InnerDoc):
content_type: str | None
count: int = esdsl.mapped_field(esdsl.Long())


class SSRUserAgentCount(esdsl.InnerDoc):
user_agent: str | None
count: int = esdsl.mapped_field(esdsl.Long())


class MonthlyAngularSSRMetricsReport(BaseMonthlyReport):
bot_render_count: int
bot_render_success_count: int
bot_render_success_rate: float = esdsl.mapped_field(esdsl.Float())
avg_ttfb_ms: float = esdsl.mapped_field(esdsl.Float())
complete_render_count: int
complete_render_rate: float = esdsl.mapped_field(esdsl.Float())
content_type_breakdown: list[SSRContentTypeCount]
user_agent_breakdown: list[SSRUserAgentCount]

class Meta:
timeseries_index_timedepth = YEARLY
2 changes: 2 additions & 0 deletions osf/metrics/reporters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .user_count import UserCountReporter
from .spam_count import SpamCountReporter
from .private_spam_metrics import PrivateSpamMetricsReporter
from .angular_ssr_metrics import AngularSSRMetricsReporter


class AllDailyReporters(enum.Enum):
Expand All @@ -34,3 +35,4 @@ class AllMonthlyReporters(enum.Enum):
INSTITUTIONAL_SUMMARY = InstitutionalSummaryMonthlyReporter
ITEM_USAGE = PublicItemUsageReporter
PRIVATE_SPAM_METRICS = PrivateSpamMetricsReporter
ANGULAR_SSR_METRICS = AngularSSRMetricsReporter
78 changes: 78 additions & 0 deletions osf/metrics/reporters/angular_ssr_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from osf.metrics.events import SSRMetricsEvent
from osf.metrics.monthly_reports import (
MonthlyAngularSSRMetricsReport,
SSRContentTypeCount,
SSRUserAgentCount,
)
from ._base import MonthlyReporter

MAX_CONTENT_LENGTH = 25


class AngularSSRMetricsReporter(MonthlyReporter):
report_name = 'Angular SSR Metrics'

def report(self):
_search = self._base_search()
_search.aggs.metric('agg_avg_ttfb', 'avg', field='ttfb')
_search.aggs.bucket('agg_by_outcome', 'filters', filters={
'total': {'match_all': {}},
'success': {'range': {'status': {'gte': 200, 'lt': 300}}},
'complete': {'term': {'isComplete': True}},
})
_search.aggs.bucket(
'agg_content_type', 'terms',
field='contentType', size=MAX_CONTENT_LENGTH, missing='(unspecified)',
)
_search.aggs.bucket('agg_user_agent', 'terms', field='userAgent', size=MAX_CONTENT_LENGTH)
_response = _search.execute()
if not _response.aggregations:
yield MonthlyAngularSSRMetricsReport(
report_yearmonth=self.yearmonth,
bot_render_count=0,
bot_render_success_count=0,
bot_render_success_rate=0.0,
avg_ttfb_ms=0.0,
complete_render_count=0,
complete_render_rate=0.0,
content_type_breakdown=[],
user_agent_breakdown=[],
)
return

_outcome = _response.aggregations.agg_by_outcome.buckets
_render_count = _outcome.total.doc_count
_success_count = _outcome.success.doc_count
_complete_count = _outcome.complete.doc_count

yield MonthlyAngularSSRMetricsReport(
report_yearmonth=self.yearmonth,
bot_render_count=_render_count,
bot_render_success_count=_success_count,
bot_render_success_rate=_safe_rate(_success_count, _render_count),
avg_ttfb_ms=_response.aggregations.agg_avg_ttfb.value or 0.0,
complete_render_count=_complete_count,
complete_render_rate=_safe_rate(_complete_count, _render_count),
content_type_breakdown=[
SSRContentTypeCount(content_type=_bucket.key, count=_bucket.doc_count)
for _bucket in _response.aggregations.agg_content_type.buckets
],
user_agent_breakdown=[
SSRUserAgentCount(user_agent=_bucket.key, count=_bucket.doc_count)
for _bucket in _response.aggregations.agg_user_agent.buckets
],
)

def _base_search(self):
return (
SSRMetricsEvent.search_timeseries_range(
self.yearmonth.month_start(),
self.yearmonth.month_end(),
)
.filter('term', isBot=True)
.extra(size=0) # only aggregations, no hits
)


def _safe_rate(success: int, total: int) -> float:
return (success / total) if total else 0.0
86 changes: 86 additions & 0 deletions osf_tests/metrics/reporters/test_angular_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from datetime import timedelta

from django.test import TestCase
from elasticsearch_metrics.tests.util import RealElasticTestCase

from osf.metrics.events import SSRMetricsEvent
from osf.metrics.monthly_reports import MonthlyAngularSSRMetricsReport
from osf.metrics.reporters.angular_ssr_metrics import AngularSSRMetricsReporter
from osf.metrics.utils import YearMonth
from ._testutils import list_monthly_reports


class TestAngularSSRMetricsReporter(RealElasticTestCase, TestCase):
@property
def ym_empty(self) -> YearMonth:
return YearMonth(2026, 7)

@property
def ym_with_data(self) -> YearMonth:
return YearMonth(2026, 8)

def test_no_data(self):
_reports = list_monthly_reports(AngularSSRMetricsReporter(self.ym_empty))
assert len(_reports) == 1
_report = _reports[0]
assert isinstance(_report, MonthlyAngularSSRMetricsReport)
assert _report.bot_render_count == 0
assert _report.bot_render_success_count == 0
assert _report.bot_render_success_rate == 0.0
assert _report.avg_ttfb_ms == 0.0
assert _report.complete_render_count == 0
assert _report.complete_render_rate == 0.0
assert list(_report.content_type_breakdown) == []
assert list(_report.user_agent_breakdown) == []

def test_reporter(self):
_month_start = self.ym_with_data.month_start()
_record_ssr_event(
timestamp=_month_start,
url='/project1/', status=200, ttfb=100,
isBot=True, isComplete=True, contentType='project', userAgent='Googlebot',
)
_record_ssr_event(
timestamp=_month_start + timedelta(minutes=1),
url='/preprint1/', status=200, ttfb=200,
isBot=True, isComplete=True, contentType='preprint', userAgent='Googlebot',
)
_record_ssr_event(
timestamp=_month_start + timedelta(minutes=2),
url='/project2/', status=200, ttfb=300,
isBot=True, isComplete=False, contentType='project', userAgent='Applebot',
)
_record_ssr_event(
timestamp=_month_start + timedelta(minutes=3),
url='/broken/', status=500, ttfb=50,
isBot=True, isComplete=False, contentType=None, userAgent='Googlebot',
)
# not a bot - should be excluded from reporter data
_record_ssr_event(
timestamp=_month_start + timedelta(minutes=4),
url='/project1/', status=200, ttfb=10,
isBot=False, isComplete=True, contentType='project', userAgent='FakeGooglebot',
)
SSRMetricsEvent.refresh()

_reports = list_monthly_reports(AngularSSRMetricsReporter(self.ym_with_data))
assert len(_reports) == 1
_report = _reports[0]
assert isinstance(_report, MonthlyAngularSSRMetricsReport)
assert _report.report_yearmonth == self.ym_with_data
assert _report.bot_render_count == 4
assert _report.bot_render_success_count == 3
assert _report.bot_render_success_rate == 0.75
assert _report.avg_ttfb_ms == 162.5
assert _report.complete_render_count == 2
assert _report.complete_render_rate == 0.5

_content_types = {_c.content_type: _c.count for _c in _report.content_type_breakdown}
assert _content_types == {'project': 2, 'preprint': 1, '(unspecified)': 1}

_user_agents = {_u.user_agent: _u.count for _u in _report.user_agent_breakdown}
assert _user_agents == {'Googlebot': 3, 'Applebot': 1}


def _record_ssr_event(**kwargs):
SSRMetricsEvent.record(**kwargs)
Loading