Просмотр исходного кода

Merge branch 'master' into zc/outbox-advisory-lock

Zach Collins 1 год назад
Родитель
Сommit
f59890253b

+ 1 - 0
.github/CODEOWNERS

@@ -165,6 +165,7 @@ yarn.lock                                                @getsentry/owners-js-de
 /src/sentry/api/endpoints/organization_searches.py        @getsentry/issues
 /src/sentry/api/helpers/group_index.py                    @getsentry/issues
 /src/sentry/api/helpers/events.py                         @getsentry/issues
+/src/sentry/api/helpers/group_index/update.py             @getsentry/issues
 
 /src/sentry/snuba/models.py                               @getsentry/issues
 /src/sentry/snuba/query_subscription_consumer.py          @getsentry/issues

+ 2 - 2
requirements-base.txt

@@ -61,9 +61,9 @@ rfc3986-validator>=0.1.1
 sentry-arroyo>=2.14.9
 sentry-kafka-schemas>=0.1.30
 sentry-redis-tools>=0.1.7
-sentry-relay>=0.8.31
+sentry-relay>=0.8.32
 sentry-sdk>=1.31.0
-snuba-sdk>=2.0.3
+snuba-sdk>=2.0.4
 simplejson>=3.17.6
 sqlparse>=0.4.4
 statsd>=3.3

+ 2 - 2
requirements-dev-frozen.txt

@@ -172,12 +172,12 @@ sentry-arroyo==2.14.9
 sentry-cli==2.16.0
 sentry-kafka-schemas==0.1.30
 sentry-redis-tools==0.1.7
-sentry-relay==0.8.31
+sentry-relay==0.8.32
 sentry-sdk==1.31.0
 simplejson==3.17.6
 six==1.16.0
 sniffio==1.2.0
-snuba-sdk==2.0.3
+snuba-sdk==2.0.4
 sortedcontainers==2.4.0
 soupsieve==2.3.2.post1
 sqlparse==0.4.4

+ 2 - 2
requirements-frozen.txt

@@ -113,11 +113,11 @@ s3transfer==0.6.1
 sentry-arroyo==2.14.9
 sentry-kafka-schemas==0.1.30
 sentry-redis-tools==0.1.7
-sentry-relay==0.8.31
+sentry-relay==0.8.32
 sentry-sdk==1.31.0
 simplejson==3.17.6
 six==1.16.0
-snuba-sdk==2.0.3
+snuba-sdk==2.0.4
 soupsieve==2.3.2.post1
 sqlparse==0.4.4
 statsd==3.3

+ 49 - 12
src/sentry/api/endpoints/custom_rules.py

@@ -1,6 +1,7 @@
 from datetime import datetime, timedelta, timezone
 from typing import List, Optional, cast
 
+import sentry_sdk
 from django.db import DatabaseError
 from rest_framework import serializers
 from rest_framework.permissions import BasePermission
@@ -12,7 +13,7 @@ from sentry.api.api_owners import ApiOwner
 from sentry.api.api_publish_status import ApiPublishStatus
 from sentry.api.base import region_silo_endpoint
 from sentry.api.bases import OrganizationEndpoint
-from sentry.api.event_search import parse_search_query
+from sentry.api.event_search import SearchFilter, SearchKey, parse_search_query
 from sentry.exceptions import InvalidSearchQuery
 from sentry.models.dynamicsampling import (
     CUSTOM_RULE_DATE_FORMAT,
@@ -128,7 +129,7 @@ class CustomRulesEndpoint(OrganizationEndpoint):
         projects = serializer.validated_data.get("projects")
         period = serializer.validated_data.get("period")
         try:
-            condition = _get_condition(query)
+            condition = get_condition(query)
 
             # the parsing must succeed (it passed validation)
             delta = cast(timedelta, parse_stats_period(period))
@@ -197,7 +198,7 @@ class CustomRulesEndpoint(OrganizationEndpoint):
             org_rule = True
 
         try:
-            condition = _get_condition(query)
+            condition = get_condition(query)
         except InvalidSearchQuery as e:
             return Response({"query": [str(e)]}, status=400)
         except ValueError as e:
@@ -245,15 +246,26 @@ def _rule_to_response(rule: CustomDynamicSamplingRule) -> Response:
     return Response(response_data, status=200)
 
 
-def _get_condition(query: Optional[str]) -> RuleCondition:
-    if not query:
-        # True condition when query not specified
-        condition: RuleCondition = {"op": "and", "inner": []}
-    else:
-        tokens = parse_search_query(query)
-        converter = SearchQueryConverter(tokens)
-        condition = converter.convert()
-    return condition
+def get_condition(query: Optional[str]) -> RuleCondition:
+    try:
+        if not query:
+            # True condition when query not specified
+            condition: RuleCondition = {"op": "and", "inner": []}
+        else:
+            tokens = parse_search_query(query)
+            # transform a simple message query into a transaction condition:
+            # "foo environment:development" -> "transaction:foo environment:development"
+            tokens = message_to_transaction_condition(tokens)
+            converter = SearchQueryConverter(tokens)
+            condition = converter.convert()
+        return condition
+    except Exception as ex:
+        with sentry_sdk.push_scope() as scope:
+            scope.set_extra("query", query)
+            scope.set_extra("error", ex)
+            message = "Could not convert query to custom dynamic sampling rule"
+            sentry_sdk.capture_message(message, level="warning")
+        raise
 
 
 def _clean_project_list(project_ids: List[int]) -> List[int]:
@@ -280,3 +292,28 @@ def _schedule_invalidate_project_configs(organization: Organization, project_ids
                 trigger="dynamic_sampling:custom_rule_upsert",
                 project_id=project_id,
             )
+
+
+def message_to_transaction_condition(tokens: List[SearchFilter]) -> List[SearchFilter]:
+    """
+    Transforms queries containing messages into proper transaction queries
+
+    eg: "foo environment:development" -> "transaction:foo environment:development"
+
+    a string "foo" is parsed into a SearchFilter(key=SearchKey(name="message"), operator="=", value="foo")
+    we need to transform it into a SearchFilter(key=SearchKey(name="transaction"), operator="=", value="foo")
+
+    """
+    new_tokens = []
+    for token in tokens:
+        if token.key.name == "message" and token.operator == "=":
+            # transform the token from message to transaction
+            new_tokens.append(
+                SearchFilter(
+                    key=SearchKey("transaction"), value=token.value, operator=token.operator
+                )
+            )
+        else:
+            # nothing to change append the token as is
+            new_tokens.append(token)
+    return new_tokens

+ 2 - 0
src/sentry/api/endpoints/organization_events_stats.py

@@ -14,6 +14,7 @@ from sentry.constants import MAX_TOP_EVENTS
 from sentry.models.organization import Organization
 from sentry.snuba import (
     discover,
+    functions,
     metrics_enhanced_performance,
     metrics_performance,
     spans_indexed,
@@ -185,6 +186,7 @@ class OrganizationEventsStatsEndpoint(OrganizationEventsV2EndpointBase):
                     if dataset
                     in [
                         discover,
+                        functions,
                         metrics_performance,
                         metrics_enhanced_performance,
                         spans_indexed,

+ 9 - 1
src/sentry/api/endpoints/organization_spans_aggregation.py

@@ -1,7 +1,7 @@
 import hashlib
 from collections import defaultdict, namedtuple
 from datetime import datetime
-from typing import Any, Dict, List, Mapping, TypedDict
+from typing import Any, Dict, List, Mapping, Optional, Set, Tuple, TypedDict
 
 import sentry_sdk
 from rest_framework import status
@@ -60,6 +60,7 @@ AggregateSpanRow = TypedDict(
         "avg(absolute_offset)": float,
         "avg(relative_offset)": float,
         "count()": int,
+        "samples": Set[Tuple[Optional[str], str]],
     },
 )
 
@@ -69,6 +70,7 @@ NULL_GROUP = "00"
 class BaseAggregateSpans:
     def __init__(self) -> None:
         self.aggregated_tree: Dict[str, AggregateSpanRow] = {}
+        self.current_transaction: Optional[str] = None
 
     def fingerprint_nodes(
         self,
@@ -140,7 +142,10 @@ class BaseAggregateSpans:
                 else node["avg(relative_offset)"]
             )
             node["count()"] += 1
+            if len(node["samples"]) < 5:
+                node["samples"].add((self.current_transaction, span_tree["span_id"]))
         else:
+            sample = {(self.current_transaction, span_tree["span_id"])}
             self.aggregated_tree[node_fingerprint] = {
                 "node_fingerprint": node_fingerprint,
                 "parent_node_fingerprint": parent_node_fingerprint,
@@ -159,6 +164,7 @@ class BaseAggregateSpans:
                 if parent_node
                 else start_timestamp - parent_timestamp,
                 "count()": 1,
+                "samples": sample,
             }
 
         # Handles sibling spans that have the same group
@@ -184,6 +190,7 @@ class AggregateIndexedSpans(BaseAggregateSpans):
             root_span_id = None
             spans = event["spans"]
 
+            self.current_transaction = event["transaction_id"]
             for span_ in spans:
                 span = EventSpan(*span_)
                 span_id = getattr(span, "span_id")
@@ -225,6 +232,7 @@ class AggregateNodestoreSpans(BaseAggregateSpans):
             event = event_.data.data
             span_tree = {}
 
+            self.current_transaction = event["event_id"]
             root_span_id = event["contexts"]["trace"]["span_id"]
             span_tree[root_span_id] = {
                 "span_id": root_span_id,

+ 61 - 62
src/sentry/api/endpoints/project_details.py

@@ -1,12 +1,11 @@
 import math
 import time
 from datetime import timedelta
-from itertools import chain
 from uuid import uuid4
 
 from django.db import IntegrityError, router, transaction
 from django.utils import timezone
-from drf_spectacular.utils import extend_schema, inline_serializer
+from drf_spectacular.utils import extend_schema, extend_schema_serializer
 from rest_framework import serializers, status
 from rest_framework.request import Request
 from rest_framework.response import Response
@@ -66,24 +65,6 @@ from sentry.utils import json
 #: Limit determined experimentally here: https://github.com/getsentry/relay/blob/3105d8544daca3a102c74cefcd77db980306de71/relay-general/src/pii/convert.rs#L289
 MAX_SENSITIVE_FIELD_CHARS = 4000
 
-_options_description = """
-Configure various project filters:
-- `Hydration Errors` - Filter out react hydration errors that are often unactionable
-- `IP Addresses` - Filter events from these IP addresses separated with newlines.
-- `Releases` - Filter events from these releases separated with newlines. Allows [glob pattern matching](https://docs.sentry.io/product/data-management-settings/filtering/#glob-matching).
-- `Error Message` - Filter events by error messages separated with newlines. Allows [glob pattern matching](https://docs.sentry.io/product/data-management-settings/filtering/#glob-matching).
-```json
-{
-    options: {
-        filters:react-hydration-errors: true,
-        filters:blacklisted_ips: "127.0.0.1\\n192.168. 0.1"
-        filters:releases: "[!3]\\n4"
-        filters:error_messages: "TypeError*\\n*ConnectionError*"
-    }
-}
-```
-"""
-
 
 def clean_newline_inputs(value, case_insensitive=True):
     result = []
@@ -109,22 +90,63 @@ class DynamicSamplingBiasSerializer(serializers.Serializer):
 
 
 class ProjectMemberSerializer(serializers.Serializer):
-    isBookmarked = serializers.BooleanField()
-    isSubscribed = serializers.BooleanField()
+    isBookmarked = serializers.BooleanField(
+        help_text="Enables starring the project within the projects tab. Can be updated with **`project:read`** permission.",
+        required=False,
+    )
+    isSubscribed = serializers.BooleanField(
+        help_text="Subscribes the member for notifications related to the project. Can be updated with **`project:read`** permission.",
+        required=False,
+    )
 
 
+@extend_schema_serializer(exclude_fields=["options"])
 class ProjectAdminSerializer(ProjectMemberSerializer, PreventNumericSlugMixin):
-    name = serializers.CharField(max_length=200)
+    name = serializers.CharField(
+        help_text="The name for the project",
+        max_length=200,
+        required=False,
+    )
     slug = serializers.RegexField(
         DEFAULT_SLUG_PATTERN,
         max_length=50,
         error_messages={"invalid": DEFAULT_SLUG_ERROR_MESSAGE},
+        help_text="Uniquely identifies a project and is used for the interface.",
+        required=False,
+    )
+    platform = serializers.CharField(
+        help_text="The platform for the project",
+        required=False,
+        allow_null=True,
+        allow_blank=True,
+    )
+
+    subjectPrefix = serializers.CharField(
+        help_text="Custom prefix for emails from this project.",
+        max_length=200,
+        allow_blank=True,
+        required=False,
     )
+    subjectTemplate = serializers.CharField(
+        help_text="""The email subject to use (excluding the prefix) for individual alerts. Here are the list of variables you can use:
+- `$title`
+- `$shortID`
+- `$projectID`
+- `$orgID`
+- `${tag:key}` - such as `${tag:environment}` or `${tag:release}`.""",
+        max_length=200,
+        required=False,
+    )
+    resolveAge = EmptyIntegerField(
+        required=False,
+        allow_null=True,
+        help_text="Automatically resolve an issue if it hasn't been seen for this many hours. Set to `0` to disable auto-resolve.",
+    )
+
+    # TODO: Add help_text to all the fields for public documentation
     team = serializers.RegexField(r"^[a-z0-9_\-]+$", max_length=50)
     digestsMinDelay = serializers.IntegerField(min_value=60, max_value=3600)
     digestsMaxDelay = serializers.IntegerField(min_value=60, max_value=3600)
-    subjectPrefix = serializers.CharField(max_length=200, allow_blank=True)
-    subjectTemplate = serializers.CharField(max_length=200)
     securityToken = serializers.RegexField(
         r"^[-a-zA-Z0-9+/=\s]+$", max_length=255, allow_blank=True
     )
@@ -155,8 +177,7 @@ class ProjectAdminSerializer(ProjectMemberSerializer, PreventNumericSlugMixin):
     groupingAutoUpdate = serializers.BooleanField(required=False)
     scrapeJavaScript = serializers.BooleanField(required=False)
     allowedDomains = EmptyListField(child=OriginField(allow_blank=True), required=False)
-    resolveAge = EmptyIntegerField(required=False, allow_null=True)
-    platform = serializers.CharField(required=False, allow_null=True, allow_blank=True)
+
     copy_from_project = serializers.IntegerField(required=False)
     dynamicSamplingBiases = DynamicSamplingBiasSerializer(required=False, many=True)
     performanceIssueCreationRate = serializers.FloatField(required=False, min_value=0, max_value=1)
@@ -165,6 +186,13 @@ class ProjectAdminSerializer(ProjectMemberSerializer, PreventNumericSlugMixin):
     recapServerUrl = serializers.URLField(required=False, allow_blank=True, allow_null=True)
     recapServerToken = serializers.CharField(required=False, allow_blank=True, allow_null=True)
 
+    # DO NOT ADD MORE TO OPTIONS
+    # Each param should be a field in the serializer like above.
+    # Keeping options here for backward compatibility but removing it from documentation.
+    options = serializers.DictField(
+        required=False,
+    )
+
     def validate(self, data):
         max_delay = (
             data["digestsMaxDelay"]
@@ -473,34 +501,7 @@ class ProjectDetailsEndpoint(ProjectEndpoint):
             GlobalParams.ORG_SLUG,
             GlobalParams.PROJECT_SLUG,
         ],
-        request=inline_serializer(
-            "UpdateProject",
-            fields={
-                "slug": serializers.RegexField(
-                    DEFAULT_SLUG_PATTERN,
-                    help_text="Uniquely identifies a project and is used for the interface.",
-                    required=False,
-                    max_length=50,
-                    error_messages={"invalid": DEFAULT_SLUG_ERROR_MESSAGE},
-                ),
-                "name": serializers.CharField(
-                    help_text="The name for the project.",
-                    required=False,
-                    max_length=200,
-                ),
-                "platform": serializers.CharField(
-                    help_text="The platform for the project.",
-                    required=False,
-                    allow_null=True,
-                    allow_blank=True,
-                ),
-                "isBookmarked": serializers.BooleanField(
-                    help_text="Enables starring the project within the projects tab.",
-                    required=False,
-                ),
-                "options": serializers.DictField(help_text=_options_description, required=False),
-            },
-        ),
+        request=ProjectAdminSerializer,
         responses={
             200: DetailedProjectSerializer,
             403: RESPONSE_FORBIDDEN,
@@ -513,7 +514,7 @@ class ProjectDetailsEndpoint(ProjectEndpoint):
         Update various attributes and configurable settings for the given project.
 
         Note that solely having the **`project:read`** scope restricts updatable settings to
-        `isBookmarked` only.
+        `isBookmarked` and `isSubscribed`.
         """
 
         old_data = serialize(project, request.user, DetailedProjectSerializer())
@@ -546,14 +547,12 @@ class ProjectDetailsEndpoint(ProjectEndpoint):
             return Response(serializer.errors, status=400)
 
         if not has_elevated_scopes:
-            # options isn't part of the serializer, but should not be editable by members
-            for key in chain(ProjectAdminSerializer().fields.keys(), ["options"]):
+            for key in ProjectAdminSerializer().fields.keys():
                 if request.data.get(key) and not result.get(key):
                     return Response(
                         {"detail": "You do not have permission to perform this action."},
                         status=403,
                     )
-
         changed = False
         changed_proj_settings = {}
 
@@ -726,9 +725,9 @@ class ProjectDetailsEndpoint(ProjectEndpoint):
                 changed_proj_settings["sentry:dynamic_sampling_biases"] = result[
                     "dynamicSamplingBiases"
                 ]
-        # TODO(dcramer): rewrite options to use standard API config
+
         if has_elevated_scopes:
-            options = request.data.get("options", {})
+            options = result.get("options", {})
             if "sentry:origins" in options:
                 project.update_option(
                     "sentry:origins", clean_newline_inputs(options["sentry:origins"])
@@ -806,7 +805,7 @@ class ProjectDetailsEndpoint(ProjectEndpoint):
             if "filters:react-hydration-errors" in options:
                 project.update_option(
                     "filters:react-hydration-errors",
-                    bool(options["filters:react-hydration-errors"]),
+                    "1" if bool(options["filters:react-hydration-errors"]) else "0",
                 )
             if "filters:chunk-load-error" in options:
                 project.update_option(

+ 0 - 4
src/sentry/api/endpoints/project_filter_details.py

@@ -46,10 +46,6 @@ class ProjectFilterDetailsEndpoint(ProjectEndpoint):
     def put(self, request: Request, project, filter_id) -> Response:
         """
         Update various inbound data filters for a project.
-
-        Note that the hydration filter and custom inbound
-        filters must be updated using the [Update a
-        Project](https://docs.sentry.io/api/projects/update-a-project/) endpoint.
         """
         for flt in inbound_filters.get_all_filter_specs():
             if flt.id == filter_id:

+ 59 - 25
src/sentry/api/endpoints/project_ownership.py

@@ -1,14 +1,20 @@
 from django.utils import timezone
+from drf_spectacular.utils import extend_schema
 from rest_framework import serializers
 from rest_framework.exceptions import PermissionDenied
 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 region_silo_endpoint
 from sentry.api.bases.project import ProjectEndpoint, ProjectOwnershipPermission
 from sentry.api.serializers import serialize
+from sentry.api.serializers.models.projectownership import ProjectOwnershipSerializer
+from sentry.apidocs.constants import RESPONSE_BAD_REQUEST
+from sentry.apidocs.examples import ownership_examples
+from sentry.apidocs.parameters import GlobalParams
 from sentry.models.groupowner import GroupOwner
 from sentry.models.project import Project
 from sentry.models.projectownership import ProjectOwnership
@@ -20,11 +26,29 @@ MAX_RAW_LENGTH = 100_000
 HIGHER_MAX_RAW_LENGTH = 200_000
 
 
-class ProjectOwnershipSerializer(serializers.Serializer):
-    raw = serializers.CharField(allow_blank=True)
-    fallthrough = serializers.BooleanField()
-    autoAssignment = serializers.CharField(allow_blank=False)
-    codeownersAutoSync = serializers.BooleanField(default=True)
+class ProjectOwnershipRequestSerializer(serializers.Serializer):
+    raw = serializers.CharField(
+        required=False,
+        allow_blank=True,
+        help_text="Raw input for ownership configuration. See the [Ownership Rules Documentation](/product/issues/ownership-rules/) to learn more.",
+    )
+    fallthrough = serializers.BooleanField(
+        required=False,
+        help_text="A boolean determining who to assign ownership to when an ownership rule has no match. If set to `True`, all project members are made owners. Otherwise, no owners are set.",
+    )
+    autoAssignment = serializers.CharField(
+        required=False,
+        allow_blank=False,
+        help_text="""Auto-assignment settings. The available options are:
+- Auto Assign to Issue Owner
+- Auto Assign to Suspect Commits
+- Turn off Auto-Assignment""",
+    )
+    codeownersAutoSync = serializers.BooleanField(
+        required=False,
+        default=True,
+        help_text="Set to `True` to sync issue owners with CODEOWNERS updates in a release.",
+    )
 
     @staticmethod
     def _validate_no_codeowners(rules):
@@ -159,10 +183,12 @@ class ProjectOwnershipSerializer(serializers.Serializer):
 
 
 @region_silo_endpoint
+@extend_schema(tags=["Projects"])
 class ProjectOwnershipEndpoint(ProjectEndpoint):
+    owner = ApiOwner.ISSUES
     publish_status = {
-        "GET": ApiPublishStatus.UNKNOWN,
-        "PUT": ApiPublishStatus.UNKNOWN,
+        "GET": ApiPublishStatus.PUBLIC,
+        "PUT": ApiPublishStatus.PUBLIC,
     }
     permission_classes = [ProjectOwnershipPermission]
 
@@ -199,14 +225,19 @@ class ProjectOwnershipEndpoint(ProjectEndpoint):
                 for rule_owner in rule["owners"]:
                     rule_owner["name"] = rule_owner.pop("identifier")
 
+    @extend_schema(
+        operation_id="Retrieve Ownership Configuration for a Project",
+        parameters=[
+            GlobalParams.ORG_SLUG,
+            GlobalParams.PROJECT_SLUG,
+        ],
+        request=None,
+        responses={200: ProjectOwnershipSerializer},
+        examples=ownership_examples.GET_PROJECT_OWNERSHIP,
+    )
     def get(self, request: Request, project) -> Response:
         """
-        Retrieve a Project's Ownership configuration
-        ````````````````````````````````````````````
-
-        Return details on a project's ownership configuration.
-
-        :auth: required
+        Returns details on a project's ownership configuration.
         """
         ownership = self.get_ownership(project)
         should_return_schema = features.has(
@@ -221,20 +252,23 @@ class ProjectOwnershipEndpoint(ProjectEndpoint):
             serialize(ownership, request.user, should_return_schema=should_return_schema)
         )
 
+    @extend_schema(
+        operation_id="Update Ownership Configuration for a Project",
+        parameters=[
+            GlobalParams.ORG_SLUG,
+            GlobalParams.PROJECT_SLUG,
+        ],
+        request=ProjectOwnershipRequestSerializer,
+        responses={
+            202: ProjectOwnershipSerializer,
+            400: RESPONSE_BAD_REQUEST,
+        },
+        examples=ownership_examples.UPDATE_PROJECT_OWNERSHIP,
+    )
     def put(self, request: Request, project) -> Response:
         """
-        Update a Project's Ownership configuration
-        ``````````````````````````````````````````
-
-        Updates a project's ownership configuration settings. Only the
+        Updates ownership configurations for a project. Note that only the
         attributes submitted are modified.
-
-        :param string raw: Raw input for ownership configuration.
-        :param boolean fallthrough: Indicate if there is no match on explicit rules,
-                                    to fall through and make everyone an implicit owner.
-
-        :param autoAssignment: String detailing automatic assignment setting
-        :auth: required
         """
 
         # Ownership settings others than "raw" ownership rules can only be updated by
@@ -249,7 +283,7 @@ class ProjectOwnershipEndpoint(ProjectEndpoint):
         should_return_schema = features.has(
             "organizations:streamline-targeting-context", project.organization
         )
-        serializer = ProjectOwnershipSerializer(
+        serializer = ProjectOwnershipRequestSerializer(
             data=request.data, partial=True, context={"ownership": self.get_ownership(project)}
         )
         if serializer.is_valid():

Некоторые файлы не были показаны из-за большого количества измененных файлов