Browse Source

feat(issues): Add stacktrace-coverage endpoint (#63163)

Scott Cooper 1 year ago
parent
commit
c91f2eac29

+ 0 - 1
pyproject.toml

@@ -208,7 +208,6 @@ module = [
     "sentry.api.endpoints.project_rule_preview",
     "sentry.api.endpoints.project_rules_configuration",
     "sentry.api.endpoints.project_servicehook_stats",
-    "sentry.api.endpoints.project_stacktrace_link",
     "sentry.api.endpoints.project_stacktrace_links",
     "sentry.api.endpoints.project_transaction_names",
     "sentry.api.endpoints.rule_snooze",

+ 78 - 0
src/sentry/api/endpoints/project_stacktrace_coverage.py

@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from rest_framework.request import Request
+from rest_framework.response import Response
+from sentry_sdk import configure_scope, start_span
+
+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
+from sentry.api.endpoints.project_stacktrace_link import generate_context
+from sentry.api.serializers import serialize
+from sentry.integrations.utils.code_mapping import get_sorted_code_mapping_configs
+from sentry.integrations.utils.codecov import codecov_enabled, fetch_codecov_data
+from sentry.integrations.utils.stacktrace_link import get_stacktrace_config
+from sentry.models.project import Project
+from sentry.utils import metrics
+
+
+@region_silo_endpoint
+class ProjectStacktraceCoverageEndpoint(ProjectEndpoint):
+    publish_status = {
+        "GET": ApiPublishStatus.PRIVATE,
+    }
+    """
+    Returns codecov data for a given stacktrace.
+    Similar to stacktrace-link, but for coverage data.
+
+    `file`: The file path from the stack trace
+    `commitId` (optional): The commit_id for the last commit of the
+                           release associated to the stack trace's event
+    `sdkName` (optional): The sdk.name associated with the event
+    `absPath` (optional): The abs_path field value of the relevant stack frame
+    `module`  (optional): The module field value of the relevant stack frame
+    `package` (optional): The package field value of the relevant stack frame
+    `groupId` (optional): The Issue's id.
+    """
+
+    owner = ApiOwner.ISSUES
+
+    def get(self, request: Request, project: Project) -> Response:
+        should_get_coverage = codecov_enabled(project.organization)
+        if not should_get_coverage:
+            return Response({"detail": "Codecov not enabled"}, status=400)
+
+        ctx = generate_context(request.GET)
+        filepath = ctx.get("file")
+        if not filepath:
+            return Response({"detail": "Filepath is required"}, status=400)
+
+        configs = get_sorted_code_mapping_configs(project)
+        if not configs:
+            return Response({"detail": "No code mappings found for this project"}, status=400)
+
+        result = get_stacktrace_config(configs, ctx)
+        error = result["error"]
+        serialized_config = None
+
+        # Post-processing before exiting scope context
+        if result["current_config"]:
+            with configure_scope() as scope:
+                serialized_config = serialize(result["current_config"]["config"], request.user)
+                provider = serialized_config["provider"]["key"]
+                # Use the provider key to split up stacktrace-link metrics by integration type
+                scope.set_tag("integration_provider", provider)  # e.g. github
+
+                with start_span(op="fetch_codecov_data"):
+                    with metrics.timer("issues.stacktrace.fetch_codecov_data"):
+                        codecov_data = fetch_codecov_data(
+                            config={
+                                "repository": result["current_config"]["repository"],
+                                "config": serialized_config,
+                                "outcome": result["current_config"]["outcome"],
+                            }
+                        )
+                        return Response(codecov_data)
+
+        return Response({"error": error, "config": serialized_config}, status=400)

+ 2 - 1
src/sentry/api/endpoints/project_stacktrace_link.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 import logging
 from typing import Dict, List, Mapping, Optional
 
+from django.http import QueryDict
 from rest_framework.request import Request
 from rest_framework.response import Response
 from sentry_sdk import Scope, configure_scope
@@ -24,7 +25,7 @@ from sentry.services.hybrid_cloud.integration import integration_service
 logger = logging.getLogger(__name__)
 
 
-def generate_context(parameters: Dict[str, Optional[str]]) -> Dict[str, Optional[str]]:
+def generate_context(parameters: QueryDict) -> Dict[str, Optional[str]]:
     return {
         "file": parameters.get("file"),
         # XXX: Temp change to support try_path_munging until refactored

+ 6 - 0
src/sentry/api/urls.py

@@ -24,6 +24,7 @@ from sentry.api.endpoints.organization_unsubscribe import (
     OrganizationUnsubscribeIssue,
     OrganizationUnsubscribeProject,
 )
+from sentry.api.endpoints.project_stacktrace_coverage import ProjectStacktraceCoverageEndpoint
 from sentry.api.endpoints.release_thresholds.release_threshold import ReleaseThresholdEndpoint
 from sentry.api.endpoints.release_thresholds.release_threshold_details import (
     ReleaseThresholdDetailsEndpoint,
@@ -2552,6 +2553,11 @@ PROJECT_URLS: list[URLPattern | URLResolver] = [
         GroupTombstoneDetailsEndpoint.as_view(),
         name="sentry-api-0-group-tombstone-details",
     ),
+    re_path(
+        r"^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/stacktrace-coverage/$",
+        ProjectStacktraceCoverageEndpoint.as_view(),
+        name="sentry-api-0-project-stacktrace-coverage",
+    ),
     re_path(
         r"^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/stacktrace-link/$",
         ProjectStacktraceLinkEndpoint.as_view(),

+ 11 - 2
src/sentry/integrations/utils/codecov.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import logging
 from enum import Enum
-from typing import Any, Dict, Sequence, Tuple, TypedDict
+from typing import Any, Sequence, Tuple, TypedDict
 
 import requests
 from rest_framework import status
@@ -10,7 +10,9 @@ from sentry_sdk import configure_scope
 from typing_extensions import NotRequired
 
 from sentry import options
+from sentry.integrations.utils.stacktrace_link import ReposityLinkOutcome
 from sentry.models.organization import Organization
+from sentry.models.repository import Repository
 from sentry.services.hybrid_cloud.integration import integration_service
 
 LineCoverage = Sequence[Tuple[int, int]]
@@ -135,6 +137,13 @@ def get_codecov_data(repo: str, service: str, path: str) -> Tuple[LineCoverage |
     return line_coverage, codecov_url
 
 
+class CodecovConfig(TypedDict):
+    repository: Repository
+    # Config is a serialized RepositoryProjectPathConfig
+    config: Any
+    outcome: ReposityLinkOutcome
+
+
 class CodecovData(TypedDict):
     lineCoverage: NotRequired[LineCoverage]
     coverageUrl: NotRequired[str]
@@ -142,7 +151,7 @@ class CodecovData(TypedDict):
     attemptedUrl: NotRequired[str]
 
 
-def fetch_codecov_data(config: Dict[str, Any]) -> CodecovData:
+def fetch_codecov_data(config: CodecovConfig) -> CodecovData:
     data: CodecovData = {}
     message = ""
     try:

+ 2 - 1
src/sentry/integrations/utils/stacktrace_link.py

@@ -9,6 +9,7 @@ from sentry import analytics
 from sentry.api.utils import Timer
 from sentry.integrations.mixins import RepositoryMixin
 from sentry.models.integrations.repository_project_path_config import RepositoryProjectPathConfig
+from sentry.models.repository import Repository
 from sentry.services.hybrid_cloud.integration import integration_service
 from sentry.shared_integrations.exceptions import ApiError
 from sentry.utils.event_frames import munged_filename_and_frames
@@ -114,7 +115,7 @@ def try_path_munging(
 class StacktraceLinkConfig(TypedDict):
     config: RepositoryProjectPathConfig
     outcome: ReposityLinkOutcome
-    repository: str
+    repository: Repository
 
 
 class StacktraceLinkOutcome(TypedDict):

+ 160 - 0
tests/sentry/api/endpoints/test_project_stacktrace_coverage.py

@@ -0,0 +1,160 @@
+import logging
+from unittest.mock import patch
+
+import pytest
+import responses
+
+from sentry import options
+from sentry.integrations.example.integration import ExampleIntegration
+from tests.sentry.api.endpoints.test_project_stacktrace_link import BaseProjectStacktraceLink
+
+
+class ProjectStracktraceLinkTestCodecov(BaseProjectStacktraceLink):
+    endpoint = "sentry-api-0-project-stacktrace-coverage"
+
+    def setUp(self):
+        BaseProjectStacktraceLink.setUp(self)
+        options.set("codecov.client-secret", "supersecrettoken")
+        self.code_mapping1 = self.create_code_mapping(
+            organization_integration=self.oi,
+            project=self.project,
+            repo=self.repo,
+            stack_root="",
+            source_root="",
+        )
+        self.filepath = "src/path/to/file.py"
+        self.organization.flags.codecov_access = True
+
+        self.expected_codecov_url = (
+            "https://app.codecov.io/gh/getsentry/sentry/commit/master/blob/src/path/to/file.py"
+        )
+        self.expected_line_coverage = [[1, 0], [3, 1], [4, 0]]
+        self.organization.save()
+
+    @pytest.fixture(autouse=True)
+    def inject_fixtures(self, caplog):
+        self._caplog = caplog
+
+    @patch.object(
+        ExampleIntegration,
+        "get_stacktrace_link",
+        return_value="https://github.com/repo/blob/a67ea84967ed1ec42844720d9daf77be36ff73b0/src/path/to/file.py",
+    )
+    @responses.activate
+    def test_codecov_not_enabled(self, mock_integration):
+        self.organization.flags.codecov_access = False
+        self.organization.save()
+        response = self.get_error_response(
+            self.organization.slug,
+            self.project.slug,
+            qs_params={
+                "file": self.filepath,
+                "absPath": "abs_path",
+                "module": "module",
+                "package": "package",
+                "commitId": "a67ea84967ed1ec42844720d9daf77be36ff73b0",
+            },
+        )
+
+        assert response.data["detail"] == "Codecov not enabled"
+
+    @patch.object(
+        ExampleIntegration,
+        "get_stacktrace_link",
+        return_value="https://github.com/repo/blob/a67ea84967ed1ec42844720d9daf77be36ff73b0/src/path/to/file.py",
+    )
+    @responses.activate
+    def test_codecov_line_coverage_success(self, mock_integration):
+        responses.add(
+            responses.GET,
+            "https://api.codecov.io/api/v2/example/getsentry/repos/sentry/file_report/src/path/to/file.py",
+            status=200,
+            json={
+                "line_coverage": self.expected_line_coverage,
+                "commit_file_url": self.expected_codecov_url,
+                "commit_sha": "a67ea84967ed1ec42844720d9daf77be36ff73b0",
+            },
+            content_type="application/json",
+        )
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            qs_params={
+                "file": self.filepath,
+                "absPath": "abs_path",
+                "module": "module",
+                "package": "package",
+                "commitId": "a67ea84967ed1ec42844720d9daf77be36ff73b0",
+            },
+        )
+
+        assert response.data["lineCoverage"] == self.expected_line_coverage
+        assert response.data["status"] == 200
+
+    @patch.object(
+        ExampleIntegration,
+        "get_stacktrace_link",
+        return_value="https://github.com/repo/blob/master/src/path/to/file.py",
+    )
+    @responses.activate
+    def test_codecov_line_coverage_with_branch_success(self, mock_integration):
+        responses.add(
+            responses.GET,
+            "https://api.codecov.io/api/v2/example/getsentry/repos/sentry/file_report/src/path/to/file.py",
+            status=200,
+            json={
+                "line_coverage": self.expected_line_coverage,
+                "commit_file_url": self.expected_codecov_url,
+                "commit_sha": "a67ea84967ed1ec42844720d9daf77be36ff73b0",
+            },
+            content_type="application/json",
+        )
+
+        response = self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            qs_params={
+                "file": self.filepath,
+                "absPath": "abs_path",
+                "module": "module",
+                "package": "package",
+            },
+        )
+        assert response.data["lineCoverage"] == self.expected_line_coverage
+        assert response.data["status"] == 200
+
+    @patch.object(
+        ExampleIntegration,
+        "get_stacktrace_link",
+        return_value="https://github.com/repo/blob/a67ea84967ed1ec42844720d9daf77be36ff73b0/src/path/to/file.py",
+    )
+    @responses.activate
+    def test_codecov_line_coverage_exception(self, mock_integration):
+        self._caplog.set_level(logging.ERROR, logger="sentry")
+        responses.add(
+            responses.GET,
+            "https://api.codecov.io/api/v2/example/getsentry/repos/sentry/file_report/src/path/to/file.py",
+            status=500,
+            content_type="application/json",
+        )
+
+        self.get_success_response(
+            self.organization.slug,
+            self.project.slug,
+            qs_params={
+                "file": self.filepath,
+                "absPath": "abs_path",
+                "module": "module",
+                "package": "package",
+                "commitId": "a67ea84967ed1ec42844720d9daf77be36ff73b0",
+            },
+        )
+
+        assert self._caplog.record_tuples == [
+            (
+                "sentry.integrations.utils.codecov",
+                logging.ERROR,
+                "Codecov HTTP error: 500. Continuing execution.",
+            )
+        ]