diff --git a/src/sentry/api/endpoints/group_ai_autofix.py b/src/sentry/api/endpoints/group_ai_autofix.py index 9146861349be3f..955fe429f122f0 100644 --- a/src/sentry/api/endpoints/group_ai_autofix.py +++ b/src/sentry/api/endpoints/group_ai_autofix.py @@ -19,6 +19,7 @@ from sentry.autofix.utils import get_autofix_repos_from_project_code_mappings from sentry.models.group import Group from sentry.models.user import User +from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.services.hybrid_cloud.user.service import user_service from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -85,47 +86,63 @@ def _call_autofix( instruction: str, timeout_secs: int, ): - response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/start", - data=orjson.dumps( - { - "organization_id": group.organization.id, - "project_id": group.project.id, - "repos": repos, - "issue": { - "id": group.id, - "title": group.title, - "short_id": group.qualified_short_id, - "events": [serialized_event], - }, - "instruction": instruction, - "timeout_secs": timeout_secs, - "last_updated": datetime.now().isoformat(), - "invoking_user": ( - { - "id": user.id, - "display_name": user.get_display_name(), - } - if not isinstance(user, AnonymousUser) - else None - ), + path = "/v1/automation/autofix/start" + body = orjson.dumps( + { + "organization_id": group.organization.id, + "project_id": group.project.id, + "repos": repos, + "issue": { + "id": group.id, + "title": group.title, + "short_id": group.qualified_short_id, + "events": [serialized_event], }, - option=orjson.OPT_NON_STR_KEYS, - ), - headers={"content-type": "application/json;charset=utf-8"}, + "instruction": instruction, + "timeout_secs": timeout_secs, + "last_updated": datetime.now().isoformat(), + "invoking_user": ( + { + "id": user.id, + "display_name": user.get_display_name(), + } + if not isinstance(user, AnonymousUser) + else None + ), + }, + option=orjson.OPT_NON_STR_KEYS, + ) + response = requests.post( + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() def _call_get_autofix_state(self, group_id: int) -> dict[str, Any] | None: + path = "/v1/automation/autofix/state" + body = orjson.dumps( + { + "group_id": group_id, + } + ) response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/state", - data=orjson.dumps( - { - "group_id": group_id, - } - ), - headers={"content-type": "application/json;charset=utf-8"}, + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() diff --git a/src/sentry/api/endpoints/group_autofix_setup_check.py b/src/sentry/api/endpoints/group_autofix_setup_check.py index cc1e30e64c056b..b092ccd4dc9048 100644 --- a/src/sentry/api/endpoints/group_autofix_setup_check.py +++ b/src/sentry/api/endpoints/group_autofix_setup_check.py @@ -21,6 +21,7 @@ from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.project import Project +from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.services.hybrid_cloud.integration import integration_service logger = logging.getLogger(__name__) @@ -67,15 +68,23 @@ def get_repos_and_access(project: Project) -> list[dict]: repos = get_autofix_repos_from_project_code_mappings(project) repos_and_access: list[dict] = [] + path = "/v1/automation/codebase/repo/check-access" for repo in repos: + body = orjson.dumps( + { + "repo": repo, + } + ) response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codebase/repo/check-access", - data=orjson.dumps( - { - "repo": repo, - } - ), - headers={"content-type": "application/json;charset=utf-8"}, + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() diff --git a/src/sentry/api/endpoints/group_autofix_update.py b/src/sentry/api/endpoints/group_autofix_update.py index 572682eb62eced..638f8668612d65 100644 --- a/src/sentry/api/endpoints/group_autofix_update.py +++ b/src/sentry/api/endpoints/group_autofix_update.py @@ -41,8 +41,9 @@ def post(self, request: Request, group: Group) -> Response: data={"error": "You must be authenticated to use this endpoint"}, ) + path = "/v1/automation/autofix/update" response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/update", + f"{settings.SEER_AUTOFIX_URL}{path}", data=orjson.dumps( { **request.data, diff --git a/src/sentry/api/endpoints/organization_profiling_functions.py b/src/sentry/api/endpoints/organization_profiling_functions.py index c6605fc817621c..7b236187691976 100644 --- a/src/sentry/api/endpoints/organization_profiling_functions.py +++ b/src/sentry/api/endpoints/organization_profiling_functions.py @@ -19,7 +19,7 @@ from sentry.models.organization import Organization from sentry.search.events.builder import ProfileTopFunctionsTimeseriesQueryBuilder from sentry.search.events.types import QueryBuilderConfig -from sentry.seer.breakpoints import BreakpointData, detect_breakpoints +from sentry.seer.breakpoints import BreakpointData, BreakpointRequest, detect_breakpoints from sentry.snuba import functions from sentry.snuba.dataset import Dataset from sentry.snuba.referrer import Referrer @@ -163,7 +163,7 @@ def get_trends_data(stats_data) -> list[BreakpointData]: if not stats_data: return [] - trends_request = { + trends_request: BreakpointRequest = { "data": { k: { "data": v[data["function"]]["data"], @@ -182,7 +182,6 @@ def get_trends_data(stats_data) -> list[BreakpointData]: if v[data["function"]]["data"] }, "sort": data["trend"].as_sort(), - "trendFunction": data["function"], } return detect_breakpoints(trends_request)["data"] diff --git a/src/sentry/api/endpoints/project_autofix_create_codebase_index.py b/src/sentry/api/endpoints/project_autofix_create_codebase_index.py index 4aaa057a3beea8..4d652878acc7ed 100644 --- a/src/sentry/api/endpoints/project_autofix_create_codebase_index.py +++ b/src/sentry/api/endpoints/project_autofix_create_codebase_index.py @@ -13,6 +13,7 @@ from sentry.api.bases.project import ProjectEndpoint, ProjectPermission from sentry.autofix.utils import get_autofix_repos_from_project_code_mappings from sentry.models.project import Project +from sentry.seer.signed_seer_api import sign_with_seer_secret logger = logging.getLogger(__name__) @@ -41,18 +42,26 @@ def post(self, request: Request, project: Project) -> Response: Create a codebase index for for a project's repositories, uses the code mapping to determine which repositories to index """ repos = get_autofix_repos_from_project_code_mappings(project) + path = "/v1/automation/codebase/index/create" for repo in repos: + body = orjson.dumps( + { + "organization_id": project.organization.id, + "project_id": project.id, + "repo": repo, + } + ) response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codebase/index/create", - data=orjson.dumps( - { - "organization_id": project.organization.id, - "project_id": project.id, - "repo": repo, - } - ), - headers={"content-type": "application/json;charset=utf-8"}, + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() diff --git a/src/sentry/api/helpers/autofix.py b/src/sentry/api/helpers/autofix.py index 61758f5b171df2..ae9ca2c03adc43 100644 --- a/src/sentry/api/helpers/autofix.py +++ b/src/sentry/api/helpers/autofix.py @@ -5,6 +5,7 @@ from django.conf import settings from sentry.autofix.utils import get_autofix_repos_from_project_code_mappings +from sentry.seer.signed_seer_api import sign_with_seer_secret class AutofixCodebaseIndexingStatus(str, enum.Enum): @@ -20,18 +21,26 @@ def get_project_codebase_indexing_status(project): return None statuses = [] + path = "/v1/automation/codebase/index/status" for repo in repos: + body = orjson.dumps( + { + "organization_id": project.organization.id, + "project_id": project.id, + "repo": repo, + }, + option=orjson.OPT_UTC_Z, + ) response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/codebase/index/status", - data=orjson.dumps( - { - "organization_id": project.organization.id, - "project_id": project.id, - "repo": repo, - }, - option=orjson.OPT_UTC_Z, - ), - headers={"content-type": "application/json;charset=utf-8"}, + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() diff --git a/src/sentry/autofix/utils.py b/src/sentry/autofix/utils.py index 36816eaa637427..27dc946819654c 100644 --- a/src/sentry/autofix/utils.py +++ b/src/sentry/autofix/utils.py @@ -6,6 +6,7 @@ from sentry.integrations.utils.code_mapping import get_sorted_code_mapping_configs from sentry.models.project import Project from sentry.models.repository import Repository +from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.utils import json @@ -51,15 +52,23 @@ def get_autofix_repos_from_project_code_mappings(project: Project) -> list[dict] def get_autofix_state_from_pr_id(provider: str, pr_id: int) -> AutofixState | None: + path = "/v1/automation/autofix/state/pr" + body = json.dumps( + { + "provider": provider, + "pr_id": pr_id, + } + ).encode("utf-8") response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/state/pr", - data=json.dumps( - { - "provider": provider, - "pr_id": pr_id, - } - ), - headers={"content-type": "application/json;charset=utf-8"}, + f"{settings.SEER_AUTOFIX_URL}{path}", + data=body, + headers={ + "content-type": "application/json;charset=utf-8", + **sign_with_seer_secret( + url=f"{settings.SEER_AUTOFIX_URL}{path}", + body=body, + ), + }, ) response.raise_for_status() diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index b1545d48ec3e57..d5099c9f00ca73 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -673,8 +673,11 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: # Timeout for RPC requests between regions RPC_TIMEOUT = 5.0 -# Shared secret used to sign cross-region RPC requests with the seer microservice. +# TODO: Replace both of these secrets with mutual TLS and simplify our rpc channels. +# Shared secret used to sign cross-region RPC requests from the seer microservice. SEER_RPC_SHARED_SECRET: list[str] | None = None +# Shared secret used to sign cross-region RPC requests to the seer microservice. +SEER_API_SHARED_SECRET: str = "" # The protocol, host and port for control silo # Usecases include sending requests to the Integration Proxy Endpoint and RPC requests. diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 189d3bed8d3396..c72cc62ef36840 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2550,3 +2550,18 @@ default=50, flags=FLAG_AUTOMATOR_MODIFIABLE, ) + +# Enable orjson in the occurrence_consumer.process_[message|batch] +register( + "issues.occurrence_consumer.use_orjson", + type=Bool, + default=False, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) + +# Controls the rate of using the sentry api shared secret for communicating to sentry. +register( + "seer.api.use-shared-secret", + default=0.0, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) diff --git a/src/sentry/seer/breakpoints.py b/src/sentry/seer/breakpoints.py index 252e4a4c8b656c..576a63748b5583 100644 --- a/src/sentry/seer/breakpoints.py +++ b/src/sentry/seer/breakpoints.py @@ -1,11 +1,13 @@ import logging -from typing import TypedDict +from collections.abc import Mapping +from typing import NotRequired, TypedDict import sentry_sdk from django.conf import settings from urllib3 import Retry from sentry.net.http import connection_from_url +from sentry.seer.signed_seer_api import make_signed_seer_api_request from sentry.utils import json logger = logging.getLogger(__name__) @@ -44,12 +46,36 @@ class BreakpointResponse(TypedDict): ) -def detect_breakpoints(breakpoint_request) -> BreakpointResponse: - response = seer_breakpoint_connection_pool.urlopen( - "POST", +# TODO: Source these from shared schema repository +class BreakpointRequest(TypedDict): + data: "Mapping[str, BreakpointTransaction]" + sort: NotRequired[str] + allow_midpoint: NotRequired[str] + validate_tail_hours: NotRequired[int] + trend_percentage: NotRequired[float] + min_change: NotRequired[float] + + +class BreakpointTransaction(TypedDict): + data: "list[SnubaTSEntry]" + request_start: int + request_end: int + data_start: int + data_end: int + + +class SnubaMetadata(TypedDict): + count: float + + +SnubaTSEntry = tuple[int, tuple[SnubaMetadata]] + + +def detect_breakpoints(breakpoint_request: BreakpointRequest) -> BreakpointResponse: + response = make_signed_seer_api_request( + seer_breakpoint_connection_pool, "/trends/breakpoint-detector", - body=json.dumps(breakpoint_request), - headers={"content-type": "application/json;charset=utf-8"}, + json.dumps(breakpoint_request).encode("utf-8"), ) if response.status >= 200 and response.status < 300: diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py new file mode 100644 index 00000000000000..cdec9c2a94646c --- /dev/null +++ b/src/sentry/seer/signed_seer_api.py @@ -0,0 +1,41 @@ +import hashlib +import hmac +from random import random +from typing import Any + +from django.conf import settings +from urllib3 import BaseHTTPResponse, HTTPConnectionPool + +from sentry import options + + +def make_signed_seer_api_request( + connection_pool: HTTPConnectionPool, path: str, body: bytes, timeout: int | None = None +) -> BaseHTTPResponse: + host = connection_pool.host + if connection_pool.port: + host += ":" + str(connection_pool.port) + auth_headers = sign_with_seer_secret(f"{connection_pool.scheme}://{host}{path}", body) + + timeout_options: dict[str, Any] = {} + if timeout: + timeout_options["timeout"] = timeout + + return connection_pool.urlopen( + "POST", + path, + body=body, + headers={"content-type": "application/json;charset=utf-8", **auth_headers}, + **timeout_options, + ) + + +def sign_with_seer_secret(url: str, body: bytes): + auth_headers: dict[str, str] = {} + if random() < options.get("seer.api.use-shared-secret") and settings.SEER_API_SHARED_SECRET: + signature_input = b"%s:%s" % (url.encode("utf8"), body) + signature = hmac.new( + settings.SEER_API_SHARED_SECRET.encode("utf-8"), signature_input, hashlib.sha256 + ).hexdigest() + auth_headers["Authorization"] = f"Rpcsignature rpc0:{signature}" + return auth_headers diff --git a/src/sentry/seer/similarity/backfill.py b/src/sentry/seer/similarity/backfill.py index f83b831ff7bba0..909263fe93db45 100644 --- a/src/sentry/seer/similarity/backfill.py +++ b/src/sentry/seer/similarity/backfill.py @@ -6,6 +6,7 @@ from sentry.conf.server import SEER_GROUPING_RECORDS_DELETE_URL, SEER_GROUPING_RECORDS_URL from sentry.net.http import connection_from_url +from sentry.seer.signed_seer_api import make_signed_seer_api_request from sentry.seer.similarity.types import RawSeerSimilarIssueData from sentry.utils import json @@ -55,11 +56,10 @@ def post_bulk_grouping_records( } try: - response = seer_grouping_connection_pool.urlopen( - "POST", + response = make_signed_seer_api_request( + seer_grouping_connection_pool, SEER_GROUPING_RECORDS_URL, - body=json.dumps(grouping_records_request), - headers={"Content-Type": "application/json;charset=utf-8"}, + body=json.dumps(grouping_records_request).encode("utf-8"), timeout=POST_BULK_GROUPING_RECORDS_TIMEOUT, ) except ReadTimeoutError: @@ -80,6 +80,7 @@ def delete_grouping_records( project_id: int, ) -> bool: try: + # TODO: Move this over to POST json_api implementation response = seer_grouping_connection_pool.urlopen( "GET", f"{SEER_GROUPING_RECORDS_DELETE_URL}/{project_id}", diff --git a/src/sentry/seer/similarity/similar_issues.py b/src/sentry/seer/similarity/similar_issues.py index c20d93f2d9a256..9765fe97aa8926 100644 --- a/src/sentry/seer/similarity/similar_issues.py +++ b/src/sentry/seer/similarity/similar_issues.py @@ -4,6 +4,7 @@ from sentry.conf.server import SEER_MAX_GROUPING_DISTANCE, SEER_SIMILAR_ISSUES_URL from sentry.net.http import connection_from_url +from sentry.seer.signed_seer_api import make_signed_seer_api_request from sentry.seer.similarity.types import ( IncompleteSeerDataError, SeerSimilarIssueData, @@ -31,11 +32,12 @@ def get_similarity_data_from_seer( sorted in order of descending similarity. """ - response = seer_grouping_connection_pool.urlopen( - "POST", + response = make_signed_seer_api_request( + seer_grouping_connection_pool, SEER_SIMILAR_ISSUES_URL, - body=json.dumps({"threshold": SEER_MAX_GROUPING_DISTANCE, **similar_issues_request}), - headers={"Content-Type": "application/json;charset=utf-8"}, + json.dumps({"threshold": SEER_MAX_GROUPING_DISTANCE, **similar_issues_request}).encode( + "utf8" + ), ) try: @@ -50,6 +52,7 @@ def get_similarity_data_from_seer( extra={ "request_params": similar_issues_request, "response_data": response.data, + "response_code": response.status, }, ) return [] diff --git a/src/sentry/statistical_detectors/detector.py b/src/sentry/statistical_detectors/detector.py index 6a0f72660399bb..032d0fb7df7fbf 100644 --- a/src/sentry/statistical_detectors/detector.py +++ b/src/sentry/statistical_detectors/detector.py @@ -25,7 +25,12 @@ get_regression_groups, ) from sentry.search.events.fields import get_function_alias -from sentry.seer.breakpoints import BreakpointData, detect_breakpoints +from sentry.seer.breakpoints import ( + BreakpointData, + BreakpointRequest, + BreakpointTransaction, + detect_breakpoints, +) from sentry.statistical_detectors.algorithm import DetectorAlgorithm from sentry.statistical_detectors.base import DetectorPayload, DetectorState, TrendType from sentry.statistical_detectors.issue_platform_adapter import fingerprint_regression @@ -199,10 +204,11 @@ def detect_regressions( serializer = SnubaTSResultSerializer(None, None, None) for chunk in chunked(cls.all_timeseries(objects, start, function), timeseries_per_batch): - data = {} + data: dict[str, BreakpointTransaction] = {} for project_id, object_name, result in chunk: serialized = serializer.serialize(result, get_function_alias(function)) - data[f"{project_id},{object_name}"] = { + + transaction: BreakpointTransaction = { "data": serialized["data"], "data_start": serialized["start"], "data_end": serialized["end"], @@ -211,11 +217,13 @@ def detect_regressions( "request_end": serialized["end"], } - request = { + data[f"{project_id},{object_name}"] = transaction + + request: BreakpointRequest = { "data": data, "sort": "-trend_percentage()", - "min_change()": cls.min_change, - # "trend_percentage()": 0.5, # require a minimum 50% increase + "min_change": cls.min_change, + # "trend_percentage": 0.5, # require a minimum 50% increase # "validate_tail_hours": 6, # Disable the fall back to use the midpoint as the breakpoint # which was originally intended to detect a gradual regression diff --git a/tests/sentry/api/endpoints/test_group_similar_issues_embeddings.py b/tests/sentry/api/endpoints/test_group_similar_issues_embeddings.py index 2f2bf9e5e26068..0defb49cf843a7 100644 --- a/tests/sentry/api/endpoints/test_group_similar_issues_embeddings.py +++ b/tests/sentry/api/endpoints/test_group_similar_issues_embeddings.py @@ -230,8 +230,8 @@ def test_simple(self, mock_logger, mock_seer_request, mock_metrics): mock_seer_request.assert_called_with( "POST", SEER_SIMILAR_ISSUES_URL, - body=orjson.dumps(expected_seer_request_params).decode(), - headers={"Content-Type": "application/json;charset=utf-8"}, + body=orjson.dumps(expected_seer_request_params), + headers={"content-type": "application/json;charset=utf-8"}, ) expected_seer_request_params["group_message"] = expected_seer_request_params.pop("message") @@ -514,8 +514,8 @@ def test_no_optional_params(self, mock_seer_request): "exception_type": "ZeroDivisionError", "read_only": True, }, - ).decode(), - headers={"Content-Type": "application/json;charset=utf-8"}, + ), + headers={"content-type": "application/json;charset=utf-8"}, ) # Include k @@ -541,8 +541,8 @@ def test_no_optional_params(self, mock_seer_request): "read_only": True, "k": 1, }, - ).decode(), - headers={"Content-Type": "application/json;charset=utf-8"}, + ), + headers={"content-type": "application/json;charset=utf-8"}, ) # Include threshold @@ -567,6 +567,6 @@ def test_no_optional_params(self, mock_seer_request): "exception_type": "ZeroDivisionError", "read_only": True, }, - ).decode(), - headers={"Content-Type": "application/json;charset=utf-8"}, + ), + headers={"content-type": "application/json;charset=utf-8"}, ) diff --git a/tests/sentry/autofix/test_utils.py b/tests/sentry/autofix/test_utils.py index 884741a3dcaa5b..04d8f8f990499d 100644 --- a/tests/sentry/autofix/test_utils.py +++ b/tests/sentry/autofix/test_utils.py @@ -52,7 +52,7 @@ def test_get_autofix_state_from_pr_id_success(self, mock_post): mock_post.assert_called_once_with( f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/state/pr", - data=json.dumps({"provider": "github", "pr_id": 1}), + data=json.dumps({"provider": "github", "pr_id": 1}).encode("utf-8"), headers={"content-type": "application/json;charset=utf-8"}, ) diff --git a/tests/sentry/seer/similarity/test_backfill.py b/tests/sentry/seer/similarity/test_backfill.py index 234a02b01cf8b6..87b2e2c81fb958 100644 --- a/tests/sentry/seer/similarity/test_backfill.py +++ b/tests/sentry/seer/similarity/test_backfill.py @@ -2,6 +2,7 @@ from unittest import mock from unittest.mock import MagicMock +import pytest from django.conf import settings from urllib3.connectionpool import ConnectionPool from urllib3.exceptions import ReadTimeoutError @@ -37,6 +38,7 @@ } +@pytest.mark.django_db @mock.patch("sentry.seer.similarity.backfill.logger") @mock.patch("sentry.seer.similarity.backfill.seer_grouping_connection_pool.urlopen") def test_post_bulk_grouping_records_success(mock_seer_request: MagicMock, mock_logger: MagicMock): @@ -60,6 +62,7 @@ def test_post_bulk_grouping_records_success(mock_seer_request: MagicMock, mock_l ) +@pytest.mark.django_db @mock.patch("sentry.seer.similarity.backfill.logger") @mock.patch("sentry.seer.similarity.backfill.seer_grouping_connection_pool.urlopen") def test_post_bulk_grouping_records_timeout(mock_seer_request: MagicMock, mock_logger: MagicMock): @@ -82,6 +85,7 @@ def test_post_bulk_grouping_records_timeout(mock_seer_request: MagicMock, mock_l ) +@pytest.mark.django_db @mock.patch("sentry.seer.similarity.backfill.logger") @mock.patch("sentry.seer.similarity.backfill.seer_grouping_connection_pool.urlopen") def test_post_bulk_grouping_records_failure(mock_seer_request: MagicMock, mock_logger: MagicMock): @@ -105,6 +109,7 @@ def test_post_bulk_grouping_records_failure(mock_seer_request: MagicMock, mock_l ) +@pytest.mark.django_db @mock.patch("sentry.seer.similarity.backfill.seer_grouping_connection_pool.urlopen") def test_post_bulk_grouping_records_empty_data(mock_seer_request: MagicMock): """Test that function handles empty data. This should not happen, but we do not want to error if it does.""" diff --git a/tests/sentry/seer/test_breakpoints.py b/tests/sentry/seer/test_breakpoints.py index 70d1aae21bc4fd..9dc5d6c9c2f689 100644 --- a/tests/sentry/seer/test_breakpoints.py +++ b/tests/sentry/seer/test_breakpoints.py @@ -7,6 +7,7 @@ from sentry.utils import json +@pytest.mark.django_db @mock.patch("sentry.seer.breakpoints.seer_breakpoint_connection_pool.urlopen") def test_detect_breakpoints(mock_urlopen): data = { @@ -27,9 +28,10 @@ def test_detect_breakpoints(mock_urlopen): } mock_urlopen.return_value = HTTPResponse(json.dumps(data), status=200) - assert detect_breakpoints({}) == data + assert detect_breakpoints({"data": {}}) == data +@pytest.mark.django_db @pytest.mark.parametrize( ["body", "status"], [ @@ -43,5 +45,5 @@ def test_detect_breakpoints(mock_urlopen): def test_detect_breakpoints_errors(mock_urlopen, mock_capture_exception, body, status): mock_urlopen.return_value = HTTPResponse(body, status=status) - assert detect_breakpoints({}) == {"data": []} + assert detect_breakpoints({"data": {}}) == {"data": []} assert mock_capture_exception.called diff --git a/tests/sentry/seer/test_signed_seer_api.py b/tests/sentry/seer/test_signed_seer_api.py new file mode 100644 index 00000000000000..fab6b29781d74c --- /dev/null +++ b/tests/sentry/seer/test_signed_seer_api.py @@ -0,0 +1,53 @@ +from unittest.mock import Mock + +import pytest +from django.test import override_settings + +from sentry.seer.signed_seer_api import make_signed_seer_api_request +from sentry.testutils.helpers import override_options + + +@pytest.mark.django_db +def test_make_signed_seer_api_request(): + body = b'{"b": 12, "thing": "thing"}' + + def url_request(timeout: int | None = None): + mock = Mock() + mock.host = "localhost" + mock.port = None + mock.scheme = "http" + with override_settings(SEER_API_SHARED_SECRET="secret-one"): + make_signed_seer_api_request( + mock, + path="/v0/some/url", + body=body, + timeout=timeout, + ) + + return mock.urlopen + + url_request().assert_called_once_with( + "POST", + "/v0/some/url", + body=body, + headers={"content-type": "application/json;charset=utf-8"}, + ) + + url_request(timeout=5).assert_called_once_with( + "POST", + "/v0/some/url", + body=body, + headers={"content-type": "application/json;charset=utf-8"}, + timeout=5, + ) + + with override_options({"seer.api.use-shared-secret": 1.0}): + url_request().assert_called_once_with( + "POST", + "/v0/some/url", + body=body, + headers={ + "content-type": "application/json;charset=utf-8", + "Authorization": "Rpcsignature rpc0:96f23d5b3df807a9dc91f090078a46c00e17fe8b0bc7ef08c9391fa8b37a66b5", + }, + )