From 4a08c9833b1a40ae4910893130b2cec731dba5a1 Mon Sep 17 00:00:00 2001 From: Bohdan Odintsov Date: Wed, 26 Aug 2026 16:28:20 +0300 Subject: [PATCH 1/2] add report, reporter, tests for angular SSR metrics --- api/metrics/views.py | 2 + osf/metrics/monthly_reports.py | 25 ++++++ osf/metrics/reporters/__init__.py | 2 + osf/metrics/reporters/angular_ssr_metrics.py | 78 +++++++++++++++++ .../metrics/reporters/test_angular_metrics.py | 86 +++++++++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 osf/metrics/reporters/angular_ssr_metrics.py create mode 100644 osf_tests/metrics/reporters/test_angular_metrics.py diff --git a/api/metrics/views.py b/api/metrics/views.py index 607e79cab3d..5cf25310fc3 100644 --- a/api/metrics/views.py +++ b/api/metrics/views.py @@ -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 @@ -78,6 +79,7 @@ 'storage_addon_usage': DailyStorageAddonUsageReport, 'user_summary': DailyUserSummaryReport, 'spam_summary': MonthlySpamSummaryReport, + 'angular_ssr_metrics': MonthlyAngularSSRMetricsReport, 'new_user_domains': DailyNewUserDomainReport, } diff --git a/osf/metrics/monthly_reports.py b/osf/metrics/monthly_reports.py index 5174ef9cb00..6456a28c662 100644 --- a/osf/metrics/monthly_reports.py +++ b/osf/metrics/monthly_reports.py @@ -17,6 +17,7 @@ 'MonthlyPrivateSpamMetricsReport', 'MonthlyPublicItemUsageReport', 'MonthlySpamSummaryReport', + 'MonthlyAngularSSRMetricsReport', ) @@ -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 diff --git a/osf/metrics/reporters/__init__.py b/osf/metrics/reporters/__init__.py index e6966414c3c..0de9095cbce 100644 --- a/osf/metrics/reporters/__init__.py +++ b/osf/metrics/reporters/__init__.py @@ -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): @@ -34,3 +35,4 @@ class AllMonthlyReporters(enum.Enum): INSTITUTIONAL_SUMMARY = InstitutionalSummaryMonthlyReporter ITEM_USAGE = PublicItemUsageReporter PRIVATE_SPAM_METRICS = PrivateSpamMetricsReporter + ANGULAR_SSR_METRICS = AngularSSRMetricsReporter diff --git a/osf/metrics/reporters/angular_ssr_metrics.py b/osf/metrics/reporters/angular_ssr_metrics.py new file mode 100644 index 00000000000..90b3bd3033f --- /dev/null +++ b/osf/metrics/reporters/angular_ssr_metrics.py @@ -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 diff --git a/osf_tests/metrics/reporters/test_angular_metrics.py b/osf_tests/metrics/reporters/test_angular_metrics.py new file mode 100644 index 00000000000..d996115904b --- /dev/null +++ b/osf_tests/metrics/reporters/test_angular_metrics.py @@ -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) \ No newline at end of file From d9ad8b9ec0f80f97bb12266326896bc909eb1cae Mon Sep 17 00:00:00 2001 From: Bohdan Odintsov Date: Wed, 26 Aug 2026 16:33:19 +0300 Subject: [PATCH 2/2] flake8 --- osf_tests/metrics/reporters/test_angular_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osf_tests/metrics/reporters/test_angular_metrics.py b/osf_tests/metrics/reporters/test_angular_metrics.py index d996115904b..67da7932ac5 100644 --- a/osf_tests/metrics/reporters/test_angular_metrics.py +++ b/osf_tests/metrics/reporters/test_angular_metrics.py @@ -83,4 +83,4 @@ def test_reporter(self): def _record_ssr_event(**kwargs): - SSRMetricsEvent.record(**kwargs) \ No newline at end of file + SSRMetricsEvent.record(**kwargs)