Skip to content

Commit 5d410f2

Browse files
authored
feat(MCP): Push MCP usage via OTel (#7746)
1 parent 8a6c26b commit 5d410f2

9 files changed

Lines changed: 310 additions & 1 deletion

File tree

api/app/settings/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@
367367
"django.contrib.messages.middleware.MessageMiddleware",
368368
"django.middleware.clickjacking.XFrameOptionsMiddleware",
369369
"simple_history.middleware.HistoryRequestMiddleware",
370+
"telemetry.middleware.MCPUsageLoggerMiddleware", # Must come last!
370371
]
371372

372373
ADD_NEVER_CACHE_HEADERS = env.bool("ADD_NEVER_CACHE_HEADERS", True)

api/permissions/permission_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from environments.models import Environment
77
from organisations.models import Organisation, OrganisationRole
88
from projects.models import Project
9+
from telemetry.spans import set_span_attribute
910

1011
from .rbac_wrapper import ( # type: ignore[attr-defined]
1112
get_permitted_environments_for_master_api_key_using_roles,
@@ -25,6 +26,7 @@ def is_user_organisation_admin(
2526
) -> bool:
2627
user_organisation = user.get_user_organisation(organisation)
2728
if user_organisation is not None:
29+
set_span_attribute("organisation.id", user_organisation.organisation_id)
2830
return user_organisation.role == OrganisationRole.ADMIN.name
2931
return False
3032

api/scripts/run-docker.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ set -e
33

44
# common environment variables
55
ACCESS_LOG_FORMAT=${ACCESS_LOG_FORMAT:-'%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %({origin}i)s %({access-control-allow-origin}o)s'}
6-
APPLICATION_LOGGERS=${APPLICATION_LOGGERS:-"app_analytics,audit,code_references,common,core,dynamodb,edge_api,environments,features,import_export,integrations,oauth2_metadata,organisations,projects,segments,task_processor,users,webhooks,workflows"}
6+
APPLICATION_LOGGERS=${APPLICATION_LOGGERS:-"app_analytics,audit,code_references,common,core,dynamodb,edge_api,environments,features,import_export,integrations,mcp,oauth2_metadata,organisations,projects,segments,task_processor,users,webhooks,workflows"}
77

88
waitfordb() {
99
if [ -z "${SKIP_WAIT_FOR_DB}" ]; then

api/telemetry/middleware.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from collections.abc import Callable
2+
3+
import structlog
4+
from django.http.request import HttpRequest
5+
from django.http.response import HttpResponse
6+
from opentelemetry import baggage
7+
8+
from telemetry.spans import get_span_attribute
9+
10+
11+
class MCPUsageLoggerMiddleware:
12+
"""Emit telemetry events for MCP usage"""
13+
14+
def __init__(
15+
self,
16+
get_response: Callable[[HttpRequest], HttpResponse],
17+
) -> None:
18+
self.get_response = get_response
19+
20+
def __call__(self, request: HttpRequest) -> HttpResponse:
21+
response = self.get_response(request)
22+
23+
if baggage.get_baggage("flagsmith.client.name") != "flagsmith-mcp":
24+
return response
25+
26+
if not request.user or not request.user.is_authenticated:
27+
return response
28+
29+
logger = structlog.get_logger("mcp")
30+
event = {
31+
# NOTE: The following W3C Baggage items are added by downstream processor
32+
# - gen_ai.tool.name
33+
# - flagsmith.mcp.client.name
34+
# - flagsmith.mcp.client.version
35+
"status": "error" if response.status_code >= 400 else "success",
36+
}
37+
if (org_id := self._get_organisation_id(request)) is not None:
38+
logger.info("tool.called", organisation__id=org_id, **event)
39+
else:
40+
logger.warning("tool.called", organisation__id=None, **event)
41+
42+
return response
43+
44+
def _get_organisation_id(self, request: HttpRequest) -> int | None:
45+
"""Obtain the organisation ID from the request context."""
46+
from organisations.models import Organisation
47+
48+
# Set by the permission layer for organisations the user belongs to
49+
if isinstance(organisation_id := get_span_attribute("organisation.id"), int):
50+
return organisation_id
51+
52+
assert request.user.is_authenticated # NOTE: protected upstream
53+
try: # Most of the time, the user belongs to one organisation
54+
return request.user.organisations.get().id
55+
except (
56+
Organisation.DoesNotExist,
57+
Organisation.MultipleObjectsReturned, # Don't guess
58+
):
59+
return None

api/telemetry/spans.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from opentelemetry import trace
2+
from opentelemetry.util.types import AttributeValue
3+
4+
5+
def set_span_attribute(attribute: str, value: AttributeValue) -> None:
6+
trace.get_current_span().set_attribute(attribute, value)
7+
8+
9+
def get_span_attribute(attribute: str) -> AttributeValue | None:
10+
attributes = getattr(trace.get_current_span(), "attributes", None) or {}
11+
return attributes.get(attribute)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from collections.abc import Generator
2+
3+
import pytest
4+
from opentelemetry import baggage
5+
from opentelemetry import context as otel_context
6+
from opentelemetry.sdk.trace import TracerProvider
7+
from opentelemetry.trace import Span
8+
9+
10+
@pytest.fixture()
11+
def mcp_baggage() -> Generator[None, None, None]:
12+
ctx = baggage.set_baggage("flagsmith.client.name", "flagsmith-mcp")
13+
token = otel_context.attach(ctx)
14+
yield
15+
otel_context.detach(token)
16+
17+
18+
@pytest.fixture()
19+
def recording_span() -> Generator[Span, None, None]:
20+
tracer = TracerProvider().get_tracer("test")
21+
with tracer.start_as_current_span("test") as span:
22+
yield span
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import pytest
2+
from pytest_structlog import StructuredLogCapture
3+
from rest_framework.test import APIClient
4+
5+
from environments.models import Environment
6+
from organisations.models import Organisation, OrganisationRole
7+
from users.models import FFAdminUser
8+
9+
10+
@pytest.mark.usefixtures("organisation")
11+
def test_mcp_usage_logger_middleware__no_mcp_baggage__logs_nothing(
12+
staff_client: APIClient,
13+
log: StructuredLogCapture,
14+
) -> None:
15+
# Given / When
16+
response = staff_client.get("/api/v1/projects/")
17+
18+
# Then
19+
assert response.status_code == 200
20+
assert log.events == []
21+
22+
23+
@pytest.mark.usefixtures("mcp_baggage", "recording_span", "organisation")
24+
def test_mcp_usage_logger_middleware__organisation_id_span_attribute__logs_span_organisation_id(
25+
staff_client: APIClient,
26+
staff_user: FFAdminUser,
27+
log: StructuredLogCapture,
28+
) -> None:
29+
# Given
30+
other_organisation = Organisation.objects.create(name="Other Org")
31+
staff_user.add_organisation(other_organisation, role=OrganisationRole.ADMIN)
32+
33+
# When
34+
response = staff_client.get(
35+
f"/api/v1/organisations/{other_organisation.pk}/invites/"
36+
)
37+
38+
# Then
39+
assert response.status_code == 200
40+
assert log.events == [
41+
{
42+
"level": "info",
43+
"event": "tool.called",
44+
"organisation__id": other_organisation.pk,
45+
"status": "success",
46+
}
47+
]
48+
49+
50+
@pytest.mark.usefixtures("mcp_baggage")
51+
def test_mcp_usage_logger_middleware__user_with_single_organisation__logs_user_organisation_id(
52+
staff_client: APIClient,
53+
organisation: Organisation,
54+
log: StructuredLogCapture,
55+
) -> None:
56+
# Given / When
57+
response = staff_client.get("/api/v1/projects/")
58+
59+
# Then
60+
assert response.status_code == 200
61+
assert log.events == [
62+
{
63+
"level": "info",
64+
"event": "tool.called",
65+
"organisation__id": organisation.pk,
66+
"status": "success",
67+
}
68+
]
69+
70+
71+
@pytest.mark.usefixtures("mcp_baggage")
72+
def test_mcp_usage_logger_middleware__user_without_organisations__logs_warning(
73+
staff_client: APIClient,
74+
staff_user: FFAdminUser,
75+
log: StructuredLogCapture,
76+
) -> None:
77+
# Given
78+
assert not staff_user.organisations.exists()
79+
80+
# When
81+
response = staff_client.get("/api/v1/projects/")
82+
83+
# Then
84+
assert response.status_code == 200
85+
assert log.events == [
86+
{
87+
"level": "warning",
88+
"event": "tool.called",
89+
"organisation__id": None,
90+
"status": "success",
91+
}
92+
]
93+
94+
95+
@pytest.mark.usefixtures("mcp_baggage", "organisation")
96+
def test_mcp_usage_logger_middleware__user_with_multiple_organisations__logs_warning(
97+
staff_client: APIClient,
98+
staff_user: FFAdminUser,
99+
log: StructuredLogCapture,
100+
) -> None:
101+
# Given
102+
other_organisation = Organisation.objects.create(name="Other Org")
103+
staff_user.add_organisation(other_organisation, role=OrganisationRole.USER)
104+
105+
# When
106+
response = staff_client.get("/api/v1/projects/")
107+
108+
# Then
109+
assert response.status_code == 200
110+
assert log.events == [
111+
{
112+
"level": "warning",
113+
"event": "tool.called",
114+
"organisation__id": None,
115+
"status": "success",
116+
}
117+
]
118+
119+
120+
@pytest.mark.usefixtures("mcp_baggage")
121+
def test_mcp_usage_logger_middleware__unauthenticated_request__logs_nothing(
122+
api_client: APIClient,
123+
log: StructuredLogCapture,
124+
) -> None:
125+
# Given / When
126+
response = api_client.get("/api/v1/projects/")
127+
128+
# Then
129+
assert response.status_code == 401
130+
assert log.events == []
131+
132+
133+
@pytest.mark.usefixtures("mcp_baggage")
134+
def test_mcp_usage_logger_middleware__error_response__logs_error_status(
135+
staff_client: APIClient,
136+
organisation: Organisation,
137+
log: StructuredLogCapture,
138+
) -> None:
139+
# Given / When
140+
response = staff_client.get(f"/api/v1/organisations/{organisation.pk}/invites/")
141+
142+
# Then
143+
assert response.status_code == 403
144+
assert log.events == [
145+
{
146+
"level": "info",
147+
"event": "tool.called",
148+
"organisation__id": organisation.pk,
149+
"status": "error",
150+
}
151+
]
152+
153+
154+
@pytest.mark.usefixtures("mcp_baggage")
155+
def test_mcp_usage_logger_middleware__sdk_request__logs_nothing(
156+
api_client: APIClient,
157+
environment: Environment,
158+
log: StructuredLogCapture,
159+
) -> None:
160+
# Given / When
161+
response = api_client.get(
162+
"/api/v1/flags/",
163+
HTTP_X_ENVIRONMENT_KEY=environment.api_key,
164+
)
165+
166+
# Then
167+
assert response.status_code == 200
168+
assert log.events == []
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import pytest
2+
3+
from telemetry.spans import get_span_attribute, set_span_attribute
4+
5+
6+
@pytest.mark.usefixtures("recording_span")
7+
def test_set_span_attribute__recording_span__attribute_round_trips() -> None:
8+
# Given
9+
attribute = "organisation.id"
10+
11+
# When
12+
set_span_attribute(attribute, 42)
13+
14+
# Then
15+
assert get_span_attribute(attribute) == 42
16+
17+
18+
def test_set_span_attribute__no_recording_span__silently_ignored() -> None:
19+
# Given
20+
attribute = "organisation.id"
21+
22+
# When
23+
set_span_attribute(attribute, 42)
24+
25+
# Then
26+
assert get_span_attribute(attribute) is None
27+
28+
29+
def test_get_span_attribute__no_recording_span__returns_none() -> None:
30+
# Given
31+
attribute = "organisation.id"
32+
33+
# When
34+
value = get_span_attribute(attribute)
35+
36+
# Then
37+
assert value is None

docs/docs/deployment-self-hosting/observability/_events-catalogue.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,15 @@ Attributes:
316316
- `project_id`
317317
- `retry_at`
318318

319+
### `mcp.tool.called`
320+
321+
Logged at `info` from:
322+
- `api/telemetry/middleware.py:38`
323+
- `api/telemetry/middleware.py:40`
324+
325+
Attributes:
326+
- `organisation.id`
327+
319328
### `platform_hub.no_analytics_database_configured`
320329

321330
Logged at `warning` from:

0 commit comments

Comments
 (0)