Skip to content
Draft
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
369 changes: 369 additions & 0 deletions src/sentry/api/endpoints/project_custom_inbound_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,369 @@
from __future__ import annotations

from enum import StrEnum
from typing import Any, TypedDict

from drf_spectacular.utils import extend_schema
from rest_framework import serializers
from rest_framework.request import Request
from rest_framework.response import Response

from sentry import audit_log, features
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint, ProjectSettingPermission
from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.paginator import OffsetPaginator
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND
from sentry.apidocs.parameters import GlobalParams
from sentry.models.custominboundfilter import CustomInboundFilter
from sentry.models.project import Project

MAX_CONDITIONS_PER_FILTER = 10
MAX_FILTERS_PER_PROJECT = 50


class CustomInboundFilterConditionType(StrEnum):
ERROR_MESSAGE = "error_message"
LOG_MESSAGE = "log_message"
METRIC_NAME = "metric_name"
RELEASE = "release"


PRIMARY_CONDITION_TYPES = frozenset(
(
CustomInboundFilterConditionType.ERROR_MESSAGE,
CustomInboundFilterConditionType.LOG_MESSAGE,
CustomInboundFilterConditionType.METRIC_NAME,
)
)


class CustomInboundFilterCondition(TypedDict):
type: str
value: list[str]


class CustomInboundFilterResponse(TypedDict):
id: str
name: str | None
active: bool
conditions: list[CustomInboundFilterCondition]
dateCreated: str
dateUpdated: str


class CustomInboundFilterConditionSerializer(serializers.Serializer):
type = serializers.ChoiceField(
choices=[condition_type.value for condition_type in CustomInboundFilterConditionType]
)
value = serializers.ListField(
child=serializers.CharField(allow_blank=False, trim_whitespace=True),
allow_empty=False,
)


class CustomInboundFilterSerializer(serializers.Serializer):
id = serializers.CharField(read_only=True)
name = serializers.CharField(
max_length=256, allow_blank=True, allow_null=True, required=False, trim_whitespace=True
)
active = serializers.BooleanField(required=False)
conditions = CustomInboundFilterConditionSerializer(
many=True,
allow_empty=False,
max_length=MAX_CONDITIONS_PER_FILTER,
help_text=(
"Conditions are combined with AND: an event must match every condition to be "
"filtered out. There is no OR between conditions, so e.g. two release conditions "
"can express a range (>2 AND <4). To broaden matching, widen a condition's values "
"or add separate filters."
),
)
dateCreated = serializers.DateTimeField(source="date_added", read_only=True)
dateUpdated = serializers.DateTimeField(source="date_updated", read_only=True)

def validate_conditions(self, conditions: list[dict[str, Any]]) -> list[dict[str, Any]]:
organization = self.context["project"].organization
request = self.context["request"]
condition_types = [condition["type"] for condition in conditions]

primary_condition_types = PRIMARY_CONDITION_TYPES.intersection(condition_types)
if CustomInboundFilterConditionType.LOG_MESSAGE in condition_types and not features.has(
"organizations:ourlogs-ingestion", organization, actor=request.user
):
raise serializers.ValidationError(
"Log message filters are not enabled for this organization."
)

if CustomInboundFilterConditionType.METRIC_NAME in condition_types and not features.has(
"organizations:tracemetrics-ingestion", organization, actor=request.user
):
raise serializers.ValidationError(
"Metric name filters are not enabled for this organization."
)
if len(primary_condition_types) > 1:
raise serializers.ValidationError(
"Only one of error_message, log_message, or metric_name can be used in a filter."
)

return conditions


def get_audit_log_data(
project: Project,
custom_filter: CustomInboundFilter,
operation: str,
changes: dict[str, Any] | None = None,
) -> dict[str, Any]:
data: dict[str, Any] = {
"project_slug": project.slug,
"filter_id": str(custom_filter.id),
"filter_name": custom_filter.name,
"active": custom_filter.active,
"conditions": custom_filter.conditions,
"operation": operation,
}

if changes:
data["changes"] = changes

return data


class ProjectCustomInboundFilterEndpoint(ProjectEndpoint):
owner = ApiOwner.TELEMETRY_EXPERIENCE
permission_classes = (ProjectSettingPermission,)

def has_feature(self, request: Request, project: Project) -> bool:
if not features.has(
"organizations:inbound-filters-v2", project.organization, actor=request.user
):
raise ResourceDoesNotExist

return features.has("projects:custom-inbound-filters", project, actor=request.user)


@cell_silo_endpoint
@extend_schema(tags=["Projects"])
class CustomInboundFiltersEndpoint(ProjectCustomInboundFilterEndpoint):
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
"POST": ApiPublishStatus.EXPERIMENTAL,
}

@extend_schema(
operation_id="List a Project's Custom Inbound Filters",
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
GlobalParams.PROJECT_ID_OR_SLUG,
],
responses={
200: list[CustomInboundFilterResponse],
400: RESPONSE_BAD_REQUEST,
403: RESPONSE_FORBIDDEN,
404: RESPONSE_NOT_FOUND,
},
)
def get(self, request: Request, project: Project) -> Response:
"""
List the custom inbound filters configured for a project.
"""
if not self.has_feature(request, project):
return Response({"detail": "You do not have that feature enabled"}, status=400)

filters = CustomInboundFilter.objects.filter(project_id=project.id)
return self.paginate(
request=request,
queryset=filters,
order_by="id",
paginator_cls=OffsetPaginator,
on_results=lambda results: CustomInboundFilterSerializer(results, many=True).data,
)

@extend_schema(
operation_id="Create a Custom Inbound Filter",
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
GlobalParams.PROJECT_ID_OR_SLUG,
],
request=CustomInboundFilterSerializer,
responses={
201: CustomInboundFilterResponse,
400: RESPONSE_BAD_REQUEST,
403: RESPONSE_FORBIDDEN,
404: RESPONSE_NOT_FOUND,
},
)
def post(self, request: Request, project: Project) -> Response:
"""
Create a custom inbound filter for a project.
"""
if not self.has_feature(request, project):
return Response({"detail": "You do not have that feature enabled"}, status=400)

if CustomInboundFilter.objects.filter(project_id=project.id).count() >= (
MAX_FILTERS_PER_PROJECT
):
return Response(
{
"detail": (
f"A project can have at most {MAX_FILTERS_PER_PROJECT} custom inbound "
"filters."
)
},
status=400,
)

serializer = CustomInboundFilterSerializer(
data=request.data,
context={"project": project, "request": request},
)
if not serializer.is_valid():
return Response(serializer.errors, status=400)

custom_filter = CustomInboundFilter.objects.create(
project=project,
name=serializer.validated_data.get("name"),
active=serializer.validated_data.get("active", True),
conditions=serializer.validated_data["conditions"],
)

self.create_audit_entry(
request=request,
organization=project.organization,
target_object=custom_filter.id,
event=audit_log.get_event_id("CUSTOM_INBOUND_FILTER"),
data=get_audit_log_data(project, custom_filter, "add"),
)

return Response(CustomInboundFilterSerializer(custom_filter).data, status=201)


@cell_silo_endpoint
@extend_schema(tags=["Projects"])
class CustomInboundFilterDetailsEndpoint(ProjectCustomInboundFilterEndpoint):
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
"PUT": ApiPublishStatus.EXPERIMENTAL,
"DELETE": ApiPublishStatus.EXPERIMENTAL,
}

def get_custom_inbound_filter(self, project: Project, filter_id: str) -> CustomInboundFilter:
try:
return CustomInboundFilter.objects.get(id=filter_id, project_id=project.id)
except (CustomInboundFilter.DoesNotExist, ValueError):
raise ResourceDoesNotExist

@extend_schema(
operation_id="Retrieve a Custom Inbound Filter",
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
GlobalParams.PROJECT_ID_OR_SLUG,
],
responses={
200: CustomInboundFilterResponse,
400: RESPONSE_BAD_REQUEST,
403: RESPONSE_FORBIDDEN,
404: RESPONSE_NOT_FOUND,
},
)
def get(self, request: Request, project: Project, filter_id: str) -> Response:
"""
Retrieve a single custom inbound filter.
"""
if not self.has_feature(request, project):
return Response({"detail": "You do not have that feature enabled"}, status=400)

custom_filter = self.get_custom_inbound_filter(project, filter_id)
return Response(CustomInboundFilterSerializer(custom_filter).data)

@extend_schema(
operation_id="Update a Custom Inbound Filter",
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
GlobalParams.PROJECT_ID_OR_SLUG,
],
request=CustomInboundFilterSerializer,
responses={
200: CustomInboundFilterResponse,
400: RESPONSE_BAD_REQUEST,
403: RESPONSE_FORBIDDEN,
404: RESPONSE_NOT_FOUND,
},
)
def put(self, request: Request, project: Project, filter_id: str) -> Response:
"""
Update a custom inbound filter's name, active state, or conditions.
"""
if not self.has_feature(request, project):
return Response({"detail": "You do not have that feature enabled"}, status=400)

custom_filter = self.get_custom_inbound_filter(project, filter_id)
serializer = CustomInboundFilterSerializer(
custom_filter,
data=request.data,
partial=True,
context={"project": project, "request": request},
)
if not serializer.is_valid():
return Response(serializer.errors, status=400)

changes = {}
for field in ("name", "active", "conditions"):
if field not in serializer.validated_data:
continue

previous_value = getattr(custom_filter, field)
new_value = serializer.validated_data[field]
if previous_value != new_value:
changes[field] = {"old": previous_value, "new": new_value}
setattr(custom_filter, field, new_value)

if changes:
custom_filter.save(update_fields=[*changes.keys(), "date_updated"])
self.create_audit_entry(
request=request,
organization=project.organization,
target_object=custom_filter.id,
event=audit_log.get_event_id("CUSTOM_INBOUND_FILTER"),
data=get_audit_log_data(project, custom_filter, "edit", changes),
)

return Response(CustomInboundFilterSerializer(custom_filter).data)

@extend_schema(
operation_id="Delete a Custom Inbound Filter",
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
GlobalParams.PROJECT_ID_OR_SLUG,
],
responses={
204: None,
400: RESPONSE_BAD_REQUEST,
403: RESPONSE_FORBIDDEN,
404: RESPONSE_NOT_FOUND,
},
)
def delete(self, request: Request, project: Project, filter_id: str) -> Response:
"""
Delete a custom inbound filter.
"""
if not self.has_feature(request, project):
return Response({"detail": "You do not have that feature enabled"}, status=400)

custom_filter = self.get_custom_inbound_filter(project, filter_id)
audit_log_data = get_audit_log_data(project, custom_filter, "remove")
target_object = custom_filter.id
custom_filter.delete()

self.create_audit_entry(
request=request,
organization=project.organization,
target_object=target_object,
event=audit_log.get_event_id("CUSTOM_INBOUND_FILTER"),
data=audit_log_data,
)

return Response(status=204)
14 changes: 14 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,10 @@
from .endpoints.project_commits import ProjectCommitsEndpoint
from .endpoints.project_create_sample import ProjectCreateSampleEndpoint
from .endpoints.project_create_sample_transaction import ProjectCreateSampleTransactionEndpoint
from .endpoints.project_custom_inbound_filters import (
CustomInboundFilterDetailsEndpoint,
CustomInboundFiltersEndpoint,
)
from .endpoints.project_filter_details import ProjectFilterDetailsEndpoint
from .endpoints.project_filters import ProjectFiltersEndpoint
from .endpoints.project_member_index import ProjectMemberIndexEndpoint
Expand Down Expand Up @@ -2865,6 +2869,16 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
ProjectFilterDetailsEndpoint.as_view(),
name="sentry-api-0-project-filters-details",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/custom-inbound-filters/$",
CustomInboundFiltersEndpoint.as_view(),
name="sentry-api-0-project-custom-inbound-filters",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/custom-inbound-filters/(?P<filter_id>[^/]+)/$",
CustomInboundFilterDetailsEndpoint.as_view(),
name="sentry-api-0-project-custom-inbound-filter-details",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/hooks/$",
ProjectServiceHooksEndpoint.as_view(),
Expand Down
Loading
Loading