Skip to content
Open
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
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!
]

Expand Down
12 changes: 12 additions & 0 deletions api/projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
ProjectRetrieveSerializer,
ProjectUpdateSerializer,
)
from telemetry.spans import set_span_attribute
from users.models import FFAdminUser


Expand Down Expand Up @@ -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()

Expand Down
72 changes: 70 additions & 2 deletions api/telemetry/middleware.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import re
from collections.abc import Callable
from typing import cast

import structlog
from django.http.request import HttpRequest
Expand All @@ -7,6 +9,29 @@

from telemetry.spans import get_span_attribute

CLI_USER_AGENT_PATTERN = re.compile(
r"^flagsmith-cli/(?P<version>.+) \((?P<os>[^/()]+)/(?P<arch>[^/()]+)\)$"
)


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"""
Expand Down Expand Up @@ -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
Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Loading
Loading