diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 0ebbc0ec3cc1..7cfdb5ee3738 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -397,6 +397,7 @@ "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "simple_history.middleware.HistoryRequestMiddleware", + "telemetry.middleware.CLIUsageLoggerMiddleware", "telemetry.middleware.MCPUsageLoggerMiddleware", # Must come last! ] diff --git a/api/projects/views.py b/api/projects/views.py index 8274dd79cd5f..8aa59f5fc6e5 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -48,6 +48,7 @@ ProjectRetrieveSerializer, ProjectUpdateSerializer, ) +from telemetry.spans import set_span_attribute from users.models import FFAdminUser @@ -81,6 +82,17 @@ def get_serializer_class(self): # type: ignore[no-untyped-def] pagination_class = None + def list(self, request, *args, **kwargs): # type: ignore[no-untyped-def] + response = super().list(request, *args, **kwargs) + + if response.data and ( + organisation_id := request.query_params.get("organisation") + ): + # get_queryset validates the ID and restricts results to permitted projects. + set_span_attribute("organisation.id", int(organisation_id)) + + return response + def get_serializer_context(self): # type: ignore[no-untyped-def] return super().get_serializer_context() diff --git a/api/telemetry/middleware.py b/api/telemetry/middleware.py index 7754a686f523..358ab26be956 100644 --- a/api/telemetry/middleware.py +++ b/api/telemetry/middleware.py @@ -1,4 +1,6 @@ +import re from collections.abc import Callable +from typing import cast import structlog from django.http.request import HttpRequest @@ -7,6 +9,29 @@ from telemetry.spans import get_span_attribute +CLI_USER_AGENT_PATTERN = re.compile( + r"^flagsmith-cli/(?P.+) \((?P[^/()]+)/(?P[^/()]+)\)$" +) + + +def _get_organisation_id_from_request_context( + request: HttpRequest, +) -> int | None: + # Set by the permission layer for organisations the user belongs to + if isinstance( + organisation_id := get_span_attribute("organisation.id"), + int, + ): + return organisation_id + + if getattr(request.user, "is_master_api_key_user", False): + from api_keys.user import APIKeyUser + + assert isinstance(request.user, APIKeyUser) + return cast(int, request.user.key.organisation_id) + + return None + class MCPUsageLoggerMiddleware: """Emit telemetry events for MCP usage""" @@ -45,8 +70,9 @@ def _get_organisation_id(self, request: HttpRequest) -> int | None: """Obtain the organisation ID from the request context.""" from organisations.models import Organisation - # Set by the permission layer for organisations the user belongs to - if isinstance(organisation_id := get_span_attribute("organisation.id"), int): + if ( + organisation_id := _get_organisation_id_from_request_context(request) + ) is not None: return organisation_id assert request.user.is_authenticated # NOTE: protected upstream @@ -57,3 +83,45 @@ def _get_organisation_id(self, request: HttpRequest) -> int | None: Organisation.MultipleObjectsReturned, # Don't guess ): return None + + +class CLIUsageLoggerMiddleware: + """Emit telemetry events for Flagsmith CLI usage.""" + + def __init__( + self, + get_response: Callable[[HttpRequest], HttpResponse], + ) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + response = self.get_response(request) + + match = CLI_USER_AGENT_PATTERN.fullmatch(request.headers.get("User-Agent", "")) + if match is None: + return response + + if not request.user or not request.user.is_authenticated: + return response + + event = { + "cli__version": match.group("version"), + "cli__os": match.group("os"), + "cli__arch": match.group("arch"), + "status": "error" if response.status_code >= 400 else "success", + } + + if ( + organisation_id := _get_organisation_id_from_request_context(request) + ) is not None: + event["organisation__id"] = organisation_id + + if not getattr(request.user, "is_master_api_key_user", False): + from users.models import FFAdminUser + + assert isinstance(request.user, FFAdminUser) + event["amplitude__user_id"] = str(request.user.uuid) + + structlog.get_logger("cli").info("request.made", **event) + + return response diff --git a/api/tests/unit/telemetry/test_unit_telemetry_middleware.py b/api/tests/unit/telemetry/test_unit_telemetry_middleware.py index 2ce11324edc0..529aa044aed5 100644 --- a/api/tests/unit/telemetry/test_unit_telemetry_middleware.py +++ b/api/tests/unit/telemetry/test_unit_telemetry_middleware.py @@ -1,9 +1,15 @@ import pytest +from django.http import HttpRequest, HttpResponse +from pytest_django import DjangoAssertNumQueries from pytest_structlog import StructuredLogCapture from rest_framework.test import APIClient +from api_keys.user import APIKeyUser from environments.models import Environment from organisations.models import Organisation, OrganisationRole +from projects.models import Project +from telemetry.middleware import CLIUsageLoggerMiddleware +from telemetry.spans import set_span_attribute from users.models import FFAdminUser @@ -166,3 +172,275 @@ def test_mcp_usage_logger_middleware__sdk_request__logs_nothing( # Then assert response.status_code == 200 assert log.events == [] + + +@pytest.mark.usefixtures("recording_span") +def test_cli_usage_logger_middleware__authenticated_cli_request__logs_usage_event( + staff_client: APIClient, + staff_user: FFAdminUser, + organisation: Organisation, + log: StructuredLogCapture, +) -> None: + # Given + set_span_attribute("organisation.id", organisation.pk) + + # When + response = staff_client.get( + "/api/v1/projects/", + HTTP_USER_AGENT="flagsmith-cli/2.0.0-beta.3 (darwin/arm64)", + ) + + # Then + assert response.status_code == 200 + assert log.events == [ + { + "level": "info", + "event": "request.made", + "cli__version": "2.0.0-beta.3", + "cli__os": "darwin", + "cli__arch": "arm64", + "organisation__id": organisation.pk, + "status": "success", + "amplitude__user_id": str(staff_user.uuid), + } + ] + + +@pytest.mark.usefixtures("recording_span") +def test_cli_usage_logger_middleware__project_list_with_organisation__logs_organisation_id( + admin_client: APIClient, + admin_user: FFAdminUser, + organisation: Organisation, + project: Project, + log: StructuredLogCapture, +) -> None: + # Given / When + response = admin_client.get( + f"/api/v1/projects/?organisation={organisation.pk}", + HTTP_USER_AGENT="flagsmith-cli/2.0.0 (linux/amd64)", + ) + + # Then + assert response.status_code == 200 + + response_data = response.json() + assert any(item["id"] == project.id for item in response_data) + + assert log.events == [ + { + "level": "info", + "event": "request.made", + "cli__version": "2.0.0", + "cli__os": "linux", + "cli__arch": "amd64", + "organisation__id": organisation.pk, + "status": "success", + "amplitude__user_id": str(admin_user.uuid), + } + ] + + +@pytest.mark.usefixtures("recording_span") +def test_cli_usage_logger_middleware__project_list_with_unpermitted_organisation__does_not_log_organisation_id( + staff_client: APIClient, + staff_user: FFAdminUser, + log: StructuredLogCapture, +) -> None: + # Given + other_organisation = Organisation.objects.create(name="Other Org") + + # When + response = staff_client.get( + f"/api/v1/projects/?organisation={other_organisation.pk}", + HTTP_USER_AGENT="flagsmith-cli/2.0.0 (linux/amd64)", + ) + + # Then + assert response.status_code == 200 + assert log.events == [ + { + "level": "info", + "event": "request.made", + "cli__version": "2.0.0", + "cli__os": "linux", + "cli__arch": "amd64", + "status": "success", + "amplitude__user_id": str(staff_user.uuid), + } + ] + + +@pytest.mark.usefixtures("recording_span") +def test_cli_usage_logger_middleware__project_list_with_empty_result__does_not_log_organisation_id( + admin_client: APIClient, + admin_user: FFAdminUser, + organisation: Organisation, + log: StructuredLogCapture, +) -> None: + # Given / When + response = admin_client.get( + f"/api/v1/projects/?organisation={organisation.pk}", + HTTP_USER_AGENT="flagsmith-cli/2.0.0 (linux/amd64)", + ) + + # Then + assert response.status_code == 200 + assert response.json() == [] + + assert log.events == [ + { + "level": "info", + "event": "request.made", + "cli__version": "2.0.0", + "cli__os": "linux", + "cli__arch": "amd64", + "status": "success", + "amplitude__user_id": str(admin_user.uuid), + } + ] + + +def test_cli_usage_logger_middleware__unauthenticated_cli_request__logs_nothing( + api_client: APIClient, + log: StructuredLogCapture, +) -> None: + # Given / When + response = api_client.get( + "/api/v1/projects/", + HTTP_USER_AGENT="flagsmith-cli/2.0.0 (linux/amd64)", + ) + + # Then + assert response.status_code == 401 + assert log.events == [] + + +def test_cli_usage_logger_middleware__master_api_key_request__logs_organisation_without_amplitude_user_id( + admin_master_api_key_client: APIClient, + organisation: Organisation, + log: StructuredLogCapture, +) -> None: + # Given / When + response = admin_master_api_key_client.get( + "/api/v1/projects/", + HTTP_USER_AGENT="flagsmith-cli/2.0.0 (linux/amd64)", + ) + + # Then + assert response.status_code == 200 + assert log.events == [ + { + "level": "info", + "event": "request.made", + "cli__version": "2.0.0", + "cli__os": "linux", + "cli__arch": "amd64", + "organisation__id": organisation.pk, + "status": "success", + } + ] + + +@pytest.mark.usefixtures("recording_span") +def test_cli_usage_logger_middleware__500_response__logs_error_status( + staff_user: FFAdminUser, + organisation: Organisation, + log: StructuredLogCapture, +) -> None: + # Given + set_span_attribute("organisation.id", organisation.pk) + + request = HttpRequest() + request.user = staff_user + request.META["HTTP_USER_AGENT"] = "flagsmith-cli/2.0.0 (linux/amd64)" + + middleware = CLIUsageLoggerMiddleware(lambda request: HttpResponse(status=500)) + + # When + response = middleware(request) + + # Then + assert response.status_code == 500 + assert log.events == [ + { + "level": "info", + "event": "request.made", + "cli__version": "2.0.0", + "cli__os": "linux", + "cli__arch": "amd64", + "organisation__id": organisation.pk, + "status": "error", + "amplitude__user_id": str(staff_user.uuid), + } + ] + + +@pytest.mark.usefixtures("recording_span") +def test_cli_usage_logger_middleware__version_with_spaces__preserves_version_unchanged( + staff_user: FFAdminUser, + organisation: Organisation, + log: StructuredLogCapture, +) -> None: + # Given + set_span_attribute("organisation.id", organisation.pk) + + request = HttpRequest() + request.user = staff_user + request.META["HTTP_USER_AGENT"] = "flagsmith-cli/dev (abcdefg) (linux/amd64)" + + middleware = CLIUsageLoggerMiddleware(lambda request: HttpResponse(status=200)) + + # When + response = middleware(request) + + # Then + assert response.status_code == 200 + assert log.events == [ + { + "level": "info", + "event": "request.made", + "cli__version": "dev (abcdefg)", + "cli__os": "linux", + "cli__arch": "amd64", + "organisation__id": organisation.pk, + "status": "success", + "amplitude__user_id": str(staff_user.uuid), + } + ] + + +def test_cli_usage_logger_middleware__non_cli_user_agent__logs_nothing( + staff_user: FFAdminUser, + log: StructuredLogCapture, +) -> None: + # Given + request = HttpRequest() + request.user = staff_user + request.META["HTTP_USER_AGENT"] = "Mozilla/5.0" + + middleware = CLIUsageLoggerMiddleware(lambda request: HttpResponse(status=200)) + + # When + response = middleware(request) + + # Then + assert response.status_code == 200 + assert log.events == [] + + +def test_cli_usage_logger_middleware__master_api_key__makes_no_database_queries( + api_key_user: APIKeyUser, + django_assert_num_queries: DjangoAssertNumQueries, +) -> None: + # Given + request = HttpRequest() + setattr(request, "user", api_key_user) + request.META["HTTP_USER_AGENT"] = "flagsmith-cli/2.0.0 (linux/amd64)" + + middleware = CLIUsageLoggerMiddleware(lambda request: HttpResponse(status=200)) + + # When / Then + with django_assert_num_queries(0): + response = middleware(request) + + assert response.status_code == 200 diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 3826b5823873..5b0b2be47d7a 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -563,8 +563,8 @@ Attributes: ### `mcp.tool.called` Logged at `info` from: - - `api/telemetry/middleware.py:38` - - `api/telemetry/middleware.py:40` + - `api/telemetry/middleware.py:63` + - `api/telemetry/middleware.py:65` Attributes: - `organisation.id`