From 37ff85deb7f104623ea2258ec3dcd360bae21541 Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Wed, 26 Aug 2026 11:51:39 +0300 Subject: [PATCH] added API for ssr metrics event --- api/metrics/serializers.py | 23 ++- api/metrics/urls.py | 5 + api/metrics/views.py | 14 ++ api_tests/base/test_views.py | 2 + api_tests/metrics/test_ssr_metrics.py | 265 ++++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 api_tests/metrics/test_ssr_metrics.py diff --git a/api/metrics/serializers.py b/api/metrics/serializers.py index a70337fcc04..87c63943890 100644 --- a/api/metrics/serializers.py +++ b/api/metrics/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers as ser from api.base.utils import absolute_reverse -from osf.metrics.events import OsfCountedUsageEvent +from osf.metrics.events import OsfCountedUsageEvent, SSRMetricsEvent logger = logging.getLogger(__name__) @@ -66,6 +66,27 @@ def create(self, validated_data): ) +class SSRMetricsSerializer(ser.Serializer): + url = ser.URLField(max_length=4095, required=True) + status = ser.IntegerField(min_value=100, max_value=599, required=True) + ttfb = ser.IntegerField(min_value=0, required=True) + is_bot = ser.BooleanField(required=True) + is_complete = ser.BooleanField(required=True) + content_type = ser.CharField(max_length=255, required=False, allow_null=True) + user_agent = ser.CharField(max_length=4095, required=True) + + def create(self, validated_data): + return SSRMetricsEvent.record( + url=validated_data['url'], + status=validated_data['status'], + ttfb=validated_data['ttfb'], + isBot=validated_data['is_bot'], + isComplete=validated_data['is_complete'], + userAgent=validated_data['user_agent'], + contentType=validated_data.get('content_type'), + ) + + class ReportNameSerializer(ser.BaseSerializer): def to_representation(self, instance): recent_link = absolute_reverse( diff --git a/api/metrics/urls.py b/api/metrics/urls.py index d9bc0a92307..8c9c9d4d161 100644 --- a/api/metrics/urls.py +++ b/api/metrics/urls.py @@ -29,6 +29,11 @@ views.CountedAuthUsageView.as_view(), name=views.CountedAuthUsageView.view_name, ), + re_path( + r'^events/ssr_metrics/$', + views.SSRMetricsView.as_view(), + name=views.SSRMetricsView.view_name, + ), re_path( r'^query/node_analytics/(?P[a-z0-9]+)/(?Pweek|fortnight|month)/$', views.NodeAnalyticsQuery.as_view(), diff --git a/api/metrics/views.py b/api/metrics/views.py index 607e79cab3d..5953fd9ab4c 100644 --- a/api/metrics/views.py +++ b/api/metrics/views.py @@ -34,6 +34,7 @@ UserVisitsSerializer, UniqueUserVisitsSerializer, CountedAuthUsageSerializer, + SSRMetricsSerializer, ) from api.metrics.utils import ( parse_date_range, @@ -413,6 +414,19 @@ def post(self, request, *args, **kwargs): return HttpResponse(status=201) +class SSRMetricsView(JSONAPIBaseView): + view_category = 'metrics' + view_name = 'ssr-metrics' + + serializer_class = SSRMetricsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return HttpResponse(status=201) + + class NodeAnalyticsQuery(JSONAPIBaseView): permission_classes = ( MustBePublic, diff --git a/api_tests/base/test_views.py b/api_tests/base/test_views.py index e5b0d23edeb..f03cffc8a58 100644 --- a/api_tests/base/test_views.py +++ b/api_tests/base/test_views.py @@ -15,6 +15,7 @@ RegistriesModerationMetricsView, CountedAuthUsageView, MetricsOpenapiView, + SSRMetricsView, ) from api.users.views import ( ClaimUser, @@ -64,6 +65,7 @@ def setUp(self): ClaimUser, CopyFileMetadataView, CountedAuthUsageView, + SSRMetricsView, MoveFileMetadataView, ParseCrossRefConfirmation, RawMetricsView, diff --git a/api_tests/metrics/test_ssr_metrics.py b/api_tests/metrics/test_ssr_metrics.py new file mode 100644 index 00000000000..5d08e2e5e80 --- /dev/null +++ b/api_tests/metrics/test_ssr_metrics.py @@ -0,0 +1,265 @@ +from unittest import mock + +import pytest + +from osf_tests.factories import ( + AuthUserFactory, +) + + +SSR_METRICS_URL = '/_/metrics/events/ssr_metrics/' + +def ssr_metrics_payload(**attributes): + return { + 'data': { + 'type': 'ssr-metrics', + 'attributes': attributes, + }, + } + + +@pytest.fixture +def mock_es8(): + with mock.patch('elasticsearch_metrics.imps.elastic8.TimeseriesRecord.check_djelme_setup'): + with mock.patch('elasticsearch_metrics.imps.elastic8.BaseDjelmeRecord._get_connection') as _mock_get_connection: + _mock_es8 = _mock_get_connection.return_value + _mock_es8.index.return_value = {'result': {}} + yield _mock_es8 + + +@pytest.mark.django_db +class TestSSRMetricsView: + + def get_response(self, app, mock_es8, payload, expected_status_code, expected_call_count): + user = AuthUserFactory() + if expected_status_code >= 400: + resp = app.post_json_api(SSR_METRICS_URL, payload, auth=user.auth, expect_errors=True) + else: + resp = app.post_json_api(SSR_METRICS_URL, payload, auth=user.auth) + + assert resp.status_code == expected_status_code + assert mock_es8.index.call_count == expected_call_count + return resp + + def test_incorrect_url_type(self, app, mock_es8): + payload = ssr_metrics_payload( + url=3, + status=200, + ttfb=100, + is_bot=False, + is_complete=True, + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert 'url' in resp.json['errors'][0]['source']['pointer'] + assert 'Enter a valid URL' in resp.json['errors'][0]['detail'] + + def test_incorrect_url(self, app, mock_es8): + payload = ssr_metrics_payload( + url='random', + status=200, + ttfb=100, + is_bot=False, + is_complete=True, + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert len(resp.json['errors']) == 1 + assert 'url' in resp.json['errors'][0]['source']['pointer'] + assert 'Enter a valid URL' in resp.json['errors'][0]['detail'] + + def test_incorrect_status_type(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status='failed', + ttfb=100, + is_bot=False, + is_complete=True, + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert 'status' in resp.json['errors'][0] + assert 'A valid integer is required' in resp.json['errors'][0]['status'] + + def test_incorrect_status_before_range_value(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=99, + ttfb=100, + is_bot=False, + is_complete=True, + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert 'status' in resp.json['errors'][0] + assert 'Ensure this value is greater than or equal to 100.' in resp.json['errors'][0]['status'] + + def test_incorrect_status_after_range_value(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=600, + ttfb=100, + is_bot=False, + is_complete=True, + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert 'status' in resp.json['errors'][0] + assert 'Ensure this value is less than or equal to 599.' in resp.json['errors'][0]['status'] + + def test_incorrect_ttfb_type(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=200, + ttfb=5.5, + is_bot=False, + is_complete=True, + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert 'ttfb' in resp.json['errors'][0]['source']['pointer'] + assert 'A valid integer is required' in resp.json['errors'][0]['detail'] + + def test_incorrect_ttfb(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=200, + ttfb=-5, + is_bot=False, + is_complete=True, + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert len(resp.json['errors']) == 1 + assert 'ttfb' in resp.json['errors'][0]['source']['pointer'] + assert 'Ensure this value is greater than or equal to 0.' in resp.json['errors'][0]['detail'] + + def test_incorrect_is_bot_type(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=200, + ttfb=4, + is_bot='string', + is_complete=True, + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert 'is_bot' in resp.json['errors'][0]['source']['pointer'] + assert 'Must be a valid boolean.' in resp.json['errors'][0]['detail'] + + def test_incorrect_is_bot(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=200, + ttfb=4, + is_bot=None, + is_complete=True, + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert len(resp.json['errors']) == 1 + assert 'is_bot' in resp.json['errors'][0]['source']['pointer'] + assert 'This field may not be null.' in resp.json['errors'][0]['detail'] + + def test_incorrect_is_complete_type(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=200, + ttfb=5, + is_bot=False, + is_complete='string', + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert 'is_complete' in resp.json['errors'][0]['source']['pointer'] + assert 'Must be a valid boolean.' in resp.json['errors'][0]['detail'] + + def test_incorrect_is_complete(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=200, + ttfb=5, + is_bot=False, + is_complete=None, + user_agent='Mozilla/5.0', + content_type=None + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert len(resp.json['errors']) == 1 + assert 'is_complete' in resp.json['errors'][0]['source']['pointer'] + assert 'This field may not be null.' in resp.json['errors'][0]['detail'] + + def test_incorrect_content_type_type(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=200, + ttfb=5, + is_bot=False, + is_complete=True, + user_agent='Mozilla/5.0', + content_type=False + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert 'content_type' in resp.json['errors'][0]['source']['pointer'] + assert 'Not a valid string.' in resp.json['errors'][0]['detail'] + + def test_incorrect_user_agent_type(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=200, + ttfb=5, + is_bot=False, + is_complete=True, + user_agent=True, + content_type=3 + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert 'user_agent' in resp.json['errors'][0]['source']['pointer'] + assert 'Not a valid string.' in resp.json['errors'][0]['detail'] + + def test_incorrect_user_agent(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=200, + ttfb=5, + is_bot=False, + is_complete=True, + user_agent=None, + content_type='some type' + ) + resp = self.get_response(app, mock_es8, payload, expected_status_code=400, expected_call_count=0) + assert len(resp.json['errors']) == 1 + assert 'user_agent' in resp.json['errors'][0]['source']['pointer'] + assert 'This field may not be null.' in resp.json['errors'][0]['detail'] + + def test_valid_payload(self, app, mock_es8): + payload = ssr_metrics_payload( + url='http://example.com', + status=200, + ttfb=5, + is_bot=False, + is_complete=True, + user_agent='Mozilla/5.0', + content_type='some type' + ) + self.get_response(app, mock_es8, payload, expected_status_code=201, expected_call_count=1) + call = mock_es8.method_calls[0].kwargs['body'] + assert call['url'] == 'http://example.com' + assert call['status'] == 200 + assert call['ttfb'] == 5 + assert call['isBot'] is False + assert call['isComplete'] is True + assert call['userAgent'] == 'Mozilla/5.0' + assert call['contentType'] == 'some type' + assert 'timestamp' in call + assert 'timeseries_timeparts' in call