diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 231d12a9857817..2e77ee11324041 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -583,8 +583,6 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get
## Telemetry Experience
-/src/sentry/autopilot/ @getsentry/telemetry-experience
-/tests/sentry/autopilot/ @getsentry/telemetry-experience
/src/sentry/api/endpoints/organization_sessions.py @getsentry/telemetry-experience
/tests/snuba/api/endpoints/test_organization_sessions.py @getsentry/telemetry-experience
/src/sentry/api/endpoints/organization_sampling_project_span_counts.py @getsentry/telemetry-experience
diff --git a/pyproject.toml b/pyproject.toml
index 36a59fe6060861..4330e14cf42188 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -717,7 +717,6 @@ module = [
"sentry.audit_log.events",
"sentry.audit_log.manager",
"sentry.audit_log.register",
- "sentry.autopilot.*",
"sentry.backup.*",
"sentry.billing.*",
"sentry.cache.*",
@@ -1919,7 +1918,6 @@ module = [
"tests.sentry.auth.test_system",
"tests.sentry.auth_v2.utils.*",
"tests.sentry.autofix.*",
- "tests.sentry.autopilot.*",
"tests.sentry.backup.*",
"tests.sentry.billing.*",
"tests.sentry.buffer.*",
diff --git a/src/apigw/config.py b/src/apigw/config.py
index 4aced2fe9ebefb..db2f8c4d3f3dc0 100644
--- a/src/apigw/config.py
+++ b/src/apigw/config.py
@@ -46,6 +46,7 @@ def load_config(app: App) -> None:
app.config.proxy.max_concurrency = int(os.environ.get("APIGW_PROXY_MAX_CONCURRENCY", 512))
app.config.proxy.max_failures = int(os.environ.get("APIGW_PROXY_MAX_FAILURES", 16))
app.config.proxy.failure_window = int(os.environ.get("APIGW_PROXY_FAILURE_WINDOW", 60))
+ app.config.proxy.latency_buckets = [50, 100, 250, 1000, 10000, 60000]
app.config.proxy.client_max_connections = None
app.config.proxy.client_keepalive_max_connections = None
@@ -73,7 +74,7 @@ def load_config(app: App) -> None:
app.config.Prometheus.metrics_route_hostname = app.config.internal_fqdn
app.config.Prometheus.enable_ws_metrics = False
- app.config.Prometheus.http_histogram_buckets = [35, 100, 500, 1000, 5000, "INF"]
+ app.config.Prometheus.http_histogram_buckets = [35, 100, 500, 1000, 5000]
app.config.Prometheus.exclude_routes = ["internal.health"]
from django.conf import settings
diff --git a/src/apigw/proxy.py b/src/apigw/proxy.py
index b2a3f81add7bf5..f64cff86e72574 100644
--- a/src/apigw/proxy.py
+++ b/src/apigw/proxy.py
@@ -1,10 +1,11 @@
import asyncio
+import time
from typing import Any, AsyncIterator
from urllib.parse import urljoin
import httpx
import prometheus_client
-from emmett55 import response
+from emmett55 import Pipe, current, response
from . import app
from .circuitbreaker import (
@@ -71,6 +72,27 @@ async def __aiter__(self) -> AsyncIterator[bytes]:
metric_cb_reject = prometheus_client.Counter(
"apigw_proxy_circuitbreaker_rejected", "Circuitbreaker rejected", labelnames=["target"]
)
+metric_latency = prometheus_client.Histogram(
+ "apigw_proxy_latency",
+ "Latency histogram (ms)",
+ labelnames=["target"],
+ buckets=app.config.proxy.latency_buckets,
+)
+
+
+class ProxyLatencyPipe(Pipe):
+ @staticmethod
+ def track(target: str) -> None:
+ current._proxy_latency_data = (target, time.perf_counter_ns())
+
+ async def open_request(self) -> None:
+ current._proxy_latency_data = None
+
+ async def close_request(self) -> None:
+ if not current._proxy_latency_data:
+ return
+ target, ts = current._proxy_latency_data
+ metric_latency.labels(target=target).observe((time.perf_counter_ns() - ts) / 1_000_000)
def build_proxied_headers(request: Any, target: str) -> list[tuple[str, str]]:
@@ -154,6 +176,7 @@ async def proxy_cell_request(cell: Cell, request: Any) -> Any:
content=request.body,
timeout=timeout,
)
+ ProxyLatencyPipe.track(cell.name)
resp = await proxy_client.send(req, stream=True, follow_redirects=False)
if resp.status_code >= 502:
circuitbreaker.incr_failures()
@@ -198,6 +221,7 @@ async def proxy_control_request(request: Any) -> Any:
content=request.body,
timeout=app.config.proxy.timeout,
)
+ ProxyLatencyPipe.track("control")
resp = await proxy_client.send(req, stream=True, follow_redirects=False)
return await adapt_response(resp)
except asyncio.CancelledError:
diff --git a/src/apigw/views/proxy.py b/src/apigw/views/proxy.py
index 3218f92c3387cd..32cbe0122c3baf 100644
--- a/src/apigw/views/proxy.py
+++ b/src/apigw/views/proxy.py
@@ -9,10 +9,11 @@
get_cell_for_organization,
get_cell_from_dsn,
)
-from ..proxy import proxy_cell_request, proxy_control_request
+from ..proxy import ProxyLatencyPipe, proxy_cell_request, proxy_control_request
from ..utils import abort_with_json
proxy = app.module(__name__, "proxy")
+proxy.pipeline = [ProxyLatencyPipe()]
@proxy.route(
diff --git a/src/sentry/api/endpoints/system_options.py b/src/sentry/api/endpoints/system_options.py
index bddc4209e65926..a37a6a841d3b34 100644
--- a/src/sentry/api/endpoints/system_options.py
+++ b/src/sentry/api/endpoints/system_options.py
@@ -22,6 +22,7 @@
"system.admin-email"
]
)
+MAIL_TLS_SSL_OPTIONS = ("mail.use-tls", "mail.use-ssl")
def _is_secret(k: Key) -> bool:
@@ -42,6 +43,7 @@ class SystemOptionsEndpoint(Endpoint):
def get(self, request: Request) -> Response:
query = request.GET.get("query")
+ required_only = query == "is:required"
if query == "is:required":
option_list = options.filter(flag=options.FLAG_REQUIRED)
elif query:
@@ -50,12 +52,19 @@ def get(self, request: Request) -> Response:
option_list = options.all()
smtp_disabled = not is_smtp_enabled()
+ disable_mail_tls_ssl_pair = required_only and self.__should_disable_mail_tls_ssl_pair()
results = {}
for k in option_list:
disabled, disabled_reason = False, None
- if smtp_disabled and k.name[:5] == "mail.":
+ if k.name in MAIL_TLS_SSL_OPTIONS and disable_mail_tls_ssl_pair:
+ disabled_reason, disabled = "diskPriority", True
+ elif (
+ smtp_disabled
+ and k.name[:5] == "mail."
+ and not (required_only and k.name in MAIL_TLS_SSL_OPTIONS)
+ ):
disabled_reason, disabled = "smtpDisabled", True
elif bool(
k.flags & options.FLAG_PRIORITIZE_DISK and settings.SENTRY_OPTIONS.get(k.name)
@@ -78,6 +87,16 @@ def get(self, request: Request) -> Response:
return Response(results)
+ def __should_disable_mail_tls_ssl_pair(self) -> bool:
+ for option_name in MAIL_TLS_SSL_OPTIONS:
+ if not options.is_set_on_disk(option_name):
+ continue
+
+ if settings.SENTRY_OPTIONS[option_name] != options.lookup_key(option_name).default():
+ return True
+
+ return False
+
def has_permission(self, request: Request) -> bool:
if settings.SENTRY_SELF_HOSTED and request.user.is_superuser:
return True
diff --git a/src/sentry/autopilot/__init__.py b/src/sentry/autopilot/__init__.py
deleted file mode 100644
index e69de29bb2d1d6..00000000000000
diff --git a/src/sentry/autopilot/apps.py b/src/sentry/autopilot/apps.py
deleted file mode 100644
index 0f02eb07f286ef..00000000000000
--- a/src/sentry/autopilot/apps.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from django.apps import AppConfig
-
-
-class AutopilotConfig(AppConfig):
- name = "sentry.autopilot"
diff --git a/src/sentry/autopilot/grouptype.py b/src/sentry/autopilot/grouptype.py
deleted file mode 100644
index 1e8eb035cee84f..00000000000000
--- a/src/sentry/autopilot/grouptype.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-from sentry.issues.grouptype import GroupCategory, GroupType
-from sentry.ratelimits.sliding_windows import Quota
-from sentry.types.group import PriorityLevel
-
-
-@dataclass(frozen=True)
-class InstrumentationIssueExperimentalGroupType(GroupType):
- """
- Issues detected by autopilot instrumentation analysis suggesting
- improvements to product usage and observability coverage.
- """
-
- type_id = 12001
- slug = "instrumentation_issue_experimental"
- description = "Instrumentation Issue"
- category = GroupCategory.INSTRUMENTATION.value
- category_v2 = GroupCategory.INSTRUMENTATION.value
- creation_quota = Quota(3600, 60, 100) # 100 per hour, sliding window of 60 seconds
- default_priority = PriorityLevel.LOW
- in_default_search = False # Hide from issues stream
- released = False # Start as feature-flagged
- enable_auto_resolve = False
- enable_escalation_detection = False
diff --git a/src/sentry/autopilot/migrations/__init__.py b/src/sentry/autopilot/migrations/__init__.py
deleted file mode 100644
index e69de29bb2d1d6..00000000000000
diff --git a/src/sentry/autopilot/models.py b/src/sentry/autopilot/models.py
deleted file mode 100644
index e69de29bb2d1d6..00000000000000
diff --git a/src/sentry/autopilot/tasks/__init__.py b/src/sentry/autopilot/tasks/__init__.py
deleted file mode 100644
index e69de29bb2d1d6..00000000000000
diff --git a/src/sentry/autopilot/tasks/common.py b/src/sentry/autopilot/tasks/common.py
deleted file mode 100644
index 57efa19a7bf8ee..00000000000000
--- a/src/sentry/autopilot/tasks/common.py
+++ /dev/null
@@ -1,89 +0,0 @@
-import logging
-import uuid
-from enum import StrEnum
-from typing import Any
-
-from django.utils import timezone
-
-from sentry.autopilot.grouptype import InstrumentationIssueExperimentalGroupType
-from sentry.issues.issue_occurrence import IssueEvidence, IssueOccurrence
-from sentry.issues.producer import PayloadType, produce_occurrence_to_kafka
-from sentry.models.project import Project
-
-logger = logging.getLogger(__name__)
-
-
-class AutopilotDetectorName(StrEnum):
- SDK_UPDATE = "sdk-update"
- MISSING_SDK_INTEGRATION = "missing-sdk-integration"
- TRACE_INSTRUMENTATION = "trace-instrumentation"
-
-
-def create_instrumentation_issue(
- project_id: int,
- detector_name: str,
- title: str,
- subtitle: str,
- description: str | None = None,
- repository_name: str | None = None,
-) -> None:
- detection_time = timezone.now()
- event_id = uuid.uuid4().hex
-
- # Fetch the project to get its platform
- project = Project.objects.get_from_cache(id=project_id)
-
- evidence_data: dict[str, Any] = {}
- evidence_display: list[IssueEvidence] = []
-
- if description:
- evidence_data["description"] = description
- evidence_display.append(
- IssueEvidence(name="Description", value=description, important=True)
- )
-
- if repository_name:
- evidence_data["repository_name"] = repository_name
- evidence_display.append(
- IssueEvidence(name="Repository", value=repository_name, important=False)
- )
-
- occurrence = IssueOccurrence(
- id=uuid.uuid4().hex,
- project_id=project_id,
- event_id=event_id,
- fingerprint=[f"{detector_name}:{title}"],
- issue_title=title,
- subtitle=subtitle,
- resource_id=None,
- evidence_data=evidence_data,
- evidence_display=evidence_display,
- type=InstrumentationIssueExperimentalGroupType,
- detection_time=detection_time,
- culprit=detector_name,
- level="info",
- )
-
- event_data: dict[str, Any] = {
- "event_id": occurrence.event_id,
- "project_id": occurrence.project_id,
- "platform": project.platform or "other",
- "received": detection_time.isoformat(),
- "timestamp": detection_time.isoformat(),
- "tags": {},
- }
-
- produce_occurrence_to_kafka(
- payload_type=PayloadType.OCCURRENCE,
- occurrence=occurrence,
- event_data=event_data,
- )
-
- logger.warning(
- "autopilot.instrumentation_issue.created",
- extra={
- "project_id": project_id,
- "detector_name": detector_name,
- "title": title,
- },
- )
diff --git a/src/sentry/autopilot/tasks/missing_sdk_integration.py b/src/sentry/autopilot/tasks/missing_sdk_integration.py
deleted file mode 100644
index c164c3d55af665..00000000000000
--- a/src/sentry/autopilot/tasks/missing_sdk_integration.py
+++ /dev/null
@@ -1,360 +0,0 @@
-import logging
-from enum import StrEnum
-
-from pydantic import BaseModel, Field
-
-from sentry import options
-from sentry.autopilot.tasks.common import AutopilotDetectorName, create_instrumentation_issue
-from sentry.constants import INTEGRATION_ID_TO_PLATFORM_DATA, ObjectStatus
-from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig
-from sentry.models.organization import Organization
-from sentry.models.project import Project
-from sentry.seer.agent.client import SeerAgentClient
-from sentry.seer.models import SeerPermissionError
-from sentry.seer.seer_setup import has_seer_access
-from sentry.tasks.base import instrumented_task
-from sentry.taskworker.namespaces import autopilot_tasks
-from sentry.utils import metrics
-
-logger = logging.getLogger(__name__)
-
-
-class SupportedPlatformPrefix(StrEnum):
- JAVASCRIPT = "javascript"
- NODE = "node"
- PYTHON = "python"
-
-
-class MissingSdkIntegrationFinishReason(StrEnum):
- SUCCESS = "success"
- MISSING_SENTRY_INIT = "missing_sentry_init"
- MISSING_DEPENDENCY_FILE = "missing_dependency_file"
- CODE_PATH_NOT_FOUND = "code_path_not_found"
-
-
-class MissingSdkIntegrationDetail(BaseModel):
- """Detail about a single missing SDK integration."""
-
- name: str = Field(..., min_length=1, max_length=200)
- summary: str = Field(..., min_length=1, max_length=500)
- docs_url: str = Field(..., min_length=1, max_length=500)
-
-
-class MissingSdkIntegrationsResult(BaseModel):
- """Result schema for missing SDK integrations detection."""
-
- missing_integrations: list[MissingSdkIntegrationDetail]
- finish_reason: str
-
-
-@instrumented_task(
- name="sentry.autopilot.tasks.run_missing_sdk_integration_detector",
- namespace=autopilot_tasks,
- processing_deadline_duration=300,
-)
-def run_missing_sdk_integration_detector() -> None:
- project_ids = options.get("autopilot.missing-sdk-integration.projects-allowlist")
- if not project_ids:
- return
-
- for project_id in project_ids:
- try:
- project = Project.objects.select_related("organization").get(id=project_id)
- except Project.DoesNotExist:
- logger.warning(
- "missing_sdk_integration_detector.project_not_found",
- extra={"project_id": project_id},
- )
- continue
-
- # Check gen AI consent before spawning child tasks
- if not has_seer_access(project.organization):
- logger.info(
- "missing_sdk_integration_detector.no_gen_ai_access",
- extra={"project_id": project_id, "organization_id": project.organization_id},
- )
- continue
-
- # Check platform support
- platform_supported = any(
- project.platform and project.platform.startswith(prefix)
- for prefix in SupportedPlatformPrefix
- )
- if not platform_supported:
- logger.info(
- "missing_sdk_integration_detector.unsupported_platform",
- extra={"project_id": project_id, "platform": project.platform},
- )
- continue
-
- # Get repo configs for this project
- repo_names = (
- RepositoryProjectPathConfig.objects.filter(
- project_repository__project=project,
- project_repository__repository__status=ObjectStatus.ACTIVE,
- project_repository__repository__provider="integrations:github",
- )
- .order_by("project_repository__repository__name")
- .distinct("project_repository__repository__name")
- .values_list("project_repository__repository__name", flat=True)
- )
- for repo_name in repo_names:
- run_missing_sdk_integration_detector_for_project_task.apply_async(
- args=(
- project.organization_id,
- project.id,
- repo_name,
- ),
- headers={"sentry-propagate-traces": False},
- )
-
-
-def _record_error(project_id: int, error_type: str) -> None:
- metrics.incr(
- "autopilot.missing_sdk_integration_detector.error",
- tags={
- "project_id": str(project_id),
- "error_type": error_type,
- },
- sample_rate=1.0,
- )
-
-
-@instrumented_task(
- name="sentry.autopilot.tasks.run_missing_sdk_integration_detector_for_project_task",
- namespace=autopilot_tasks,
- processing_deadline_duration=280,
-)
-def run_missing_sdk_integration_detector_for_project_task(
- organization_id: int, project_id: int, repo_name: str
-) -> list[str] | None:
- """
- Detect missing SDK integrations for a project using Seer Agent.
-
- Returns:
- List of missing integration names, or None if detection failed.
- """
- metrics.incr(
- "autopilot.missing_sdk_integration_detector.run",
- tags={"project_id": str(project_id)},
- sample_rate=1.0,
- )
-
- logger.info(
- "missing_sdk_integration_detector.run_started",
- extra={
- "organization_id": organization_id,
- "project_id": project_id,
- "repo_name": repo_name,
- },
- )
-
- try:
- organization = Organization.objects.get(id=organization_id)
- project = Project.objects.get(id=project_id)
- except (Organization.DoesNotExist, Project.DoesNotExist):
- logger.warning(
- "missing_sdk_integration_detector.entity_not_found",
- extra={"organization_id": organization_id, "project_id": project_id},
- )
- return None
-
- try:
- client = SeerAgentClient(
- organization,
- user=None,
- category_key=AutopilotDetectorName.MISSING_SDK_INTEGRATION,
- category_value=str(project.id),
- intelligence_level="medium",
- )
- except SeerPermissionError:
- _record_error(project.id, "SeerPermissionError")
- logger.exception(
- "missing_sdk_integration_detector.no_seer_access",
- extra={"organization_id": organization.id, "project_id": project.id},
- )
- return None
-
- if project.platform not in INTEGRATION_ID_TO_PLATFORM_DATA:
- logger.warning(
- "missing_sdk_integration_detector.platform_data_lookup.not_found",
- extra={
- "platform": project.platform,
- },
- )
-
- # Get docs URL from platform data
- platform_data = INTEGRATION_ID_TO_PLATFORM_DATA.get(project.platform or "", {})
- docs_url = platform_data.get("link")
- if project.platform and project.platform.startswith(SupportedPlatformPrefix.PYTHON):
- integration_docs_url = "https://docs.sentry.io/platforms/python/integrations/"
- elif docs_url:
- integration_docs_url = f"{docs_url}configuration/integrations/"
- else:
- integration_docs_url = "https://docs.sentry.io/platforms/"
-
- prompt = f"""# Objective
-Find missing Sentry SDK integrations for the project `{project.slug}` in repository `{repo_name}`.
-
-# Locate Project Directory
-Find the directory in the repository that contains the {project.platform} project `{project.slug}`.
-
-Use these hints to locate it:
-1. Check the repository root for dependency files
-2. Look for a directory named `{project.slug}` or similarly named
-3. Check common monorepo locations: `packages/`, `apps/`, `services/`
-4. Look for directories containing {project.platform} dependency files
-
-If you cannot locate the project directory, return `{MissingSdkIntegrationFinishReason.CODE_PATH_NOT_FOUND}` as the finish reason and an empty list of missing integrations.
-
-Once located, analyze ONLY that directory. Do not read files from parent or sibling directories.
-
-# Steps
-
-1. **Read Dependencies**
- Read the {project.platform} dependency file from the project directory:
- - JavaScript/Node: `package.json`
- - Python: `requirements.txt`, `pyproject.toml`, or `setup.py`
- - Ruby: `Gemfile`
- - Go: `go.mod`
- - Java: `pom.xml` or `build.gradle`
- - PHP: `composer.json`
- If you cannot find any dependency file, return `{MissingSdkIntegrationFinishReason.MISSING_DEPENDENCY_FILE}` as the finish reason and an empty list of missing integrations.
-
-2. **Read Sentry Configuration**
- Search for Sentry initialization (`Sentry.init` or `sentry_sdk.init`) within the project directory and note configured integrations.
- If you cannot find any Sentry initialization, return `{MissingSdkIntegrationFinishReason.MISSING_SENTRY_INIT}` as the finish reason and an empty list of missing integrations.
-
-3. **Read SDK Integrations Docs**
- Fetch the integrations table from: {integration_docs_url}
- Note integration names and whether they are auto-enabled.
-
-4. **Read Missing Integrations Docs**
- For each identified missing integration, read the documentation link for that integration and double check if it is really tied to a specific package in the project's dependencies and if it is applicable to the project.
-
-# Acceptance Criteria
-
-Only report an integration if ALL of the following are true. Check each criterion one by one:
-
-1. [ ] The integration has a **specific package dependency** (e.g., `zodErrorsIntegration` requires `zod`)
-2. [ ] That package exists in the project's dependency file
-3. [ ] The integration is NOT marked as auto-enabled in the docs
-4. [ ] The integration is NOT already configured in `Sentry.init`
-5. [ ] The integration is NOT explicitly disabled in `Sentry.init`
-
-General-purpose integrations that don't require a specific package (e.g., `extraErrorDataIntegration`, `replayIntegration`, `feedbackIntegration`, `captureConsoleIntegration`, `httpClientIntegration`, `browserTracingIntegration`) will never pass criterion 1.
-
-# Output
-
-Return a JSON object with:
-- `missing_integrations`: Array of objects, each with:
- - `name`: The exact integration name from the docs (e.g., `zodErrorsIntegration`)
- - `summary`: A 1-2 sentence summary explaining what features this integration enables and why it is relevant to the project (e.g., which dependency triggers it)
- - `docs_url`: The full URL to the integration's documentation page on docs.sentry.io
-- `finish_reason`: A short snake_case string describing the outcome:
- - `{MissingSdkIntegrationFinishReason.SUCCESS}`: Successfully analyzed the project (even if no integrations are missing)
- - `{MissingSdkIntegrationFinishReason.MISSING_SENTRY_INIT}`: Could not find Sentry initialization code (`Sentry.init` or `sentry_sdk.init`)
- - `{MissingSdkIntegrationFinishReason.MISSING_DEPENDENCY_FILE}`: Could not find any dependency file for the project
- - `{MissingSdkIntegrationFinishReason.CODE_PATH_NOT_FOUND}`: Could not locate the project directory in the repository
- - For other issues, use a descriptive snake_case reason (e.g., `docs_unavailable`)
-
-Example success: `{{"missing_integrations": [{{"name": "zodErrorsIntegration", "summary": "Enable richer Zod validation errors in Sentry — since your project already uses the zod package, adding this integration gives you detailed schema context on every validation failure.", "docs_url": "https://docs.sentry.io/platforms/javascript/configuration/integrations/zod/"}}], "finish_reason": "{MissingSdkIntegrationFinishReason.SUCCESS}"}}`
-Example no missing: `{{"missing_integrations": [], "finish_reason": "{MissingSdkIntegrationFinishReason.SUCCESS}"}}`
-Example no init: `{{"missing_integrations": [], "finish_reason": "{MissingSdkIntegrationFinishReason.MISSING_SENTRY_INIT}"}}`"""
-
- try:
- run_id = client.start_run(
- prompt,
- artifact_key="missing_integrations",
- artifact_schema=MissingSdkIntegrationsResult,
- )
- with metrics.timer(
- "autopilot.missing_sdk_integration_detector.run_duration",
- tags={"project_slug": project.slug},
- sample_rate=1.0,
- ):
- state = client.get_run(run_id, blocking=True, poll_timeout=240.0, poll_interval=5.0)
-
- # Extract the structured result
- result = state.get_artifact("missing_integrations", MissingSdkIntegrationsResult)
- if result is None:
- _record_error(project.id, "no_artifact_result")
- logger.warning(
- "missing_sdk_integration_detector.no_artifact_result",
- extra={
- "organization_id": organization.id,
- "project_id": project.id,
- "run_id": run_id,
- },
- )
- return None
-
- missing_integrations = result.missing_integrations
- finish_reason = result.finish_reason
- integrations_count = len(missing_integrations)
-
- logger.warning(
- "missing_sdk_integration_detector.integrations_found",
- extra={
- "organization_id": organization.id,
- "project_id": project.id,
- "project_slug": project.slug,
- "platform": project.platform,
- "repo_name": repo_name,
- "run_id": run_id,
- "finish_reason": finish_reason,
- "integrations_count": integrations_count,
- },
- )
-
- if integrations_count > 0:
- metrics.incr(
- "autopilot.missing_sdk_integration_detector.integrations_found",
- sample_rate=1.0,
- )
-
- # Only create issues if the detection was successful
- if finish_reason == MissingSdkIntegrationFinishReason.SUCCESS:
- for integration in missing_integrations:
- description = f"{integration.summary}\n\nLearn more: {integration.docs_url}"
-
- logger.info(
- "missing_sdk_integration_detector.issue_would_be_created",
- extra={
- "project_id": project.id,
- "project_slug": project.slug,
- "integration": integration.name,
- "title": f"Missing SDK Integration: {integration.name}",
- "subtitle": integration.summary,
- "description": description,
- "repository_name": repo_name,
- "docs_url": integration.docs_url,
- },
- )
- metrics.incr(
- "autopilot.missing_sdk_integration_detector.issue_created",
- tags={"project_id": str(project.id), "integration": integration.name},
- sample_rate=1.0,
- )
- create_instrumentation_issue(
- project_id=project.id,
- detector_name=AutopilotDetectorName.MISSING_SDK_INTEGRATION,
- title=f"Missing SDK Integration: {integration.name}",
- subtitle=integration.summary,
- description=description,
- repository_name=repo_name,
- )
-
- return [i.name for i in missing_integrations]
-
- except Exception as e:
- _record_error(project.id, type(e).__name__)
- logger.exception(
- "autopilot.missing_sdk_integration_detector.error",
- extra={
- "organization_id": organization.id,
- "project_id": project.id,
- "project_slug": project.slug,
- },
- )
- return None
diff --git a/src/sentry/autopilot/tasks/sdk_update.py b/src/sentry/autopilot/tasks/sdk_update.py
deleted file mode 100644
index c8e466d1a5a399..00000000000000
--- a/src/sentry/autopilot/tasks/sdk_update.py
+++ /dev/null
@@ -1,152 +0,0 @@
-import logging
-from datetime import timedelta
-from itertools import chain, groupby
-from typing import Any
-
-from django.utils import timezone
-from packaging import version
-
-from sentry import options
-from sentry.api.utils import handle_query_errors
-from sentry.autopilot.tasks.common import AutopilotDetectorName, create_instrumentation_issue
-from sentry.models.organization import Organization
-from sentry.models.project import Project
-from sentry.sdk_updates import get_sdk_versions
-from sentry.search.events.types import SnubaParams
-from sentry.snuba import discover
-from sentry.tasks.base import instrumented_task
-from sentry.taskworker.namespaces import autopilot_tasks
-from sentry.utils import metrics
-
-logger = logging.getLogger(__name__)
-
-
-def strip_patch_version(sdk_version: str) -> str:
- return ".".join(sdk_version.split(".")[:2])
-
-
-@instrumented_task(
- name="sentry.autopilot.tasks.run_sdk_update_detector",
- namespace=autopilot_tasks,
- processing_deadline_duration=60,
-)
-def run_sdk_update_detector() -> None:
- organization_allowlist = options.get("autopilot.organization-allowlist")
- if not organization_allowlist:
- return
-
- organizations = Organization.objects.filter(slug__in=organization_allowlist)
-
- for organization in organizations:
- run_sdk_update_detector_for_organization(organization)
-
-
-def run_sdk_update_detector_for_organization(organization: Organization):
- projects = list(Project.objects.filter(organization=organization))
-
- if not projects:
- return
-
- metrics.incr("autopilot.sdk_update_detector.projects_found", len(projects))
-
- with handle_query_errors():
- result: Any = discover.query(
- query="has:sdk.version",
- selected_columns=[
- "project.id",
- "sdk.name",
- "sdk.version",
- "count()",
- ],
- orderby=["project.id", "sdk.name", "sdk.version"],
- limit=1000,
- snuba_params=SnubaParams(
- start=timezone.now() - timedelta(hours=1),
- end=timezone.now(),
- organization=organization,
- projects=projects,
- ),
- referrer="autopilot.sdk-update-detector",
- )
-
- # Filter out SDKs with empty sdk.name or sdk.version or invalid version
- nonempty_sdks = []
- for sdk in result["data"]:
- if not sdk["sdk.name"] or not sdk["sdk.version"]:
- continue
-
- try:
- version.parse(sdk["sdk.version"])
- except version.InvalidVersion:
- continue
-
- nonempty_sdks.append(sdk)
-
- # Sort by project.id to ensure groupby works correctly (groups consecutive elements)
- nonempty_sdks.sort(key=lambda x: x["project.id"])
-
- # Build datastructure of the latest version of each SDK in use for each
- # project we have events for.
- latest_sdks = list(
- chain.from_iterable(
- [
- {
- "projectId": str(project_id),
- "sdkName": sdk_name,
- "sdkVersion": max((s["sdk.version"] for s in sdks), key=version.parse),
- }
- for sdk_name, sdks in groupby(
- sorted(sdks_used, key=lambda x: x["sdk.name"]), key=lambda x: x["sdk.name"]
- )
- ]
- for project_id, sdks_used in groupby(nonempty_sdks, key=lambda x: x["project.id"])
- )
- )
-
- # Determine if each SDK needs an update for each project
- sdk_versions = get_sdk_versions()
-
- def needs_update(sdk_name, sdk_version):
- if sdk_name not in sdk_versions:
- # Unknown SDK, we can't determine if it needs an update
- return False
-
- # Ignore patch versions
- try:
- return version.Version(strip_patch_version(sdk_version)) < version.Version(
- strip_patch_version(sdk_versions.get(sdk_name))
- )
- except version.InvalidVersion:
- return False
-
- updates_list = [
- dict(
- **latest,
- newestSdkVersion=sdk_versions.get(latest["sdkName"]),
- needsUpdate=needs_update(latest["sdkName"], latest["sdkVersion"]),
- )
- for latest in latest_sdks
- ]
-
- updates_list = [update for update in updates_list if update["needsUpdate"]]
-
- logger.warning("updates_list: %s", updates_list)
- metrics.incr("autopilot.sdk_update_detector.updates_found", len(updates_list))
-
- for update in updates_list:
- project_id = int(update["projectId"])
- sdk_name = update["sdkName"]
- current_version = update["sdkVersion"]
- newest_version = update["newestSdkVersion"]
-
- create_instrumentation_issue(
- project_id=project_id,
- detector_name=AutopilotDetectorName.SDK_UPDATE,
- title=f"SDK Update Available: {sdk_name}",
- subtitle=f"Update from {current_version} to {newest_version}",
- description=f"A newer version of {sdk_name} is available. "
- f"Consider updating from version {current_version} to {newest_version} "
- f"to gain access to bug fixes, performance improvements, and new features.",
- )
-
- return updates_list
diff --git a/src/sentry/autopilot/tasks/trace_instrumentation.py b/src/sentry/autopilot/tasks/trace_instrumentation.py
deleted file mode 100644
index 16e06eebe1981a..00000000000000
--- a/src/sentry/autopilot/tasks/trace_instrumentation.py
+++ /dev/null
@@ -1,371 +0,0 @@
-import logging
-import textwrap
-from enum import StrEnum
-
-from pydantic import BaseModel, Field
-
-from sentry import options
-from sentry.autopilot.tasks.common import AutopilotDetectorName, create_instrumentation_issue
-from sentry.constants import ObjectStatus
-from sentry.models.organization import Organization
-from sentry.models.project import Project
-from sentry.seer.agent.client import SeerAgentClient
-from sentry.seer.agent.tools import get_trace_waterfall
-from sentry.seer.models import SeerPermissionError
-from sentry.seer.seer_setup import has_seer_access
-from sentry.tasks.base import instrumented_task
-from sentry.tasks.llm_issue_detection.detection import TraceMetadataWithSpanCount
-from sentry.tasks.llm_issue_detection.trace_data import (
- get_project_top_transaction_traces_for_llm_detection,
-)
-from sentry.taskworker.namespaces import autopilot_tasks
-from sentry.utils import json, metrics
-
-logger = logging.getLogger(__name__)
-
-
-class TraceInstrumentationFinishReason(StrEnum):
- SUCCESS = "success"
- ANALYSIS_ERROR = "analysis_error"
-
-
-class InstrumentationIssueCategory(StrEnum):
- """
- Known instrumentation issue categories.
- LLM may return custom categories not defined here.
- """
-
- MISSING_INSTRUMENTATION = "missing_instrumentation"
-
-
-class TraceInstrumentationIssue(BaseModel):
- """Schema for a trace instrumentation issue identified by Seer."""
-
- explanation: str = Field(..., max_length=2000)
- impact: str = Field(..., max_length=2000)
- evidence: str = Field(..., max_length=2000)
- offender_span_ids: list[str] = Field(default_factory=list, max_length=10)
- missing_telemetry: str | None = Field(None, max_length=2000)
- title: str = Field(..., max_length=200)
- category: str = Field(..., max_length=100)
- subcategory: str = Field(..., max_length=200)
-
-
-class TraceInstrumentationResult(BaseModel):
- """Result schema for trace instrumentation detection."""
-
- issues: list[TraceInstrumentationIssue]
- finish_reason: str
-
-
-def _build_instrumentation_prompt(trace_json: str, project_slug: str) -> str:
- """Build the prompt for trace instrumentation analysis.
-
- Args:
- trace_json: Pre-serialized JSON string of the trace data.
- project_slug: The project slug for context.
- """
- return textwrap.dedent(
- f"""\
- You analyze trace telemetry to identify instrumentation issues. Your default answer is: no issues exist.
-
- ### CONTEXT
- This is ONE trace from project {project_slug}. Most traces will have adequate instrumentation.
- Finding an issue should be the exception, not the rule.
- Focus on issues that would provide meaningful debugging value, not minor optimizations.
- A developer will investigate any issue you report. False positives waste their time and should never be reported.
-
- ### TELEMETRY
- ```
- {trace_json}
- ```
-
- ### DETECTION CRITERIA
- Your job is to identify genuine instrumentation issues that reduce debugging effectiveness or add unnecessary noise.
- Only report issues that are:
- - **Significant**: Would materially improve debugging, performance analysis, or observability
- - **Concrete**: Backed by specific evidence from the trace (span IDs, patterns, anomalies)
- - **Actionable**: You can describe what instrumentation change to make
- - **Fixable**: Issue can be resolved by modifying instrumentation in this service
-
- ### TYPES OF INSTRUMENTATION ISSUES TO DETECT:
-
- 1. **Missing Spans**:
- - Database operations without `db.*` spans
- - HTTP requests without `http.*` or `request.*` spans
- - Cache operations without `cache.*` spans
- - Message queue operations without `messaging.*` spans
- - Orphaned spans (parent_span_id references non-existent span)
- - Large time gaps between child spans suggesting uninstrumented work
-
- 2. **Other Issues** (identify if you observe them):
- - Any other instrumentation problem that reduces observability
- - Use your judgment to categorize and describe
-
- ### MANDATORY REJECTION — Do NOT report if ANY apply:
- 1. **Speculative**: Based on assumptions rather than concrete trace evidence
- 2. **Minor**: Issue would provide minimal debugging value
- 3. **Unproven**: Cannot cite specific span IDs or patterns proving the issue
- 4. **Unfixable**: Cannot describe what instrumentation change to make
- 5. **Expected**: Normal trace characteristics for this platform or SDK version
- 6. **Downstream**: Issue is in external services or dependencies (not fixable in this codebase)
-
- ### BEFORE REPORTING
- For each issue you intend to report, verify it does not match ANY mandatory rejection criteria. If it does, discard it.
-
- ### OUTPUT FORMAT
- For each issue provide:
- - **explanation**: Detailed analysis of the issue (max 40 words)
- - **impact**: How this affects debugging/observability with specifics (max 25 words)
- - **evidence**: Cite specific span IDs, patterns, or anomalies proving the issue (max 30 words)
- - **offender_span_ids**: List the specific span IDs demonstrating the issue (max 10)
- - **missing_telemetry**: Description of what instrumentation should be added (max 20 words). Applicable for missing span issues.
- - **title**: Canonical issue category (max 10 words)
- - **category**: Short snake_case identifier for the issue type (max 5 words). Use `"{InstrumentationIssueCategory.MISSING_INSTRUMENTATION}"` for missing span issues. For other issue types, create a descriptive snake_case category (e.g., "span_quality", "context_propagation").
- - **subcategory**: Human-readable description of the specific pattern (max 5 words). Examples: "Missing Database Spans", "Generic Descriptions", "Orphaned Spans"
-
- ### TITLE GUIDELINES
- Return a canonical title representing the issue pattern, not the specific instance.
- Title = Issue Type, optionally with Operation Category if meaningful.
- Strip away: counts, locations, business context.
- Two issues with the same root cause must return identical titles.
-
- **Examples:**
- - "Database calls not instrumented", "DB queries missing spans" → "Missing Database Instrumentation"
- - "HTTP requests without spans", "External API calls not tracked" → "Missing HTTP Instrumentation"
- - "Orphaned span in checkout flow", "Parent span not found" → "Orphaned Spans"
-
- ### CATEGORY GUIDELINES
- **category**: Short snake_case identifier for the issue type.
- Use `"{InstrumentationIssueCategory.MISSING_INSTRUMENTATION}"` for missing span issues.
- For other issue types, create a descriptive snake_case category (e.g., "span_quality", "context_propagation").
-
- **subcategory**: More specific pattern within the category (e.g., "missing_database_spans", "generic_descriptions").
-
- ### FINISH REASON
- Return one of:
- - "{TraceInstrumentationFinishReason.SUCCESS}": Analysis completed successfully (use this whether issues were found or not)
- - "{TraceInstrumentationFinishReason.ANALYSIS_ERROR}": Cannot analyze due to invalid or malformed trace data
-
- Always return "{TraceInstrumentationFinishReason.SUCCESS}" when you complete analysis, with issues as an empty list if no issues were found.
- """
- )
-
-
-def sample_trace_for_instrumentation_analysis(
- project: Project,
-) -> TraceMetadataWithSpanCount | None:
- """
- Sample ONE trace for instrumentation analysis.
- Uses top transaction sampling with random time offset.
- Returns the top transaction's first trace with valid span count (20-500).
- """
- traces = get_project_top_transaction_traces_for_llm_detection(
- project_id=project.id,
- limit=1, # Only need 1 trace
- start_time_delta_minutes=24 * 60, # 24 hours
- )
- return traces[0] if traces else None
-
-
-@instrumented_task(
- name="sentry.autopilot.tasks.run_trace_instrumentation_detector",
- namespace=autopilot_tasks,
- processing_deadline_duration=60,
-)
-def run_trace_instrumentation_detector() -> None:
- """Main scheduled task that coordinates trace instrumentation detection across projects."""
- project_ids = options.get("autopilot.trace-instrumentation.projects-allowlist")
- if not project_ids:
- return
-
- for project_id in project_ids:
- try:
- project = Project.objects.select_related("organization").get(
- id=project_id, status=ObjectStatus.ACTIVE
- )
- except Project.DoesNotExist:
- logger.warning(
- "trace_instrumentation_detector.project_not_found",
- extra={"project_id": project_id},
- )
- continue
-
- if not has_seer_access(project.organization):
- logger.info(
- "trace_instrumentation_detector.no_gen_ai_access",
- extra={"project_id": project_id, "organization_id": project.organization_id},
- )
- continue
-
- run_trace_instrumentation_detector_for_project_task.apply_async(
- args=(project.organization_id, project.id),
- headers={"sentry-propagate-traces": False},
- )
-
-
-@instrumented_task(
- name="sentry.autopilot.tasks.run_trace_instrumentation_detector_for_project_task",
- namespace=autopilot_tasks,
- processing_deadline_duration=300,
-)
-def run_trace_instrumentation_detector_for_project_task(
- organization_id: int, project_id: int
-) -> list[TraceInstrumentationIssue] | None:
- """
- Analyze ONE trace from a project to identify instrumentation issues using SeerAgentClient.
-
- Returns:
- List of instrumentation issues found, or None if detection failed.
- """
- try:
- organization = Organization.objects.get(id=organization_id)
- project = Project.objects.get(id=project_id, status=ObjectStatus.ACTIVE)
- except (Organization.DoesNotExist, Project.DoesNotExist):
- logger.exception(
- "trace_instrumentation_detector.entity_not_found",
- extra={"organization_id": organization_id, "project_id": project_id},
- )
- return None
-
- trace_metadata = sample_trace_for_instrumentation_analysis(project)
- if not trace_metadata:
- logger.warning(
- "trace_instrumentation_detector.no_trace_sampled",
- extra={"organization_id": organization.id, "project_id": project.id},
- )
- return None
-
- trace_id = trace_metadata.trace_id
-
- try:
- eap_trace = get_trace_waterfall(trace_id=trace_id, organization_id=organization.id)
- except Exception:
- logger.exception(
- "trace_instrumentation_detector.trace_query_failed",
- extra={
- "organization_id": organization.id,
- "project_id": project.id,
- "trace_id": trace_id,
- },
- )
- return None
-
- if not eap_trace or not eap_trace.trace:
- logger.warning(
- "trace_instrumentation_detector.empty_trace_data",
- extra={
- "organization_id": organization.id,
- "project_id": project.id,
- "trace_id": trace_id,
- },
- )
- return None
-
- try:
- client = SeerAgentClient(
- organization,
- user=None,
- category_key=AutopilotDetectorName.TRACE_INSTRUMENTATION,
- category_value=str(trace_id),
- intelligence_level="medium",
- )
- except SeerPermissionError:
- logger.warning(
- "trace_instrumentation_detector.no_seer_access",
- extra={"organization_id": organization.id, "project_id": project.id},
- )
- return None
-
- # Check trace size to avoid exceeding LLM context limits
- trace_json = json.dumps(eap_trace.trace)
- if len(trace_json) > 100_000:
- logger.warning(
- "trace_instrumentation_detector.trace_too_large",
- extra={
- "organization_id": organization.id,
- "project_id": project.id,
- "trace_id": trace_id,
- "trace_size_bytes": len(trace_json),
- },
- )
- return None
-
- prompt = _build_instrumentation_prompt(trace_json, project.slug)
-
- try:
- with metrics.timer(
- "autopilot.trace_instrumentation_detector.run_duration",
- tags={"project_slug": project.slug},
- sample_rate=1.0,
- ):
- run_id = client.start_run(
- prompt,
- artifact_key="issues",
- artifact_schema=TraceInstrumentationResult,
- )
- state = client.get_run(run_id, blocking=True, poll_timeout=240.0, poll_interval=5.0)
-
- result = state.get_artifact("issues", TraceInstrumentationResult)
- if result is None:
- logger.warning(
- "trace_instrumentation_detector.no_artifact_result",
- extra={
- "organization_id": organization.id,
- "project_id": project.id,
- "trace_id": trace_id,
- "run_id": run_id,
- },
- )
- return None
-
- issues = result.issues
- finish_reason = result.finish_reason
-
- logger.warning(
- "trace_instrumentation_detector.analysis_complete",
- extra={
- "organization_id": organization.id,
- "project_id": project.id,
- "project_slug": project.slug,
- "trace_id": trace_id,
- "run_id": run_id,
- "finish_reason": finish_reason,
- "issue_count": len(issues),
- "issue_categories": [issue.category for issue in issues],
- },
- )
-
- if finish_reason == TraceInstrumentationFinishReason.SUCCESS:
- seen_titles: set[str] = set()
- for issue in issues:
- # Dedupe by title to avoid creating redundant issues
- if issue.title in seen_titles:
- continue
- seen_titles.add(issue.title)
-
- create_instrumentation_issue(
- project_id=project.id,
- detector_name=AutopilotDetectorName.TRACE_INSTRUMENTATION,
- title=issue.title,
- subtitle=issue.subcategory,
- description=f"{issue.explanation}\n\n"
- f"**Impact**: {issue.impact}\n\n"
- f"**Evidence**: {issue.evidence}\n\n"
- f"**Missing Telemetry**: {issue.missing_telemetry or 'Not specified'}\n\n"
- f"**Affected Spans**: {', '.join(issue.offender_span_ids)}",
- )
-
- return issues
-
- except Exception:
- logger.exception(
- "autopilot.trace_instrumentation_detector.error",
- extra={
- "organization_id": organization.id,
- "project_id": project.id,
- "project_slug": project.slug,
- "trace_id": trace_id,
- },
- )
- return None
diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py
index 391f4e3b91f0ea..4d749efeeab79d 100644
--- a/src/sentry/conf/server.py
+++ b/src/sentry/conf/server.py
@@ -163,6 +163,8 @@ def env(
"ff00::/8",
)
+SENTRY_ALLOWED_IPS: tuple[str, ...] = ()
+
# When resolving DNS for external sources (source map fetching, webhooks, etc),
# ensure that domains are fully resolved first to avoid poking internal
# search domains.
@@ -458,7 +460,6 @@ def env(
"crispy_forms",
"rest_framework",
"sentry",
- "sentry.autopilot",
"sentry.analytics",
"sentry.auth_v2",
"sentry.incidents.apps.Config",
@@ -865,9 +866,6 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
# accessible to the worker.
# This list includes all tasks even if they are imported transitively by other modules.
TASKWORKER_IMPORTS: tuple[str, ...] = (
- "sentry.autopilot.tasks.missing_sdk_integration",
- "sentry.autopilot.tasks.sdk_update",
- "sentry.autopilot.tasks.trace_instrumentation",
"sentry.conduit.tasks",
"sentry.data_export.tasks",
"sentry.debug_files.tasks",
diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py
index d87426452b771b..b07b19671fe7db 100644
--- a/src/sentry/features/temporary.py
+++ b/src/sentry/features/temporary.py
@@ -311,8 +311,6 @@ def register_temporary_features(manager: FeatureManager) -> None:
# Enable search query builder raw search replacement
manager.add("organizations:search-query-builder-raw-search-replacement", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
manager.add("organizations:seer-agent-pr-consolidation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
- # Enables Seer Autopilot
- manager.add("organizations:seer-autopilot", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Disables the enableSeerCoding setting, preventing orgs from changing code generation behavior
manager.add("organizations:seer-disable-coding-setting", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable GitLab as a supported SCM provider for Seer
diff --git a/src/sentry/issues/grouptype.py b/src/sentry/issues/grouptype.py
index abcc97554c2580..3efd96345895f2 100644
--- a/src/sentry/issues/grouptype.py
+++ b/src/sentry/issues/grouptype.py
@@ -76,12 +76,6 @@ class GroupCategory(IntEnum):
"""
PREPROD = 17
- """
- Issues detected by autopilot instrumentation analysis suggesting
- improvements to product usage and observability coverage.
- """
- INSTRUMENTATION = 18
-
"""
Issues detected from SDK/tooling configuration problems,
such as missing or broken source maps.
diff --git a/src/sentry/net/socket.py b/src/sentry/net/socket.py
index dda31279cc1b94..d8cde649acecc4 100644
--- a/src/sentry/net/socket.py
+++ b/src/sentry/net/socket.py
@@ -22,16 +22,24 @@
ipaddress.ip_network(str(i), strict=False) for i in settings.SENTRY_DISALLOWED_IPS
)
+ALLOWED_IPS = frozenset(
+ ipaddress.ip_network(str(i), strict=False) for i in settings.SENTRY_ALLOWED_IPS
+)
+
@functools.lru_cache(maxsize=100)
def is_ipaddress_allowed(ip: str) -> bool:
"""
Test if a given IP address is allowed or not
- based on the DISALLOWED_IPS rules.
+ based on the DISALLOWED_IPS AND ALLOWED_IPS rules.
"""
if not DISALLOWED_IPS:
return True
ip_address = ipaddress.ip_address(force_str(ip, strings_only=True))
+ for ip_network in ALLOWED_IPS:
+ if ip_address in ip_network:
+ return True
+
for ip_network in DISALLOWED_IPS:
if ip_address in ip_network:
return False
diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py
index 751540fe9f78cf..080744a8fd012e 100644
--- a/src/sentry/options/defaults.py
+++ b/src/sentry/options/defaults.py
@@ -4140,30 +4140,6 @@
flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE,
)
-# Organization slug allowlist to enable Autopilot for specific organizations.
-register(
- "autopilot.organization-allowlist",
- type=Sequence,
- default=[],
- flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE,
-)
-
-# Project ID allowlist to enable missing SDK integration detector for specific projects.
-register(
- "autopilot.missing-sdk-integration.projects-allowlist",
- type=Sequence,
- default=[],
- flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE,
-)
-
-# Project ID allowlist to enable trace instrumentation detector for specific projects.
-register(
- "autopilot.trace-instrumentation.projects-allowlist",
- type=Sequence,
- default=[],
- flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE,
-)
-
# Global flag to enable API token async flush
register(
"api-token-async-flush",
diff --git a/src/sentry/search/snuba/executors.py b/src/sentry/search/snuba/executors.py
index 72262c063f2121..79fd2c2931526f 100644
--- a/src/sentry/search/snuba/executors.py
+++ b/src/sentry/search/snuba/executors.py
@@ -163,7 +163,6 @@ def group_categories_from_search_filters(search_filters: Sequence[SearchFilter])
group_categories = set(get_search_strategies().keys())
# Hide certain categories from the default issue stream
group_categories.discard(GroupCategory.FEEDBACK.value)
- group_categories.discard(GroupCategory.INSTRUMENTATION.value)
group_categories.discard(GroupCategory.CONFIGURATION.value)
return group_categories
diff --git a/src/sentry/seer/autofix/introspection.py b/src/sentry/seer/autofix/introspection.py
index 268e5b47185fe0..685638d61d0172 100644
--- a/src/sentry/seer/autofix/introspection.py
+++ b/src/sentry/seer/autofix/introspection.py
@@ -6,11 +6,12 @@
from sentry.models.group import Group
from sentry.models.organization import Organization
-from sentry.seer.agent.client import SeerAgentClient
from sentry.seer.agent.client_models import SeerRunState
from sentry.seer.autofix.artifact_schemas import RootCauseArtifact, SolutionArtifact
from sentry.seer.autofix.autofix_agent import AutofixStep
-from sentry.utils import metrics
+from sentry.seer.models.seer_api_models import SeerApiError
+from sentry.seer.signed_seer_api import LlmGenerateRequest, make_llm_generate_request
+from sentry.utils import json, metrics
logger = logging.getLogger(__name__)
@@ -98,33 +99,50 @@ def _format_event_section(event_details: str | None) -> str:
""")
+INTROSPECTION_SYSTEM_PROMPT = "You are a quality gate evaluating autofix outputs. Respond with JSON matching the requested schema."
+
+INTROSPECTION_RESPONSE_SCHEMA: dict[str, object] = {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["continue", "needs_more_context", "redo", "not_actionable"],
+ },
+ "reason": {"type": "string"},
+ },
+ "required": ["action", "reason"],
+}
+
+
def _run_introspection(
- organization: Organization,
run_id: int,
step: AutofixStep,
prompt: str,
- artifact_key: str,
) -> IntrospectionDecision | None:
- client = SeerAgentClient(
- organization,
- user=None,
- category_key=f"autofix.introspection.{step.value}",
- category_value=str(run_id),
- intelligence_level="medium",
- reasoning_effort="low",
- )
- introspection_run_id = client.start_run(
+ body = LlmGenerateRequest(
+ provider="gemini",
+ model="flash-lite",
+ referrer=f"sentry.autofix.introspection.{step.value}",
prompt=prompt,
- artifact_key=artifact_key,
- artifact_schema=IntrospectionDecision,
+ system_prompt=INTROSPECTION_SYSTEM_PROMPT,
+ temperature=0.0,
+ max_tokens=500,
+ response_schema=INTROSPECTION_RESPONSE_SCHEMA,
+ reasoning="low",
)
with metrics.timer("autofix.introspection", tags={"step": step.value}):
- state = client.get_run(
- introspection_run_id,
- blocking=True,
- poll_timeout=30, # only poll for 30s hopefully this is enough
- )
- return state.get_artifact(artifact_key, IntrospectionDecision)
+ response = make_llm_generate_request(body, timeout=30)
+ if response.status >= 400:
+ raise SeerApiError("Seer introspection request failed", response.status)
+ data = response.json()
+ content = data.get("content")
+ if not content:
+ logger.warning(
+ "autofix.introspection.empty_response",
+ extra={"run_id": run_id, "step": step.value},
+ )
+ return None
+ return IntrospectionDecision.parse_obj(json.loads(content))
def _root_cause_introspection_prompt(
@@ -145,7 +163,6 @@ def _root_cause_introspection_prompt(
## Your Task
Compare the root cause analysis against the issue and event evidence above. Decide whether the analysis is accurate.
- Write your decision to the `artifact_write_introspection_decision_root_cause` tool.
Choose one action:
@@ -192,11 +209,9 @@ def introspect_root_cause(
)
return _run_introspection(
- organization,
run_id,
AutofixStep.ROOT_CAUSE,
prompt,
- "introspection_decision_root_cause",
)
except Exception:
logger.exception(
@@ -234,7 +249,6 @@ def _solution_introspection_prompt(
## Your Task
Evaluate whether this solution plan addresses the root cause and is specific enough for a coding agent to implement.
- Write your decision to the `artifact_write_introspection_decision_solution` tool.
Choose one action:
@@ -295,11 +309,9 @@ def introspect_solution(
)
return _run_introspection(
- organization,
run_id,
AutofixStep.SOLUTION,
prompt,
- "introspection_decision_solution",
)
except Exception:
logger.exception(
@@ -339,7 +351,6 @@ def _code_changes_introspection_prompt(
## Your Task
Evaluate whether the code changes correctly implement the solution plan and address the root cause.
- Write your decision to the `artifact_write_introspection_decision_code_changes` tool.
Choose one action:
@@ -416,11 +427,9 @@ def introspect_code_changes(
)
return _run_introspection(
- organization,
run_id,
AutofixStep.CODE_CHANGES,
prompt,
- "introspection_decision_code_changes",
)
except Exception:
logger.exception(
diff --git a/src/sentry/snuba/referrer.py b/src/sentry/snuba/referrer.py
index da8fa461878e18..af7c2a2a424de8 100644
--- a/src/sentry/snuba/referrer.py
+++ b/src/sentry/snuba/referrer.py
@@ -684,9 +684,6 @@ class Referrer(StrEnum):
INSIGHTS_MOBILE_HAS_TTFDCONFIGURED = "insights.mobile.hasTTFDConfigured"
INSIGHTS_TIME_SPENT_TOTAL_TIME = "insights.time_spent.total_time"
- # TODO(telex-team): temporary referrer, remove once low value spans job is no longer needed
- LOW_VALUE_SPANS_JOB = "autopilot.low_value_spans_job"
-
LOW_VALUE_TELEMETRY_DETECTOR = "configuration.low_value_telemetry_detector"
METRIC_EXTRACTION_CARDINALITY_CHECK = "metric_extraction.cardinality_check"
diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py
index aba5035b5d4891..1c212d204cfe1d 100644
--- a/src/sentry/tasks/post_process.py
+++ b/src/sentry/tasks/post_process.py
@@ -1613,11 +1613,6 @@ def kick_off_lightweight_rca_cluster(job: PostProcessJob) -> None:
feedback_filter_decorator(process_workflow_engine),
feedback_filter_decorator(process_resource_change_bounds),
],
- GroupCategory.INSTRUMENTATION: [
- process_snoozes,
- process_inbox_adds,
- kick_off_seer_automation,
- ],
}
GENERIC_POST_PROCESS_PIPELINE: list[Callable[[PostProcessJob], None]] = [
diff --git a/src/sentry/taskworker/namespaces.py b/src/sentry/taskworker/namespaces.py
index 31502074bcae92..374dfdf7cdcb84 100644
--- a/src/sentry/taskworker/namespaces.py
+++ b/src/sentry/taskworker/namespaces.py
@@ -23,11 +23,6 @@
app_feature="shared",
)
-autopilot_tasks = app.taskregistry.create_namespace(
- "autopilot",
- app_feature="shared",
-)
-
buffer_tasks = app.taskregistry.create_namespace(
"buffer",
app_feature="errors",
diff --git a/src/sentry/testutils/helpers/socket.py b/src/sentry/testutils/helpers/socket.py
index fab35d564ecb30..2c44c96a526e10 100644
--- a/src/sentry/testutils/helpers/socket.py
+++ b/src/sentry/testutils/helpers/socket.py
@@ -7,7 +7,7 @@
from sentry.net import socket as net_socket
-__all__ = ["override_blocklist"]
+__all__ = ["override_blocklist", "override_allowlist"]
@contextlib.contextmanager
@@ -23,3 +23,18 @@ def override_blocklist(*ip_addresses: str) -> Generator[None]:
# We end up caching these disallowed ips on this function, so
# make sure we clear the cache as part of cleanup
net_socket.is_ipaddress_allowed.cache_clear()
+
+
+@contextlib.contextmanager
+def override_allowlist(*ip_addresses: str) -> Generator[None]:
+ with mock.patch.object(
+ net_socket,
+ "ALLOWED_IPS",
+ frozenset(ipaddress.ip_network(ip) for ip in ip_addresses),
+ ):
+ try:
+ yield
+ finally:
+ # We end up caching these disallowed ips on this function, so
+ # make sure we clear the cache as part of cleanup
+ net_socket.is_ipaddress_allowed.cache_clear()
diff --git a/static/app/components/core/badge/featureBadge.figma.tsx b/static/app/components/core/badge/featureBadge.figma.tsx
index d65120e931e18b..d386f13f365e30 100644
--- a/static/app/components/core/badge/featureBadge.figma.tsx
+++ b/static/app/components/core/badge/featureBadge.figma.tsx
@@ -20,6 +20,7 @@ figma.connect(
beta: 'beta',
new: 'new',
experimental: 'experimental',
+ debug: 'debug',
}),
},
example: props => ,
diff --git a/static/app/components/core/badge/featureBadge.tsx b/static/app/components/core/badge/featureBadge.tsx
index dc087f168a4f29..d8530c5756cb79 100644
--- a/static/app/components/core/badge/featureBadge.tsx
+++ b/static/app/components/core/badge/featureBadge.tsx
@@ -3,6 +3,7 @@ import styled from '@emotion/styled';
import {Tooltip, type TooltipProps} from '@sentry/scraps/tooltip';
import {IconBroadcast} from 'sentry/icons/iconBroadcast';
+import {IconBug} from 'sentry/icons/iconBug';
import {IconLab} from 'sentry/icons/iconLab';
import {t} from 'sentry/locale';
import type {TagVariant} from 'sentry/utils/theme';
@@ -16,6 +17,7 @@ const defaultTitles: Record = {
experimental: t(
'This feature is experimental! Try it out and let us know what you think. No promises!'
),
+ debug: t('This UI is for debugging purposes only'),
};
const variantMap: Record = {
@@ -23,6 +25,7 @@ const variantMap: Record = {
beta: 'warning',
new: 'success',
experimental: 'muted',
+ debug: 'danger',
};
const iconMap: Record = {
@@ -30,10 +33,11 @@ const iconMap: Record = {
beta: ,
new: ,
experimental: ,
+ debug: ,
};
export interface FeatureBadgeProps extends Omit {
- type: 'alpha' | 'beta' | 'new' | 'experimental';
+ type: 'alpha' | 'beta' | 'new' | 'experimental' | 'debug';
tooltipProps?: Partial;
}
diff --git a/static/app/components/pipeline/pipelineIntegrationPerforce.spec.tsx b/static/app/components/pipeline/pipelineIntegrationPerforce.spec.tsx
index 5d970dcaa3ca32..eabeccf709e695 100644
--- a/static/app/components/pipeline/pipelineIntegrationPerforce.spec.tsx
+++ b/static/app/components/pipeline/pipelineIntegrationPerforce.spec.tsx
@@ -48,6 +48,7 @@ describe('PerforceInstallationConfigStep', () => {
client: undefined,
sslFingerprint: 'AB:CD:EF',
webUrl: undefined,
+ charset: 'none',
});
});
});
@@ -110,6 +111,7 @@ describe('PerforceInstallationConfigStep', () => {
client: 'my-workspace',
sslFingerprint: 'AB:CD:EF',
webUrl: 'https://swarm.example.com',
+ charset: 'none',
});
});
});
diff --git a/static/app/components/pipeline/pipelineIntegrationPerforce.tsx b/static/app/components/pipeline/pipelineIntegrationPerforce.tsx
index e6907587e0b6a3..2fac86a7bf5a91 100644
--- a/static/app/components/pipeline/pipelineIntegrationPerforce.tsx
+++ b/static/app/components/pipeline/pipelineIntegrationPerforce.tsx
@@ -18,6 +18,7 @@ const AUTH_TYPE_CHOICES = [
interface InstallationConfigAdvanceData {
authType: string;
+ charset: string;
p4port: string;
password: string;
user: string;
@@ -35,6 +36,7 @@ const installationConfigSchema = z
client: z.string(),
sslFingerprint: z.string(),
webUrl: z.string(),
+ unicodeServer: z.boolean(),
})
.superRefine((data, ctx) => {
if (data.p4port.startsWith('ssl:') && !data.sslFingerprint) {
@@ -62,6 +64,7 @@ function PerforceInstallationConfigStep({
client: '',
sslFingerprint: '',
webUrl: '',
+ unicodeServer: false,
},
validators: {onDynamic: installationConfigSchema},
onSubmit: ({value}) => {
@@ -73,6 +76,9 @@ function PerforceInstallationConfigStep({
client: value.client || undefined,
sslFingerprint: value.sslFingerprint || undefined,
webUrl: value.webUrl || undefined,
+ // Backend stores charset as a string enum (Charset.NONE / Charset.UTF8)
+ // so it can grow to other encodings without an API contract change.
+ charset: value.unicodeServer ? 'utf8' : 'none',
});
},
});
@@ -130,6 +136,18 @@ function PerforceInstallationConfigStep({
)}
+
+ {field => (
+
+
+
+ )}
+
{field => (
diff --git a/static/gsApp/views/seerAutomation/components/projectDetails/nightShift.tsx b/static/gsApp/views/seerAutomation/components/projectDetails/nightShift.tsx
index 152e062b6b73f4..9d39af94e54257 100644
--- a/static/gsApp/views/seerAutomation/components/projectDetails/nightShift.tsx
+++ b/static/gsApp/views/seerAutomation/components/projectDetails/nightShift.tsx
@@ -104,7 +104,7 @@ export function NightShift({canWrite, project}: Props) {
title={
{t('Manually trigger night shift')}
-
+
}
>
diff --git a/tests/sentry/api/endpoints/test_system_options.py b/tests/sentry/api/endpoints/test_system_options.py
index 6e922cfa12d778..9df88b85fe912d 100644
--- a/tests/sentry/api/endpoints/test_system_options.py
+++ b/tests/sentry/api/endpoints/test_system_options.py
@@ -42,6 +42,30 @@ def test_required(self) -> None:
assert response.status_code == 200
assert "system.url-prefix" in response.data
+ def test_required_disables_mail_tls_ssl_pair_when_one_disk_value_is_non_default(self) -> None:
+ self.login_as(user=self.user, superuser=True)
+
+ with override_options({"mail.use-tls": True, "mail.use-ssl": False}):
+ response = self.client.get(self.url, {"query": "is:required"})
+
+ assert response.status_code == 200
+ assert response.data["mail.use-tls"]["field"]["disabled"] is True
+ assert response.data["mail.use-tls"]["field"]["disabledReason"] == "diskPriority"
+ assert response.data["mail.use-ssl"]["field"]["disabled"] is True
+ assert response.data["mail.use-ssl"]["field"]["disabledReason"] == "diskPriority"
+
+ def test_required_keeps_mail_tls_ssl_pair_enabled_when_disk_values_match_defaults(self) -> None:
+ self.login_as(user=self.user, superuser=True)
+
+ with override_options({"mail.use-tls": False, "mail.use-ssl": False}):
+ response = self.client.get(self.url, {"query": "is:required"})
+
+ assert response.status_code == 200
+ assert response.data["mail.use-tls"]["field"]["disabled"] is False
+ assert response.data["mail.use-tls"]["field"]["disabledReason"] is None
+ assert response.data["mail.use-ssl"]["field"]["disabled"] is False
+ assert response.data["mail.use-ssl"]["field"]["disabledReason"] is None
+
def test_not_logged_in(self) -> None:
response = self.client.get(self.url)
assert response.status_code == 401
@@ -62,6 +86,10 @@ def test_disabled_smtp(self) -> None:
assert response.status_code == 200
assert response.data["mail.host"]["field"]["disabled"] is True
assert response.data["mail.host"]["field"]["disabledReason"] == "smtpDisabled"
+ assert response.data["mail.use-tls"]["field"]["disabled"] is True
+ assert response.data["mail.use-tls"]["field"]["disabledReason"] == "smtpDisabled"
+ assert response.data["mail.use-ssl"]["field"]["disabled"] is True
+ assert response.data["mail.use-ssl"]["field"]["disabledReason"] == "smtpDisabled"
def test_put_user_access_forbidden(self) -> None:
self.login_as(user=self.user, superuser=False)
diff --git a/tests/sentry/autopilot/__init__.py b/tests/sentry/autopilot/__init__.py
deleted file mode 100644
index e69de29bb2d1d6..00000000000000
diff --git a/tests/sentry/autopilot/tasks/__init__.py b/tests/sentry/autopilot/tasks/__init__.py
deleted file mode 100644
index e69de29bb2d1d6..00000000000000
diff --git a/tests/sentry/autopilot/tasks/test_missing_sdk_integration.py b/tests/sentry/autopilot/tasks/test_missing_sdk_integration.py
deleted file mode 100644
index 38925c34c938da..00000000000000
--- a/tests/sentry/autopilot/tasks/test_missing_sdk_integration.py
+++ /dev/null
@@ -1,583 +0,0 @@
-from unittest import mock
-
-import pytest
-
-from sentry.autopilot.tasks.common import AutopilotDetectorName
-from sentry.autopilot.tasks.missing_sdk_integration import (
- MissingSdkIntegrationDetail,
- MissingSdkIntegrationFinishReason,
- MissingSdkIntegrationsResult,
- run_missing_sdk_integration_detector,
- run_missing_sdk_integration_detector_for_project_task,
-)
-from sentry.constants import ObjectStatus
-from sentry.seer.models import SeerPermissionError
-from sentry.testutils.cases import TestCase
-from sentry.testutils.helpers.options import override_options
-
-
-class TestRunMissingSdkIntegrationDetector(TestCase):
- def setUp(self) -> None:
- super().setUp()
- self.integration = self.create_integration(
- organization=self.organization,
- external_id="12345",
- provider="github",
- )
-
- def _create_code_mapping(
- self,
- project,
- repo_name: str,
- provider: str = "integrations:github",
- stack_root: str = "",
- ):
- """Helper to create a repository with code mapping."""
- repo = self.create_repo(
- project=project,
- name=repo_name,
- provider=provider,
- integration_id=self.integration.id,
- )
- return self.create_code_mapping(
- project=project,
- repo=repo,
- stack_root=stack_root,
- source_root="",
- )
-
- @pytest.mark.django_db
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_returns_early_when_allowlist_empty(self, mock_apply_async: mock.MagicMock) -> None:
- with override_options({"autopilot.missing-sdk-integration.projects-allowlist": []}):
- run_missing_sdk_integration_detector()
- assert not mock_apply_async.called
-
- @pytest.mark.django_db
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_skips_nonexistent_project(self, mock_apply_async: mock.MagicMock) -> None:
- with override_options({"autopilot.missing-sdk-integration.projects-allowlist": [999999]}):
- run_missing_sdk_integration_detector()
- assert not mock_apply_async.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.has_seer_access", return_value=True)
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_skips_unsupported_platform(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- # Set an unsupported platform
- self.project.platform = "go"
- self.project.save()
- self._create_code_mapping(self.project, "test-repo")
-
- with override_options(
- {"autopilot.missing-sdk-integration.projects-allowlist": [self.project.id]}
- ):
- run_missing_sdk_integration_detector()
-
- # No task should be spawned for unsupported platforms
- assert not mock_apply_async.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.has_seer_access", return_value=True)
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_skips_project_without_repository_mapping(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- self.project.platform = "python"
- self.project.save()
-
- with override_options(
- {"autopilot.missing-sdk-integration.projects-allowlist": [self.project.id]}
- ):
- run_missing_sdk_integration_detector()
-
- # No task should be spawned for projects without code mappings
- assert not mock_apply_async.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.has_seer_access", return_value=True)
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_skips_inactive_repositories(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- self.project.platform = "python"
- self.project.save()
- # Create a code mapping with an inactive repository
- code_mapping = self._create_code_mapping(self.project, "inactive-repo")
- code_mapping.project_repository.repository.status = ObjectStatus.PENDING_DELETION
- code_mapping.project_repository.repository.save()
-
- with override_options(
- {"autopilot.missing-sdk-integration.projects-allowlist": [self.project.id]}
- ):
- run_missing_sdk_integration_detector()
-
- # No task should be spawned since repo is inactive
- assert not mock_apply_async.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.has_seer_access", return_value=True)
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_spawns_task_for_project_with_mapping(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- self.project.platform = "python"
- self.project.save()
- self._create_code_mapping(self.project, "test-repo")
-
- with override_options(
- {"autopilot.missing-sdk-integration.projects-allowlist": [self.project.id]}
- ):
- run_missing_sdk_integration_detector()
-
- # Task should be spawned with correct arguments
- mock_apply_async.assert_called_once_with(
- args=(self.organization.id, self.project.id, "test-repo"),
- headers={"sentry-propagate-traces": False},
- )
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.has_seer_access", return_value=True)
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_only_processes_supported_platforms(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- # Create projects with supported platforms
- python_project = self.create_project(organization=self.organization, platform="python")
- node_project = self.create_project(organization=self.organization, platform="node")
- js_project = self.create_project(organization=self.organization, platform="javascript")
- js_react_project = self.create_project(
- organization=self.organization, platform="javascript-react"
- )
- python_django_project = self.create_project(
- organization=self.organization, platform="python-django"
- )
- node_express_project = self.create_project(
- organization=self.organization, platform="node-express"
- )
-
- # Create projects with unsupported platforms
- go_project = self.create_project(organization=self.organization, platform="go")
- ruby_project = self.create_project(organization=self.organization, platform="ruby")
- java_project = self.create_project(organization=self.organization, platform="java")
-
- # Create code mappings for all projects
- self._create_code_mapping(python_project, "python-repo")
- self._create_code_mapping(node_project, "node-repo")
- self._create_code_mapping(js_project, "js-repo")
- self._create_code_mapping(js_react_project, "js-react-repo")
- self._create_code_mapping(python_django_project, "python-django-repo")
- self._create_code_mapping(node_express_project, "node-express-repo")
- self._create_code_mapping(go_project, "go-repo")
- self._create_code_mapping(ruby_project, "ruby-repo")
- self._create_code_mapping(java_project, "java-repo")
-
- # Include all projects in the allowlist
- with override_options(
- {
- "autopilot.missing-sdk-integration.projects-allowlist": [
- python_project.id,
- node_project.id,
- js_project.id,
- js_react_project.id,
- python_django_project.id,
- node_express_project.id,
- go_project.id,
- ruby_project.id,
- java_project.id,
- ]
- }
- ):
- run_missing_sdk_integration_detector()
-
- # Only supported platforms should have tasks spawned
- assert mock_apply_async.call_count == 6
-
- # Collect all project IDs that had tasks spawned
- spawned_project_ids = {call[1]["args"][1] for call in mock_apply_async.call_args_list}
-
- # Supported platforms should be included
- assert python_project.id in spawned_project_ids
- assert node_project.id in spawned_project_ids
- assert js_project.id in spawned_project_ids
- assert js_react_project.id in spawned_project_ids
- assert python_django_project.id in spawned_project_ids
- assert node_express_project.id in spawned_project_ids
-
- # Unsupported platforms should NOT be included
- assert go_project.id not in spawned_project_ids
- assert ruby_project.id not in spawned_project_ids
- assert java_project.id not in spawned_project_ids
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.has_seer_access", return_value=True)
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_skips_non_github_repositories(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- self.project.platform = "python"
- self.project.save()
- # Create a code mapping with a non-GitHub provider
- self._create_code_mapping(self.project, "gitlab-repo", provider="integrations:gitlab")
-
- with override_options(
- {"autopilot.missing-sdk-integration.projects-allowlist": [self.project.id]}
- ):
- run_missing_sdk_integration_detector()
-
- # No task should be spawned for non-GitHub repos
- assert not mock_apply_async.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.has_seer_access", return_value=True)
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_processes_github_repos_and_skips_non_github(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- self.project.platform = "python"
- self.project.save()
- self._create_code_mapping(
- self.project, "github-repo", provider="integrations:github", stack_root="src/"
- )
- self._create_code_mapping(
- self.project, "gitlab-repo", provider="integrations:gitlab", stack_root="lib/"
- )
-
- with override_options(
- {"autopilot.missing-sdk-integration.projects-allowlist": [self.project.id]}
- ):
- run_missing_sdk_integration_detector()
-
- # Only the GitHub repo should have a task spawned
- mock_apply_async.assert_called_once_with(
- args=(self.organization.id, self.project.id, "github-repo"),
- headers={"sentry-propagate-traces": False},
- )
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.has_seer_access", return_value=True)
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_deduplicates_multiple_code_mappings_for_same_repo(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- self.project.platform = "python"
- self.project.save()
-
- # Create two code mappings pointing to the same repository but with different stack/source roots
- repo = self.create_repo(
- project=self.project,
- name="shared-repo",
- provider="integrations:github",
- integration_id=self.integration.id,
- )
- self.create_code_mapping(
- project=self.project, repo=repo, stack_root="backend/", source_root="src/backend/"
- )
- self.create_code_mapping(
- project=self.project, repo=repo, stack_root="api/", source_root="src/api/"
- )
-
- with override_options(
- {"autopilot.missing-sdk-integration.projects-allowlist": [self.project.id]}
- ):
- run_missing_sdk_integration_detector()
-
- # Only one task should be spawned despite two code mappings for the same repo
- mock_apply_async.assert_called_once()
- assert mock_apply_async.call_args[1]["args"][2] == "shared-repo"
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.has_seer_access", return_value=True)
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_spawns_tasks_for_distinct_repos(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- self.project.platform = "python"
- self.project.save()
-
- # Create code mappings for two different repositories (with distinct stack_roots)
- self._create_code_mapping(self.project, "repo-one", stack_root="app/")
- self._create_code_mapping(self.project, "repo-two", stack_root="lib/")
-
- with override_options(
- {"autopilot.missing-sdk-integration.projects-allowlist": [self.project.id]}
- ):
- run_missing_sdk_integration_detector()
-
- # Both repos are distinct, so both should get tasks
- assert mock_apply_async.call_count == 2
- repo_names = {call[1]["args"][2] for call in mock_apply_async.call_args_list}
- assert repo_names == {"repo-one", "repo-two"}
-
- @pytest.mark.django_db
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.has_seer_access", return_value=False
- )
- @mock.patch(
- "sentry.autopilot.tasks.missing_sdk_integration.run_missing_sdk_integration_detector_for_project_task.apply_async"
- )
- def test_skips_project_without_gen_ai_access(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- self.project.platform = "python"
- self.project.save()
- self._create_code_mapping(self.project, "test-repo")
-
- with override_options(
- {"autopilot.missing-sdk-integration.projects-allowlist": [self.project.id]}
- ):
- run_missing_sdk_integration_detector()
-
- # No task should be spawned when org lacks gen AI access
- assert not mock_apply_async.called
-
-
-class TestRunMissingSdkIntegrationDetectorForProject(TestCase):
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.metrics.incr")
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.SeerAgentClient")
- def test_returns_none_for_nonexistent_organization(
- self, mock_seer_client: mock.MagicMock, mock_metrics_incr: mock.MagicMock
- ) -> None:
- result = run_missing_sdk_integration_detector_for_project_task(
- organization_id=999999,
- project_id=self.project.id,
- repo_name="test-repo",
- )
- assert result is None
- assert not mock_seer_client.called
- # Verify run metric was emitted
- mock_metrics_incr.assert_any_call(
- "autopilot.missing_sdk_integration_detector.run",
- tags={"project_id": str(self.project.id)},
- sample_rate=1.0,
- )
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.metrics.incr")
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.SeerAgentClient")
- def test_returns_none_for_nonexistent_project(
- self, mock_seer_client: mock.MagicMock, mock_metrics_incr: mock.MagicMock
- ) -> None:
- result = run_missing_sdk_integration_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=999999,
- repo_name="test-repo",
- )
- assert result is None
- assert not mock_seer_client.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.metrics.incr")
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.SeerAgentClient")
- def test_returns_none_without_seer_access(
- self, mock_seer_client: mock.MagicMock, mock_metrics_incr: mock.MagicMock
- ) -> None:
- mock_seer_client.side_effect = SeerPermissionError("Access denied")
-
- result = run_missing_sdk_integration_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=self.project.id,
- repo_name="test-repo",
- )
-
- assert result is None
- assert mock_seer_client.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.metrics.incr")
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.create_instrumentation_issue")
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.SeerAgentClient")
- def test_creates_issues_for_missing_integrations(
- self,
- mock_seer_client: mock.MagicMock,
- mock_create_issue: mock.MagicMock,
- mock_metrics_incr: mock.MagicMock,
- ) -> None:
- # Mock the client instance with artifact result
- mock_client_instance = mock.MagicMock()
- mock_client_instance.start_run.return_value = 123
- mock_state = mock.MagicMock()
- mock_state.status = "completed"
- mock_state.blocks = []
- mock_state.get_artifact.return_value = MissingSdkIntegrationsResult(
- missing_integrations=[
- MissingSdkIntegrationDetail(
- name="anthropicIntegration",
- summary="Track your Anthropic API calls in Sentry — since your project already uses the anthropic package, this integration gives you token usage and model details on every request.",
- docs_url="https://docs.sentry.io/platforms/python/integrations/anthropic/",
- ),
- MissingSdkIntegrationDetail(
- name="openaiIntegration",
- summary="Track your OpenAI API calls in Sentry — since your project already uses the openai package, this integration gives you token usage and model details on every request.",
- docs_url="https://docs.sentry.io/platforms/python/integrations/openai/",
- ),
- ],
- finish_reason=MissingSdkIntegrationFinishReason.SUCCESS,
- )
- mock_client_instance.get_run.return_value = mock_state
- mock_seer_client.return_value = mock_client_instance
-
- self.project.platform = "python"
- self.project.save()
-
- result = run_missing_sdk_integration_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=self.project.id,
- repo_name="test-repo",
- )
-
- # Should return the list of missing integrations
- assert result == ["anthropicIntegration", "openaiIntegration"]
-
- # Check that start_run was called with artifact schema
- assert mock_client_instance.start_run.call_count == 1
- call_kwargs = mock_client_instance.start_run.call_args[1]
- assert call_kwargs.get("artifact_key") == "missing_integrations"
- assert call_kwargs.get("artifact_schema") == MissingSdkIntegrationsResult
-
- # Check that the prompt includes the repo name, slug, and platform
- prompt = mock_client_instance.start_run.call_args[0][0]
- assert "test-repo" in prompt
- assert self.project.slug in prompt
- assert "python" in prompt
-
- # Verify that an instrumentation issue was created for each missing integration
- assert mock_create_issue.call_count == 2
-
- # Check that each integration got its own issue
- call_args_list = [call[1] for call in mock_create_issue.call_args_list]
- titles = [args["title"] for args in call_args_list]
- assert "Missing SDK Integration: anthropicIntegration" in titles
- assert "Missing SDK Integration: openaiIntegration" in titles
-
- # Verify common attributes and AI-generated content
- for call_kwargs in call_args_list:
- assert call_kwargs["project_id"] == self.project.id
- assert call_kwargs["detector_name"] == AutopilotDetectorName.MISSING_SDK_INTEGRATION
- assert call_kwargs["repository_name"] == "test-repo"
- assert "docs.sentry.io" in call_kwargs["description"]
- assert call_kwargs["subtitle"] != ""
-
- # Verify the anthropic integration has its specific details
- anthropic_call = next(c for c in call_args_list if "anthropicIntegration" in c["title"])
- assert anthropic_call["subtitle"] == (
- "Track your Anthropic API calls in Sentry — since your project already uses"
- " the anthropic package, this integration gives you token usage and model"
- " details on every request."
- )
- assert (
- "docs.sentry.io/platforms/python/integrations/anthropic/"
- in anthropic_call["description"]
- )
-
- # Verify metrics were emitted
- mock_metrics_incr.assert_any_call(
- "autopilot.missing_sdk_integration_detector.run",
- tags={"project_id": str(self.project.id)},
- sample_rate=1.0,
- )
- mock_metrics_incr.assert_any_call(
- "autopilot.missing_sdk_integration_detector.issue_created",
- tags={"project_id": str(self.project.id), "integration": "anthropicIntegration"},
- sample_rate=1.0,
- )
- mock_metrics_incr.assert_any_call(
- "autopilot.missing_sdk_integration_detector.issue_created",
- tags={"project_id": str(self.project.id), "integration": "openaiIntegration"},
- sample_rate=1.0,
- )
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.metrics.incr")
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.SeerAgentClient")
- def test_handles_seer_explorer_error_gracefully(
- self, mock_seer_client: mock.MagicMock, mock_metrics_incr: mock.MagicMock
- ) -> None:
- # Mock the client to raise an error on start_run
- mock_client_instance = mock.MagicMock()
- mock_client_instance.start_run.side_effect = Exception("API error")
- mock_seer_client.return_value = mock_client_instance
-
- # Should not raise, just return None
- result = run_missing_sdk_integration_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=self.project.id,
- repo_name="test-repo",
- )
- assert result is None
-
- # Verify error metric was emitted
- mock_metrics_incr.assert_any_call(
- "autopilot.missing_sdk_integration_detector.error",
- tags={"project_id": str(self.project.id), "error_type": "Exception"},
- sample_rate=1.0,
- )
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.metrics.incr")
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.create_instrumentation_issue")
- @mock.patch("sentry.autopilot.tasks.missing_sdk_integration.SeerAgentClient")
- def test_does_not_create_issues_when_no_missing_integrations(
- self,
- mock_seer_client: mock.MagicMock,
- mock_create_issue: mock.MagicMock,
- mock_metrics_incr: mock.MagicMock,
- ) -> None:
- mock_client_instance = mock.MagicMock()
- mock_client_instance.start_run.return_value = 123
- mock_state = mock.MagicMock()
- mock_state.status = "completed"
- mock_state.blocks = []
- mock_state.get_artifact.return_value = MissingSdkIntegrationsResult(
- missing_integrations=[], finish_reason=MissingSdkIntegrationFinishReason.SUCCESS
- )
- mock_client_instance.get_run.return_value = mock_state
- mock_seer_client.return_value = mock_client_instance
-
- result = run_missing_sdk_integration_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=self.project.id,
- repo_name="test-repo",
- )
-
- assert result == []
- assert mock_create_issue.call_count == 0
-
- # Verify run metric was emitted but no issue_created metric
- mock_metrics_incr.assert_any_call(
- "autopilot.missing_sdk_integration_detector.run",
- tags={"project_id": str(self.project.id)},
- sample_rate=1.0,
- )
- # Verify no issue_created metric was emitted
- issue_created_calls = [
- call
- for call in mock_metrics_incr.call_args_list
- if call[0][0] == "autopilot.missing_sdk_integration_detector.issue_created"
- ]
- assert len(issue_created_calls) == 0
diff --git a/tests/sentry/autopilot/tasks/test_sdk_update.py b/tests/sentry/autopilot/tasks/test_sdk_update.py
deleted file mode 100644
index 9251f60ba3d847..00000000000000
--- a/tests/sentry/autopilot/tasks/test_sdk_update.py
+++ /dev/null
@@ -1,255 +0,0 @@
-from unittest import mock
-
-import pytest
-
-from sentry.autopilot.tasks.common import AutopilotDetectorName
-from sentry.autopilot.tasks.sdk_update import run_sdk_update_detector_for_organization
-from sentry.testutils.cases import SnubaTestCase, TestCase
-from sentry.testutils.helpers.datetime import before_now
-from sentry.testutils.helpers.options import override_options
-
-
-class TestRunSdkUpdateDetector(TestCase, SnubaTestCase):
- def setUp(self) -> None:
- super().setUp()
- self.project2 = self.create_project(organization=self.organization)
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.sdk_update.create_instrumentation_issue")
- @mock.patch(
- "sentry.autopilot.tasks.sdk_update.get_sdk_versions",
- return_value={"example.sdk": "1.4.0"},
- )
- def test_simple(
- self, mock_get_sdk_versions: mock.MagicMock, mock_create_issue: mock.MagicMock
- ) -> None:
- min_ago = before_now(minutes=1).isoformat()
- self.store_event(
- data={
- "event_id": "a" * 32,
- "message": "oh no",
- "timestamp": min_ago,
- "fingerprint": ["group-1"],
- "sdk": {"name": "example.sdk", "version": "1.0.0"},
- },
- project_id=self.project.id,
- assert_no_errors=False,
- )
-
- with override_options({"autopilot.organization-allowlist": [self.organization.slug]}):
- updates = run_sdk_update_detector_for_organization(self.organization)
-
- assert len(updates) == 1
- assert updates[0] == {
- "projectId": str(self.project.id),
- "sdkName": "example.sdk",
- "sdkVersion": "1.0.0",
- "newestSdkVersion": "1.4.0",
- "needsUpdate": True,
- }
-
- # Verify that an instrumentation issue was created
- assert mock_create_issue.call_count == 1
- call_kwargs = mock_create_issue.call_args[1]
- assert call_kwargs["project_id"] == self.project.id
- assert call_kwargs["detector_name"] == AutopilotDetectorName.SDK_UPDATE
- assert "example.sdk" in call_kwargs["title"]
- assert "1.0.0" in call_kwargs["subtitle"]
- assert "1.4.0" in call_kwargs["subtitle"]
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.sdk_update.create_instrumentation_issue")
- @mock.patch(
- "sentry.autopilot.tasks.sdk_update.get_sdk_versions",
- return_value={"example.sdk": "1.4.0"},
- )
- def test_it_handles_multiple_projects(
- self, mock_get_sdk_versions: mock.MagicMock, mock_create_issue: mock.MagicMock
- ) -> None:
- min_ago = before_now(minutes=1).isoformat()
- self.store_event(
- data={
- "event_id": "a" * 32,
- "message": "oh no",
- "timestamp": min_ago,
- "fingerprint": ["group-1"],
- "sdk": {"name": "example.sdk", "version": "1.0.0"},
- },
- project_id=self.project.id,
- assert_no_errors=False,
- )
- self.store_event(
- data={
- "event_id": "b" * 32,
- "message": "oh no",
- "timestamp": min_ago,
- "fingerprint": ["group-2"],
- "sdk": {"name": "example.sdk", "version": "0.9.0"},
- },
- project_id=self.project2.id,
- assert_no_errors=False,
- )
-
- with override_options({"autopilot.organization-allowlist": [self.organization.slug]}):
- updates = run_sdk_update_detector_for_organization(self.organization)
-
- assert len(updates) == 2
- assert updates[0] == {
- "projectId": str(self.project2.id),
- "sdkName": "example.sdk",
- "sdkVersion": "0.9.0",
- "newestSdkVersion": "1.4.0",
- "needsUpdate": True,
- }
- assert updates[1] == {
- "projectId": str(self.project.id),
- "sdkName": "example.sdk",
- "sdkVersion": "1.0.0",
- "newestSdkVersion": "1.4.0",
- "needsUpdate": True,
- }
-
- # Verify that an instrumentation issue was created for each update
- assert mock_create_issue.call_count == 2
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.sdk_update.create_instrumentation_issue")
- @mock.patch(
- "sentry.autopilot.tasks.sdk_update.get_sdk_versions",
- return_value={"example.sdk": "1.4.0", "example.sdk2": "1.2.0"},
- )
- def test_it_handles_multiple_sdks(
- self, mock_get_sdk_versions: mock.MagicMock, mock_create_issue: mock.MagicMock
- ) -> None:
- min_ago = before_now(minutes=1).isoformat()
- self.store_event(
- data={
- "event_id": "a" * 32,
- "message": "oh no",
- "timestamp": min_ago,
- "fingerprint": ["group-1"],
- "sdk": {"name": "example.sdk", "version": "1.0.0"},
- },
- project_id=self.project.id,
- assert_no_errors=False,
- )
- self.store_event(
- data={
- "event_id": "b" * 32,
- "message": "oh no",
- "timestamp": min_ago,
- "fingerprint": ["group-2"],
- "sdk": {"name": "example.sdk2", "version": "0.9.0"},
- },
- project_id=self.project2.id,
- assert_no_errors=False,
- )
-
- with override_options({"autopilot.organization-allowlist": [self.organization.slug]}):
- updates = run_sdk_update_detector_for_organization(self.organization)
-
- assert len(updates) == 2
- assert updates[0] == {
- "projectId": str(self.project2.id),
- "sdkName": "example.sdk2",
- "sdkVersion": "0.9.0",
- "newestSdkVersion": "1.2.0",
- "needsUpdate": True,
- }
- assert updates[1] == {
- "projectId": str(self.project.id),
- "sdkName": "example.sdk",
- "sdkVersion": "1.0.0",
- "newestSdkVersion": "1.4.0",
- "needsUpdate": True,
- }
-
- # Verify that an instrumentation issue was created for each SDK
- assert mock_create_issue.call_count == 2
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.sdk_update.create_instrumentation_issue")
- @mock.patch(
- "sentry.autopilot.tasks.sdk_update.get_sdk_versions",
- return_value={"example.sdk": "1.0.5"},
- )
- def test_it_ignores_patch_versions(
- self, mock_get_sdk_versions: mock.MagicMock, mock_create_issue: mock.MagicMock
- ) -> None:
- min_ago = before_now(minutes=1).isoformat()
- self.store_event(
- data={
- "event_id": "a" * 32,
- "message": "oh no",
- "timestamp": min_ago,
- "fingerprint": ["group-1"],
- "sdk": {"name": "example.sdk", "version": "1.0.0"},
- },
- project_id=self.project.id,
- assert_no_errors=False,
- )
-
- with override_options({"autopilot.organization-allowlist": [self.organization.slug]}):
- updates = run_sdk_update_detector_for_organization(self.organization)
-
- assert len(updates) == 0
- # No instrumentation issue should be created
- assert mock_create_issue.call_count == 0
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.sdk_update.create_instrumentation_issue")
- @mock.patch(
- "sentry.autopilot.tasks.sdk_update.get_sdk_versions",
- return_value={"example.sdk": "1.0.5"},
- )
- def test_it_ignores_unknown_sdks(
- self, mock_get_sdk_versions: mock.MagicMock, mock_create_issue: mock.MagicMock
- ) -> None:
- min_ago = before_now(minutes=1).isoformat()
- self.store_event(
- data={
- "event_id": "a" * 32,
- "message": "oh no",
- "timestamp": min_ago,
- "fingerprint": ["group-1"],
- "sdk": {"name": "example.sdk.unknown", "version": "0.9.0"},
- },
- project_id=self.project.id,
- assert_no_errors=False,
- )
-
- with override_options({"autopilot.organization-allowlist": [self.organization.slug]}):
- updates = run_sdk_update_detector_for_organization(self.organization)
-
- assert len(updates) == 0
- # No instrumentation issue should be created
- assert mock_create_issue.call_count == 0
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.sdk_update.create_instrumentation_issue")
- @mock.patch(
- "sentry.autopilot.tasks.sdk_update.get_sdk_versions",
- return_value={"example.sdk": "1.0.5"},
- )
- def test_it_ignores_invalid_sdk_versions(
- self, mock_get_sdk_versions: mock.MagicMock, mock_create_issue: mock.MagicMock
- ) -> None:
- min_ago = before_now(minutes=1).isoformat()
- self.store_event(
- data={
- "event_id": "a" * 32,
- "message": "oh no",
- "timestamp": min_ago,
- "fingerprint": ["group-1"],
- "sdk": {"name": "example.sdk", "version": "abcdefg"},
- },
- project_id=self.project.id,
- assert_no_errors=False,
- )
-
- with override_options({"autopilot.organization-allowlist": [self.organization.slug]}):
- updates = run_sdk_update_detector_for_organization(self.organization)
-
- assert len(updates) == 0
- # No instrumentation issue should be created
- assert mock_create_issue.call_count == 0
diff --git a/tests/sentry/autopilot/tasks/test_trace_instrumentation.py b/tests/sentry/autopilot/tasks/test_trace_instrumentation.py
deleted file mode 100644
index 40ec813f47c7df..00000000000000
--- a/tests/sentry/autopilot/tasks/test_trace_instrumentation.py
+++ /dev/null
@@ -1,311 +0,0 @@
-import uuid
-from datetime import datetime, timedelta
-from typing import Any
-from unittest import mock
-
-import pytest
-
-from sentry.autopilot.tasks.common import AutopilotDetectorName
-from sentry.autopilot.tasks.trace_instrumentation import (
- InstrumentationIssueCategory,
- TraceInstrumentationFinishReason,
- TraceInstrumentationIssue,
- TraceInstrumentationResult,
- run_trace_instrumentation_detector,
- run_trace_instrumentation_detector_for_project_task,
- sample_trace_for_instrumentation_analysis,
-)
-from sentry.seer.models import SeerPermissionError
-from sentry.testutils.cases import SnubaTestCase, SpanTestCase, TestCase
-from sentry.testutils.helpers.datetime import before_now
-from sentry.testutils.helpers.options import override_options
-
-
-class TestRunTraceInstrumentationDetector(TestCase):
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.has_seer_access", return_value=True)
- @mock.patch(
- "sentry.autopilot.tasks.trace_instrumentation.run_trace_instrumentation_detector_for_project_task.apply_async"
- )
- def test_queues_task_for_each_allowlisted_project(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- project1 = self.create_project(organization=self.organization)
- project2 = self.create_project(organization=self.organization)
-
- with override_options(
- {"autopilot.trace-instrumentation.projects-allowlist": [project1.id, project2.id]}
- ):
- run_trace_instrumentation_detector()
-
- assert mock_apply_async.call_count == 2
- spawned = {call[1]["args"] for call in mock_apply_async.call_args_list}
- assert spawned == {
- (self.organization.id, project1.id),
- (self.organization.id, project2.id),
- }
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.has_seer_access", return_value=False)
- @mock.patch(
- "sentry.autopilot.tasks.trace_instrumentation.run_trace_instrumentation_detector_for_project_task.apply_async"
- )
- def test_skips_project_without_gen_ai_access(
- self, mock_apply_async: mock.MagicMock, _mock_has_seer_access: mock.MagicMock
- ) -> None:
- with override_options(
- {"autopilot.trace-instrumentation.projects-allowlist": [self.project.id]}
- ):
- run_trace_instrumentation_detector()
-
- assert not mock_apply_async.called
-
-
-class TraceSpanTestMixin(SpanTestCase, SnubaTestCase):
- """Mixin providing helper methods for creating and storing trace spans."""
-
- ten_mins_ago: datetime
-
- def _create_trace_with_spans(
- self, trace_id: str, transaction_name: str, span_count: int = 25
- ) -> list[dict[str, Any]]:
- """Helper to create and store a trace with the specified number of spans.
-
- Creates enough spans (default 25) to meet the minimum span count requirement (20-500)
- for trace sampling.
- """
- spans: list[dict[str, Any]] = []
- for i in range(span_count):
- span = self.create_span(
- {
- "description": f"span-{i}" if i > 0 else transaction_name,
- "sentry_tags": {
- "transaction": transaction_name,
- "op": "http.server" if i == 0 else "db.query",
- },
- "trace_id": trace_id,
- "parent_span_id": None if i == 0 else spans[0]["span_id"],
- "is_segment": i == 0,
- },
- start_ts=self.ten_mins_ago + timedelta(seconds=i),
- )
- spans.append(span)
-
- self.store_spans(spans)
- return spans
-
-
-class TestSampleTraceForInstrumentationAnalysis(TestCase, TraceSpanTestMixin):
- def setUp(self) -> None:
- super().setUp()
- self.ten_mins_ago = before_now(minutes=10)
-
- @pytest.mark.django_db
- def test_returns_none_when_no_traces_found(self) -> None:
- # No spans stored, so no traces should be found
- result = sample_trace_for_instrumentation_analysis(self.project)
- assert result is None
-
- @pytest.mark.django_db
- def test_returns_first_trace_when_found(self) -> None:
- trace_id = uuid.uuid4().hex
- transaction_name = "GET /api/users"
-
- self._create_trace_with_spans(trace_id, transaction_name)
-
- result = sample_trace_for_instrumentation_analysis(self.project)
-
- assert result is not None
- assert result.trace_id == trace_id
-
-
-class TestRunTraceInstrumentationDetectorForProject(TestCase, TraceSpanTestMixin):
- def setUp(self) -> None:
- super().setUp()
- self.ten_mins_ago = before_now(minutes=10)
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.SeerAgentClient")
- def test_skips_llm_for_nonexistent_organization(self, mock_seer_client: mock.MagicMock) -> None:
- result = run_trace_instrumentation_detector_for_project_task(
- organization_id=999999,
- project_id=self.project.id,
- )
- assert result is None
- assert not mock_seer_client.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.SeerAgentClient")
- def test_skips_llm_for_nonexistent_project(self, mock_seer_client: mock.MagicMock) -> None:
- result = run_trace_instrumentation_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=999999,
- )
- assert result is None
- assert not mock_seer_client.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.SeerAgentClient")
- def test_skips_llm_when_no_trace_sampled(
- self,
- mock_seer_client: mock.MagicMock,
- ) -> None:
- # No spans stored, so sample_trace_for_instrumentation_analysis returns None
- result = run_trace_instrumentation_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=self.project.id,
- )
-
- assert result is None
- assert not mock_seer_client.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.SeerAgentClient")
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.get_trace_waterfall")
- def test_skips_llm_when_trace_query_fails(
- self,
- mock_get_waterfall: mock.MagicMock,
- mock_seer_client: mock.MagicMock,
- ) -> None:
- trace_id = uuid.uuid4().hex
- transaction_name = "GET /api/users"
- self._create_trace_with_spans(trace_id, transaction_name)
-
- # Mock get_trace_waterfall to raise an exception
- mock_get_waterfall.side_effect = Exception("Query failed")
-
- result = run_trace_instrumentation_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=self.project.id,
- )
-
- assert result is None
- assert not mock_seer_client.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.SeerAgentClient")
- def test_handles_missing_seer_access(
- self,
- mock_seer_client: mock.MagicMock,
- ) -> None:
- trace_id = uuid.uuid4().hex
- transaction_name = "GET /api/users"
- self._create_trace_with_spans(trace_id, transaction_name)
-
- mock_seer_client.side_effect = SeerPermissionError("Access denied")
-
- result = run_trace_instrumentation_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=self.project.id,
- )
-
- assert result is None
- assert mock_seer_client.called
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.create_instrumentation_issue")
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.SeerAgentClient")
- def test_creates_issues_for_instrumentation_gaps(
- self,
- mock_seer_client: mock.MagicMock,
- mock_create_issue: mock.MagicMock,
- ) -> None:
- trace_id = uuid.uuid4().hex
- transaction_name = "GET /api/users"
- self._create_trace_with_spans(trace_id, transaction_name)
-
- # Mock Seer client to return instrumentation issues
- mock_client_instance = mock.MagicMock()
- mock_client_instance.start_run.return_value = 123
- mock_state = mock.MagicMock()
- mock_state.status = "completed"
- mock_state.blocks = []
-
- issue1 = TraceInstrumentationIssue(
- explanation="Database queries are not instrumented with db.* spans",
- impact="Cannot identify slow queries or N+1 problems",
- evidence="Time gap of 250ms between spans span1 and span2",
- offender_span_ids=["span1", "span2"],
- missing_telemetry="Add db.query spans for database operations",
- title="Missing Database Instrumentation",
- category=InstrumentationIssueCategory.MISSING_INSTRUMENTATION,
- subcategory="Missing Database Spans",
- )
- issue2 = TraceInstrumentationIssue(
- explanation="HTTP requests to external APIs lack http.* spans",
- impact="External dependency performance cannot be tracked",
- evidence="Parent span duration suggests external calls at span3",
- offender_span_ids=["span3"],
- missing_telemetry="Add http.client spans for external API calls",
- title="Missing HTTP Instrumentation",
- category=InstrumentationIssueCategory.MISSING_INSTRUMENTATION,
- subcategory="Missing HTTP Spans",
- )
-
- mock_state.get_artifact.return_value = TraceInstrumentationResult(
- issues=[issue1, issue2],
- finish_reason=TraceInstrumentationFinishReason.SUCCESS,
- )
- mock_client_instance.get_run.return_value = mock_state
- mock_seer_client.return_value = mock_client_instance
-
- result = run_trace_instrumentation_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=self.project.id,
- )
-
- # Should return the list of issues
- assert result is not None
- assert len(result) == 2
- assert result[0].title == "Missing Database Instrumentation"
- assert result[1].title == "Missing HTTP Instrumentation"
-
- # Verify Seer client was called correctly
- assert mock_client_instance.start_run.call_count == 1
- call_kwargs = mock_client_instance.start_run.call_args[1]
- assert call_kwargs.get("artifact_key") == "issues"
- assert call_kwargs.get("artifact_schema") == TraceInstrumentationResult
-
- # Verify prompt includes project context and trace data
- prompt = mock_client_instance.start_run.call_args[0][0]
- assert self.project.slug in prompt
- assert "DETECTION CRITERIA" in prompt
- assert transaction_name in prompt
-
- # Verify issues were created
- assert mock_create_issue.call_count == 2
-
- # Check first issue
- call_args_list = [call[1] for call in mock_create_issue.call_args_list]
- assert call_args_list[0]["project_id"] == self.project.id
- assert call_args_list[0]["detector_name"] == AutopilotDetectorName.TRACE_INSTRUMENTATION
- assert call_args_list[0]["title"] == "Missing Database Instrumentation"
- assert call_args_list[0]["subtitle"] == "Missing Database Spans"
- assert "Database queries are not instrumented" in call_args_list[0]["description"]
- assert "span1, span2" in call_args_list[0]["description"]
-
- # Check second issue
- assert call_args_list[1]["title"] == "Missing HTTP Instrumentation"
- assert call_args_list[1]["subtitle"] == "Missing HTTP Spans"
-
- @pytest.mark.django_db
- @mock.patch("sentry.autopilot.tasks.trace_instrumentation.SeerAgentClient")
- def test_handles_seer_explorer_error_gracefully(
- self,
- mock_seer_client: mock.MagicMock,
- ) -> None:
- trace_id = uuid.uuid4().hex
- transaction_name = "GET /api/users"
- self._create_trace_with_spans(trace_id, transaction_name)
-
- # Mock client to raise error on start_run
- mock_client_instance = mock.MagicMock()
- mock_client_instance.start_run.side_effect = Exception("API error")
- mock_seer_client.return_value = mock_client_instance
-
- # Should not raise, just return None
- result = run_trace_instrumentation_detector_for_project_task(
- organization_id=self.organization.id,
- project_id=self.project.id,
- )
- assert result is None
diff --git a/tests/sentry/net/test_socket.py b/tests/sentry/net/test_socket.py
index 9f8bc9bce6b2ef..753d5f51383165 100644
--- a/tests/sentry/net/test_socket.py
+++ b/tests/sentry/net/test_socket.py
@@ -4,12 +4,12 @@
from sentry.net.socket import ensure_fqdn, is_ipaddress_allowed, is_safe_hostname
from sentry.testutils.cases import TestCase
-from sentry.testutils.helpers import override_blocklist
+from sentry.testutils.helpers import override_allowlist, override_blocklist
class SocketTest(TestCase):
@override_blocklist("10.0.0.0/8", "127.0.0.1")
- def test_is_ipaddress_allowed(self) -> None:
+ def test_is_ipaddress_blocked(self) -> None:
is_ipaddress_allowed.cache_clear()
assert is_ipaddress_allowed("127.0.0.1") is False
is_ipaddress_allowed.cache_clear()
@@ -18,7 +18,7 @@ def test_is_ipaddress_allowed(self) -> None:
assert is_ipaddress_allowed("1.1.1.1") is True
@override_blocklist("::ffff:10.0.0.0/104", "::1/128")
- def test_is_ipaddress_allowed_ipv6(self) -> None:
+ def test_is_ipaddress_blocked_ipv6(self) -> None:
is_ipaddress_allowed.cache_clear()
assert is_ipaddress_allowed("::1") is False
is_ipaddress_allowed.cache_clear()
@@ -28,6 +28,22 @@ def test_is_ipaddress_allowed_ipv6(self) -> None:
is_ipaddress_allowed.cache_clear()
assert is_ipaddress_allowed("2001:db8:a::123") is True
+ @override_blocklist("10.0.0.0/8")
+ @override_allowlist("10.0.0.1/32")
+ def test_is_ipaddress_allowed(self) -> None:
+ is_ipaddress_allowed.cache_clear()
+ assert is_ipaddress_allowed("10.0.1.1") is False
+ is_ipaddress_allowed.cache_clear()
+ assert is_ipaddress_allowed("10.0.0.1") is True
+
+ @override_blocklist("::ffff:10.0.0.0/104")
+ @override_allowlist("::ffff:10.0.0.1/128")
+ def test_is_ipaddress_allowed_ipv6(self) -> None:
+ is_ipaddress_allowed.cache_clear()
+ assert is_ipaddress_allowed("::ffff:10.0.1.2") is False
+ is_ipaddress_allowed.cache_clear()
+ assert is_ipaddress_allowed("::ffff:10.0.0.1") is True
+
@override_blocklist("10.0.0.0/8", "127.0.0.1")
@patch("socket.getaddrinfo")
def test_is_safe_hostname(self, mock_getaddrinfo: MagicMock) -> None:
diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py
index 850be2a58669a3..4af85d24553adf 100644
--- a/tests/sentry/tasks/test_post_process.py
+++ b/tests/sentry/tasks/test_post_process.py
@@ -16,7 +16,6 @@
from sentry import buffer
from sentry.analytics.events.first_flag_sent import FirstFlagSentEvent
-from sentry.autopilot.grouptype import InstrumentationIssueExperimentalGroupType
from sentry.eventstream.types import EventStreamEventType
from sentry.feedback.lib.utils import FeedbackCreationSource
from sentry.integrations.models.integration import Integration
@@ -4360,82 +4359,3 @@ def test_process_data_forwarding_one_forwarder_fails(
# Both forwarders should be called despite SQS failure
assert mock_sqs_forward.call_count == 1
assert mock_splunk_forward.call_count == 1
-
-
-class PostProcessGroupInstrumentationIssueTest(
- TestCase,
- SnubaTestCase,
- OccurrenceTestMixin,
-):
- """Tests that instrumentation issues do not trigger alerts."""
-
- def create_event(
- self,
- data,
- project_id,
- assert_no_errors=True,
- ):
- data["type"] = "generic"
-
- event = self.store_event(
- data=data, project_id=project_id, assert_no_errors=assert_no_errors
- )
-
- occurrence_data = self.build_occurrence_data(
- event_id=event.event_id,
- project_id=project_id,
- id=uuid.uuid4().hex,
- fingerprint=["instrumentation-" + uuid.uuid4().hex],
- issue_title="Missing Instrumentation",
- subtitle="Database query not instrumented",
- culprit="api/endpoint",
- resource_id="1234",
- evidence_data={"test": "data"},
- evidence_display=[
- {"name": "issue", "value": "missing span", "important": True},
- ],
- type=InstrumentationIssueExperimentalGroupType.type_id,
- detection_time=datetime.now().timestamp(),
- level="info",
- )
- occurrence, group_info = save_issue_occurrence(occurrence_data, event)
- assert group_info is not None
-
- group_event = event.for_group(group_info.group)
- group_event.occurrence = occurrence
- return group_event
-
- def call_post_process_group(self, is_new, is_regression, is_new_group_environment, event):
- with self.feature(
- InstrumentationIssueExperimentalGroupType.build_post_process_group_feature_name()
- ):
- post_process_group(
- is_new=is_new,
- is_regression=is_regression,
- is_new_group_environment=is_new_group_environment,
- cache_key=None,
- group_id=event.group_id,
- occurrence_id=event.occurrence.id,
- project_id=event.group.project_id,
- eventstream_type=EventStreamEventType.Generic.value,
- )
-
- @patch("sentry.tasks.post_process.process_workflow_engine")
- def test_instrumentation_issues_do_not_trigger_alerts(
- self,
- mock_process_workflow_engine,
- ):
- """Instrumentation issues should not trigger process_rules or process_workflow_engine."""
- event = self.create_event(
- data={},
- project_id=self.project.id,
- )
-
- self.call_post_process_group(
- is_new=True,
- is_regression=False,
- is_new_group_environment=True,
- event=event,
- )
-
- mock_process_workflow_engine.assert_not_called()