diff --git a/api/app/settings/common.py b/api/app/settings/common.py index d52655168c65..9393e20979b8 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -395,6 +395,9 @@ USE_POSTGRES_FOR_ANALYTICS = env.bool("USE_POSTGRES_FOR_ANALYTICS", default=False) USE_CACHE_FOR_USAGE_DATA = env.bool("USE_CACHE_FOR_USAGE_DATA", default=True) +# Base URL of the Control Plane that licensed Instances report usage snapshots to. +CONTROL_PLANE_URL = env.str("CONTROL_PLANE_URL", default=None) + API_USAGE_CACHE_SECONDS = env.int("API_USAGE_CACHE_SECONDS", default=0) if not API_USAGE_CACHE_SECONDS: diff --git a/api/app_analytics/analytics_db_service.py b/api/app_analytics/analytics_db_service.py index 38d42f8b27be..70594f8c0202 100644 --- a/api/app_analytics/analytics_db_service.py +++ b/api/app_analytics/analytics_db_service.py @@ -5,7 +5,7 @@ from common.core.utils import is_saas, using_database_replica from dateutil.relativedelta import relativedelta from django.conf import settings -from django.db.models import Q, Sum +from django.db.models import Q, QuerySet, Sum from django.utils import timezone from rest_framework.exceptions import NotFound @@ -78,19 +78,12 @@ def get_usage_data( return [] -def get_usage_data_from_local_db( +def _get_api_usage_bucket_qs( organisation: Organisation, environment_id: int | None = None, project_id: int | None = None, - date_start: datetime | None = None, - date_stop: datetime | None = None, labels_filter: Labels | None = None, -) -> list[UsageData]: - if date_start is None: - date_start = timezone.now() - timedelta(days=30) - if date_stop is None: - date_stop = timezone.now() - +) -> QuerySet[APIUsageBucket]: qs = APIUsageBucket.objects.filter( environment_id__in=_get_environment_ids_for_org(organisation), bucket_size=constants.ANALYTICS_READ_BUCKET_SIZE, @@ -110,17 +103,96 @@ def get_usage_data_from_local_db( if labels_filter: qs = qs.filter(labels__contains=labels_filter) - qs = ( - qs.filter( # type: ignore[assignment] - created_at__date__lte=date_stop, - created_at__date__gt=date_start, - ) - .order_by("created_at__date") + return qs + + +def _aggregate_buckets(qs: QuerySet[APIUsageBucket]) -> list[UsageData]: + annotated = ( + qs.order_by("created_at__date") .values("created_at__date", "resource", "labels") .annotate(count=Sum("total_count")) ) + return map_annotated_api_usage_buckets_to_usage_data(annotated) # type: ignore[arg-type] + + +def get_usage_data_from_local_db( + organisation: Organisation, + environment_id: int | None = None, + project_id: int | None = None, + date_start: datetime | None = None, + date_stop: datetime | None = None, + labels_filter: Labels | None = None, +) -> list[UsageData]: + if date_start is None: + date_start = timezone.now() - timedelta(days=30) + if date_stop is None: + date_stop = timezone.now() + + qs = _get_api_usage_bucket_qs( + organisation, + environment_id=environment_id, + project_id=project_id, + labels_filter=labels_filter, + ).filter( + created_at__date__lte=date_stop, + created_at__date__gt=date_start, + ) + return _aggregate_buckets(qs) + + +def get_usage_data_from_local_db_for_window( + organisation: Organisation, + date_start: datetime, + date_stop: datetime, + project_id: int | None = None, +) -> list[UsageData]: + # Filters on the full timestamp (not ``created_at__date``) so that sub-day + # windows such as a single hour are honoured. The daily ``get_usage_data`` + # path keeps its date-truncated filter to leave the billing endpoint + # unchanged. + qs = _get_api_usage_bucket_qs( + organisation, + project_id=project_id, + ).filter( + created_at__gte=date_start, + created_at__lt=date_stop, + ) + return _aggregate_buckets(qs) + - return map_annotated_api_usage_buckets_to_usage_data(qs) +def get_usage_data_for_window( + organisation: Organisation, + date_start: datetime, + date_stop: datetime, + project_id: int | None = None, +) -> list[UsageData]: + """ + Return per-resource API-call counts for an explicit datetime window. + + Mirrors ``get_usage_data`` but at full datetime + resolution, so callers can request a window finer than a day. + """ + if settings.USE_POSTGRES_FOR_ANALYTICS: + return get_usage_data_from_local_db_for_window( + organisation, + date_start=date_start, + date_stop=date_stop, + project_id=project_id, + ) + + if settings.INFLUXDB_TOKEN: + return get_usage_data_from_influxdb( + organisation_id=organisation.id, + project_id=project_id, + date_start=date_start, + date_stop=date_stop, + ) + + logger.warning( + "no-analytics-database-configured", + details=constants.NO_ANALYTICS_DATABASE_CONFIGURED_WARNING, + ) + return [] def get_top_organisations_from_local_db( diff --git a/api/organisations/tasks.py b/api/organisations/tasks.py index 6a2a0079b017..f7a00ef62926 100644 --- a/api/organisations/tasks.py +++ b/api/organisations/tasks.py @@ -29,6 +29,7 @@ Subscription, ) from organisations.subscriptions.constants import FREE_PLAN_ID +from organisations.usage_reporting.services import push_usage_snapshots from users.models import FFAdminUser from .constants import ( @@ -87,6 +88,11 @@ def update_organisation_subscription_information_cache_recurring() -> None: update_organisation_subscription_information_cache() +@register_recurring_task(run_every=timedelta(hours=1)) +def push_usage_to_control_plane() -> None: + push_usage_snapshots() + + @register_task_handler() def update_organisation_subscription_information_api_usage_cache() -> None: subscription_info_cache.update_caches(SubscriptionCacheEntity.API_USAGE) diff --git a/api/organisations/usage_reporting/__init__.py b/api/organisations/usage_reporting/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/organisations/usage_reporting/dataclasses.py b/api/organisations/usage_reporting/dataclasses.py new file mode 100644 index 000000000000..92945ccc3e74 --- /dev/null +++ b/api/organisations/usage_reporting/dataclasses.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class ProjectUsage: + project_id: int + api_call_count: int + + +@dataclass(frozen=True) +class ApiCallBreakdown: + flags: int + identities: int + traits: int + environment_documents: int + + +@dataclass(frozen=True) +class UsageSnapshot: + timestamp: datetime + seat_count: int + api_call_total: int + api_call_breakdown: ApiCallBreakdown + project_count: int + instance_version: str + project_usage: list[ProjectUsage] diff --git a/api/organisations/usage_reporting/mappers.py b/api/organisations/usage_reporting/mappers.py new file mode 100644 index 000000000000..c1a01156fd0f --- /dev/null +++ b/api/organisations/usage_reporting/mappers.py @@ -0,0 +1,95 @@ +from datetime import datetime, timedelta + +from common.core.utils import get_version +from django.db.models import QuerySet +from django.utils import timezone + +from app_analytics.analytics_db_service import get_usage_data_for_window +from app_analytics.dataclasses import UsageData +from organisations.models import Organisation +from organisations.usage_reporting.dataclasses import ( + ApiCallBreakdown, + ProjectUsage, + UsageSnapshot, +) +from projects.models import Project + +# The Control Plane rejects payloads with more than this many project_usage rows. +MAX_PROJECT_USAGE_ROWS = 5_000 + + +def _complete_hour_window() -> tuple[datetime, datetime]: + hour_end = timezone.now().replace(minute=0, second=0, microsecond=0) + hour_start = hour_end - timedelta(hours=1) + return hour_start, hour_end + + +def _total_api_calls(usage_data: list[UsageData]) -> int: + return sum( + data.flags + data.identities + data.traits + data.environment_document + for data in usage_data + ) + + +def _aggregate(usage_data: list[UsageData]) -> tuple[int, ApiCallBreakdown]: + breakdown = ApiCallBreakdown( + flags=sum(data.flags for data in usage_data), + identities=sum(data.identities for data in usage_data), + traits=sum(data.traits for data in usage_data), + environment_documents=sum(data.environment_document for data in usage_data), + ) + api_call_total = ( + breakdown.flags + + breakdown.identities + + breakdown.traits + + breakdown.environment_documents + ) + return api_call_total, breakdown + + +def _project_usage( + *, + organisation: Organisation, + projects: QuerySet[Project], + hour_start: datetime, + hour_end: datetime, +) -> list[ProjectUsage]: + rows = [ + ProjectUsage( + project_id=project.id, + api_call_count=_total_api_calls( + get_usage_data_for_window( + organisation, + hour_start, + hour_end, + project_id=project.id, + ) + ), + ) + for project in projects + ] + # Highest usage first + rows.sort(key=lambda row: row.api_call_count, reverse=True) + return rows[:MAX_PROJECT_USAGE_ROWS] + + +def map_organisation_to_usage_snapshot(organisation: Organisation) -> UsageSnapshot: + hour_start, hour_end = _complete_hour_window() + api_call_total, api_call_breakdown = _aggregate( + get_usage_data_for_window(organisation, hour_start, hour_end) + ) + projects = organisation.projects.all() + return UsageSnapshot( + timestamp=hour_start, + seat_count=organisation.num_seats, + api_call_total=api_call_total, + api_call_breakdown=api_call_breakdown, + project_count=projects.count(), + instance_version=get_version(), + project_usage=_project_usage( + organisation=organisation, + projects=projects, + hour_start=hour_start, + hour_end=hour_end, + ), + ) diff --git a/api/organisations/usage_reporting/services.py b/api/organisations/usage_reporting/services.py new file mode 100644 index 000000000000..db840adcad3f --- /dev/null +++ b/api/organisations/usage_reporting/services.py @@ -0,0 +1,79 @@ +import base64 +import dataclasses +import json + +import requests +import structlog +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder + +from organisations.models import Organisation +from organisations.usage_reporting.dataclasses import UsageSnapshot +from organisations.usage_reporting.mappers import ( + map_organisation_to_usage_snapshot, +) + +logger = structlog.get_logger("usage_reporting") + +USAGE_ENDPOINT_PATH = "/v1/usage" +REQUEST_TIMEOUT_SECONDS = 30 + + +def _build_auth_token(signature: str) -> str: + return ( + base64.urlsafe_b64encode(signature.encode("utf-8")).decode("ascii").rstrip("=") + ) + + +def get_licensed_organisations() -> list[Organisation]: + if not settings.LICENSING_INSTALLED: + return [] + return list( # pragma: no cover - requires the optional licensing package + Organisation.objects.filter(licence__isnull=False).select_related("licence") + ) + + +def push_snapshot( + *, + base_url: str, + snapshot: UsageSnapshot, + signature: str, +) -> None: + url = f"{base_url.rstrip('/')}{USAGE_ENDPOINT_PATH}" + headers = { + "Authorization": f"Bearer {_build_auth_token(signature)}", + "Content-Type": "application/json", + } + body = json.dumps(dataclasses.asdict(snapshot), cls=DjangoJSONEncoder) + + response = requests.post( + url, + data=body, + headers=headers, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + + if response.ok: + logger.info("snapshot.pushed", status_code=response.status_code) + else: + logger.warning("snapshot.push_failed", status_code=response.status_code) + + +def push_usage_snapshots() -> None: + if not (base_url := settings.CONTROL_PLANE_URL): + logger.debug("run.skipped", reason="control_plane_url_unset") + return + if not (organisations := get_licensed_organisations()): + logger.debug("run.skipped", reason="no_licensed_organisations") + return + + for organisation in organisations: + with structlog.contextvars.bound_contextvars(organisation__id=organisation.id): + try: + push_snapshot( + base_url=base_url, + snapshot=map_organisation_to_usage_snapshot(organisation), + signature=organisation.licence.signature, + ) + except Exception: + logger.exception("snapshot.errored") diff --git a/api/tests/unit/app_analytics/test_analytics_db_service.py b/api/tests/unit/app_analytics/test_analytics_db_service.py index bd527da826e3..50321bdcc47a 100644 --- a/api/tests/unit/app_analytics/test_analytics_db_service.py +++ b/api/tests/unit/app_analytics/test_analytics_db_service.py @@ -12,7 +12,9 @@ get_top_organisations_from_local_db, get_total_events_count, get_usage_data, + get_usage_data_for_window, get_usage_data_from_local_db, + get_usage_data_from_local_db_for_window, ) from app_analytics.constants import CURRENT_BILLING_PERIOD, PREVIOUS_BILLING_PERIOD from app_analytics.dataclasses import FeatureEvaluationData, UsageData @@ -812,3 +814,155 @@ def test_get_top_organisations_from_local_db__saas_mode__raises_runtime_error( # When / Then with pytest.raises(RuntimeError, match="Must not run in SaaS mode"): get_top_organisations_from_local_db(timezone.now() - timedelta(days=30)) + + +@pytest.mark.use_analytics_db +@pytest.mark.freeze_time("2023-01-19T09:00:00+00:00") +def test_get_usage_data_from_local_db_for_window__bucket_outside_window__excluded( + organisation: Organisation, + environment: Environment, + settings: SettingsWrapper, +) -> None: + # Given + settings.ANALYTICS_BUCKET_SIZE = 15 + now = timezone.now() + hour_start = now - timedelta(hours=1) + + # A bucket inside the window [08:00, 09:00) ... + APIUsageBucket.objects.create( + environment_id=environment.id, + resource=Resource.FLAGS, + total_count=5, + bucket_size=15, + created_at=hour_start + timedelta(minutes=30), + ) + # ... and one earlier the same day, before the window, that the daily filter + # would have included but the datetime window must exclude. + APIUsageBucket.objects.create( + environment_id=environment.id, + resource=Resource.FLAGS, + total_count=99, + bucket_size=15, + created_at=hour_start - timedelta(hours=1), + ) + + # When + usage_data = get_usage_data_from_local_db_for_window( + organisation, + date_start=hour_start, + date_stop=now, + ) + + # Then + assert len(usage_data) == 1 + assert usage_data[0].flags == 5 + + +@pytest.mark.use_analytics_db +@pytest.mark.freeze_time("2023-01-19T09:00:00+00:00") +def test_get_usage_data_from_local_db_for_window__project_id_filter__scopes_to_project( + organisation: Organisation, + project: Project, + project_two: Project, + environment: Environment, + project_two_environment: Environment, + settings: SettingsWrapper, +) -> None: + # Given + settings.ANALYTICS_BUCKET_SIZE = 15 + now = timezone.now() + hour_start = now - timedelta(hours=1) + for environment_id in [environment.id, project_two_environment.id]: + APIUsageBucket.objects.create( + environment_id=environment_id, + resource=Resource.FLAGS, + total_count=10, + bucket_size=15, + created_at=hour_start + timedelta(minutes=10), + ) + + # When + usage_data = get_usage_data_from_local_db_for_window( + organisation, + date_start=hour_start, + date_stop=now, + project_id=project.id, + ) + + # Then + assert len(usage_data) == 1 + assert usage_data[0].flags == 10 + + +def test_get_usage_data_for_window__postgres_configured__calls_local_db_for_window( + mocker: MockerFixture, + settings: SettingsWrapper, + organisation: Organisation, +) -> None: + # Given + settings.USE_POSTGRES_FOR_ANALYTICS = True + date_start = timezone.now() - timedelta(hours=1) + date_stop = timezone.now() + mocked_local_db_for_window = mocker.patch( + "app_analytics.analytics_db_service.get_usage_data_from_local_db_for_window", + autospec=True, + ) + + # When + usage_data = get_usage_data_for_window(organisation, date_start, date_stop) + + # Then + assert usage_data == mocked_local_db_for_window.return_value + mocked_local_db_for_window.assert_called_once_with( + organisation, + date_start=date_start, + date_stop=date_stop, + project_id=None, + ) + + +def test_get_usage_data_for_window__influxdb_configured__calls_influxdb( + mocker: MockerFixture, + settings: SettingsWrapper, + organisation: Organisation, +) -> None: + # Given + settings.USE_POSTGRES_FOR_ANALYTICS = False + settings.INFLUXDB_TOKEN = "test-token" + date_start = timezone.now() - timedelta(hours=1) + date_stop = timezone.now() + mocked_influxdb = mocker.patch( + "app_analytics.analytics_db_service.get_usage_data_from_influxdb", + autospec=True, + ) + + # When + usage_data = get_usage_data_for_window(organisation, date_start, date_stop) + + # Then + assert usage_data == mocked_influxdb.return_value + mocked_influxdb.assert_called_once_with( + organisation_id=organisation.id, + project_id=None, + date_start=date_start, + date_stop=date_stop, + ) + + +def test_get_usage_data_for_window__no_analytics_configured__returns_empty( + settings: SettingsWrapper, + organisation: Organisation, +) -> None: + # Given + settings.USE_POSTGRES_FOR_ANALYTICS = False + settings.INFLUXDB_TOKEN = None + + # When + result = get_usage_data_for_window( + organisation, + timezone.now() - timedelta(hours=1), + timezone.now(), + ) + + # Then + assert result == [] diff --git a/api/tests/unit/organisations/usage_reporting/__init__.py b/api/tests/unit/organisations/usage_reporting/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/organisations/usage_reporting/test_mappers.py b/api/tests/unit/organisations/usage_reporting/test_mappers.py new file mode 100644 index 000000000000..25889b97f407 --- /dev/null +++ b/api/tests/unit/organisations/usage_reporting/test_mappers.py @@ -0,0 +1,103 @@ +import pytest +from pytest_mock import MockerFixture + +from app_analytics.dataclasses import UsageData +from organisations.models import Organisation +from organisations.usage_reporting import mappers +from organisations.usage_reporting.dataclasses import ProjectUsage +from organisations.usage_reporting.mappers import ( + map_organisation_to_usage_snapshot, +) +from projects.models import Project + + +@pytest.mark.freeze_time("2026-06-18T09:30:00+00:00") +def test_map_organisation_to_usage_snapshot__with_usage__returns_populated_snapshot( + organisation: Organisation, + project: Project, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch.object(mappers, "get_version", return_value="2.142.3") + mocker.patch.object( + mappers, + "get_usage_data_for_window", + return_value=[ + UsageData( + day=None, # type: ignore[arg-type] + flags=1, + identities=2, + traits=3, + environment_document=4, + ) + ], + ) + + # When + snapshot = map_organisation_to_usage_snapshot(organisation) + + # Then + assert snapshot.timestamp.isoformat() == "2026-06-18T08:00:00+00:00" + assert snapshot.seat_count == organisation.num_seats + assert snapshot.instance_version == "2.142.3" + assert snapshot.api_call_total == 10 + assert snapshot.api_call_breakdown.flags == 1 + assert snapshot.api_call_breakdown.identities == 2 + assert snapshot.api_call_breakdown.traits == 3 + assert snapshot.api_call_breakdown.environment_documents == 4 + assert snapshot.project_count == 1 + assert snapshot.project_usage == [ + ProjectUsage(project_id=project.id, api_call_count=10) + ] + + +def test_map_organisation_to_usage_snapshot__no_analytics__returns_zeroed_snapshot( + organisation: Organisation, + project: Project, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch.object(mappers, "get_version", return_value="2.142.3") + mocker.patch.object(mappers, "get_usage_data_for_window", return_value=[]) + + # When + snapshot = map_organisation_to_usage_snapshot(organisation) + + # Then + assert snapshot.api_call_total == 0 + assert snapshot.api_call_breakdown.flags == 0 + assert snapshot.api_call_breakdown.environment_documents == 0 + assert snapshot.project_count == 1 + assert snapshot.project_usage == [ + ProjectUsage(project_id=project.id, api_call_count=0) + ] + + +def test_map_organisation_to_usage_snapshot__excess_projects__keeps_highest_usage( + organisation: Organisation, + project: Project, + project_two: Project, + mocker: MockerFixture, +) -> None: + # Given - project_two has more usage than project + usage_by_project = { + project.id: [UsageData(day=None, flags=5)], # type: ignore[arg-type] + project_two.id: [UsageData(day=None, flags=100)], # type: ignore[arg-type] + } + mocker.patch.object(mappers, "get_version", return_value="2.142.3") + mocker.patch.object( + mappers, + "get_usage_data_for_window", + side_effect=lambda org, start, stop, project_id=None: usage_by_project.get( + project_id, [] + ), + ) + mocker.patch.object(mappers, "MAX_PROJECT_USAGE_ROWS", 1) + + # When + snapshot = map_organisation_to_usage_snapshot(organisation) + + # Then - only the highest-usage project survives the cap + assert snapshot.project_usage == [ + ProjectUsage(project_id=project_two.id, api_call_count=100) + ] diff --git a/api/tests/unit/organisations/usage_reporting/test_services.py b/api/tests/unit/organisations/usage_reporting/test_services.py new file mode 100644 index 000000000000..7008551bb8e7 --- /dev/null +++ b/api/tests/unit/organisations/usage_reporting/test_services.py @@ -0,0 +1,216 @@ +import base64 +import json +from datetime import datetime, timezone + +import pytest +from pytest_django.fixtures import SettingsWrapper +from pytest_mock import MockerFixture +from pytest_structlog import StructuredLogCapture + +from organisations.usage_reporting.dataclasses import ( + ApiCallBreakdown, + ProjectUsage, + UsageSnapshot, +) +from organisations.usage_reporting.services import ( + _build_auth_token, + get_licensed_organisations, + push_snapshot, + push_usage_snapshots, +) + +SERVICES = "organisations.usage_reporting.services" + + +def _snapshot() -> UsageSnapshot: + return UsageSnapshot( + timestamp=datetime(2026, 6, 18, 8, 0, 0, tzinfo=timezone.utc), + seat_count=3, + api_call_total=10, + api_call_breakdown=ApiCallBreakdown( + flags=1, identities=2, traits=3, environment_documents=4 + ), + project_count=1, + instance_version="2.142.3", + project_usage=[ProjectUsage(project_id=1, api_call_count=10)], + ) + + +def test_build_auth_token__base64url_token__decodes_back_to_signature() -> None: + # Given + signature = "abc+/=def==" + + # When + token = _build_auth_token(signature) + + # Then - the Control Plane recovers the raw signature this way + recovered = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)) + assert recovered == signature.encode("utf-8") + + +def test_get_licensed_organisations__licensing_not_installed__returns_empty( + settings: SettingsWrapper, +) -> None: + # Given + settings.LICENSING_INSTALLED = False + + # When / Then + assert get_licensed_organisations() == [] + + +@pytest.mark.parametrize( + "status_code, event, level", + [ + (201, "snapshot.pushed", "info"), + (200, "snapshot.pushed", "info"), + (400, "snapshot.push_failed", "warning"), + (401, "snapshot.push_failed", "warning"), + (403, "snapshot.push_failed", "warning"), + (429, "snapshot.push_failed", "warning"), + (500, "snapshot.push_failed", "warning"), + (503, "snapshot.push_failed", "warning"), + (418, "snapshot.push_failed", "warning"), + ], +) +def test_push_snapshot__status_code__logs_expected_event( + mocker: MockerFixture, + log: StructuredLogCapture, + status_code: int, + event: str, + level: str, +) -> None: + # Given + mocked_post = mocker.patch(f"{SERVICES}.requests.post") + mocked_post.return_value.status_code = status_code + mocked_post.return_value.ok = status_code < 400 + + # When + push_snapshot( + base_url="https://cp.example.com/", + snapshot=_snapshot(), + signature="sig", + ) + + # Then + assert log.has(event, level=level, status_code=status_code) + + +def test_push_snapshot__valid_snapshot__sends_bearer_authed_post( + mocker: MockerFixture, +) -> None: + # Given + mocked_post = mocker.patch(f"{SERVICES}.requests.post") + mocked_post.return_value.status_code = 201 + + # When + push_snapshot( + base_url="https://cp.example.com/", + snapshot=_snapshot(), + signature="sig", + ) + + # Then + mocked_post.assert_called_once() + (url,), kwargs = mocked_post.call_args + assert url == "https://cp.example.com/v1/usage" + assert kwargs["headers"]["Authorization"] == f"Bearer {_build_auth_token('sig')}" + assert kwargs["headers"]["Content-Type"] == "application/json" + body = json.loads(kwargs["data"]) + assert body["timestamp"] == "2026-06-18T08:00:00Z" + assert body["api_call_breakdown"]["environment_documents"] == 4 + assert body["project_usage"] == [{"project_id": 1, "api_call_count": 10}] + + +def test_push_usage_snapshots__control_plane_url_unset__no_op( + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + # Given + settings.CONTROL_PLANE_URL = None + mocked_get_orgs = mocker.patch(f"{SERVICES}.get_licensed_organisations") + mocked_push = mocker.patch(f"{SERVICES}.push_snapshot") + + # When + push_usage_snapshots() + + # Then + mocked_get_orgs.assert_not_called() + mocked_push.assert_not_called() + + +def test_push_usage_snapshots__no_licensed_organisations__no_op( + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + # Given + settings.CONTROL_PLANE_URL = "https://cp.example.com" + mocker.patch(f"{SERVICES}.get_licensed_organisations", return_value=[]) + mocked_push = mocker.patch(f"{SERVICES}.push_snapshot") + + # When + push_usage_snapshots() + + # Then + mocked_push.assert_not_called() + + +def test_push_usage_snapshots__licensed_organisations__pushes_each( + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + # Given + settings.CONTROL_PLANE_URL = "https://cp.example.com" + org_one = mocker.Mock(id=1, licence=mocker.Mock(signature="sig-1")) + org_two = mocker.Mock(id=2, licence=mocker.Mock(signature="sig-2")) + mocker.patch( + f"{SERVICES}.get_licensed_organisations", + return_value=[org_one, org_two], + ) + snapshot = _snapshot() + mocker.patch( + f"{SERVICES}.map_organisation_to_usage_snapshot", + return_value=snapshot, + ) + mocked_push = mocker.patch(f"{SERVICES}.push_snapshot") + + # When + push_usage_snapshots() + + # Then + assert mocked_push.call_count == 2 + mocked_push.assert_any_call( + base_url="https://cp.example.com", snapshot=snapshot, signature="sig-1" + ) + mocked_push.assert_any_call( + base_url="https://cp.example.com", snapshot=snapshot, signature="sig-2" + ) + + +def test_push_usage_snapshots__one_organisation_raises__continues( + settings: SettingsWrapper, + mocker: MockerFixture, + log: StructuredLogCapture, +) -> None: + # Given + settings.CONTROL_PLANE_URL = "https://cp.example.com" + org_one = mocker.Mock(id=1, licence=mocker.Mock(signature="sig-1")) + org_two = mocker.Mock(id=2, licence=mocker.Mock(signature="sig-2")) + mocker.patch( + f"{SERVICES}.get_licensed_organisations", + return_value=[org_one, org_two], + ) + snapshot = _snapshot() + mocker.patch( + f"{SERVICES}.map_organisation_to_usage_snapshot", + side_effect=[ValueError("boom"), snapshot], + ) + mocked_push = mocker.patch(f"{SERVICES}.push_snapshot") + + # When + push_usage_snapshots() + + # Then - first organisation failed (logged) but second still pushed + assert log.has("snapshot.errored", level="error") + mocked_push.assert_called_once_with( + base_url="https://cp.example.com", snapshot=snapshot, signature="sig-2" + ) diff --git a/api/tests/unit/organisations/usage_reporting/test_tasks.py b/api/tests/unit/organisations/usage_reporting/test_tasks.py new file mode 100644 index 000000000000..6c80ab9b5bf8 --- /dev/null +++ b/api/tests/unit/organisations/usage_reporting/test_tasks.py @@ -0,0 +1,16 @@ +from pytest_mock import MockerFixture + +from organisations.tasks import push_usage_to_control_plane + + +def test_push_usage_to_control_plane__called__delegates_to_service( + mocker: MockerFixture, +) -> None: + # Given + mock_push = mocker.patch("organisations.tasks.push_usage_snapshots") + + # When + push_usage_to_control_plane() + + # Then + assert mock_push.call_args_list == [mocker.call()] diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 1022283a1380..d3f4d9594494 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -34,7 +34,8 @@ Attributes: Logged at `warning` from: - `api/app_analytics/analytics_db_service.py:74` - - `api/app_analytics/analytics_db_service.py:210` + - `api/app_analytics/analytics_db_service.py:191` + - `api/app_analytics/analytics_db_service.py:282` Attributes: - `details` @@ -462,6 +463,38 @@ Attributes: - `feature_name` - `sentry_action` +### `usage_reporting.run.skipped` + +Logged at `debug` from: + - `api/organisations/usage_reporting/services.py:64` + - `api/organisations/usage_reporting/services.py:67` + +Attributes: + - `reason` + +### `usage_reporting.snapshot.errored` + +Logged at `exception` from: + - `api/organisations/usage_reporting/services.py:79` + +Attributes: + +### `usage_reporting.snapshot.push_failed` + +Logged at `warning` from: + - `api/organisations/usage_reporting/services.py:59` + +Attributes: + - `status_code` + +### `usage_reporting.snapshot.pushed` + +Logged at `info` from: + - `api/organisations/usage_reporting/services.py:57` + +Attributes: + - `status_code` + ### `warehouse.connection.connected` Logged at `info` from: