From 3cbbcc50b5bd50cc58b40fac9f2f493acf52653d Mon Sep 17 00:00:00 2001 From: Hector Dearman Date: Fri, 22 May 2026 11:01:21 +0100 Subject: [PATCH 1/8] feat(ui): Add debug FeatureBadge variant (#116000) Adds a `debug` type to `FeatureBadge` for marking UI that is only intended for debugging or internal experimentation, and uses it on the Night Shift project settings page in place of the previous `alpha` badge. Agent transcript: https://claudescope.sentry.dev/share/10qokud3YuEflxKruIvQjhjI7ul5gREgy94rRA9BebA Co-authored-by: Nate Moore --- static/app/components/core/badge/featureBadge.figma.tsx | 1 + static/app/components/core/badge/featureBadge.tsx | 6 +++++- .../seerAutomation/components/projectDetails/nightShift.tsx | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) 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/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')} - + } > From f7e6593878905e3052fe9771893c07794c5db84c Mon Sep 17 00:00:00 2001 From: Amir Mujacic Date: Fri, 22 May 2026 12:06:14 +0200 Subject: [PATCH 2/8] fix(perforce): Update onboarding frontend for Unicode support (#116005) * Update onboarding frontend for Unicode support --- .../pipelineIntegrationPerforce.spec.tsx | 2 ++ .../pipeline/pipelineIntegrationPerforce.tsx | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) 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 => ( From 434b444fc8f67b07df876db3f14cf4c01aaf6e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vjeran=20Grozdani=C4=87?= Date: Fri, 22 May 2026 12:24:37 +0200 Subject: [PATCH 3/8] chore(autopilot): Delete autopilot module and all references (#115466) Continuation of #115465 Complete clean up of autopilot related code. --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- pyproject.toml | 2 - src/sentry/autopilot/__init__.py | 0 src/sentry/autopilot/apps.py | 5 - src/sentry/autopilot/grouptype.py | 27 - src/sentry/autopilot/migrations/__init__.py | 0 src/sentry/autopilot/models.py | 0 src/sentry/autopilot/tasks/__init__.py | 0 src/sentry/autopilot/tasks/common.py | 89 --- .../tasks/missing_sdk_integration.py | 360 ----------- src/sentry/autopilot/tasks/sdk_update.py | 152 ----- .../autopilot/tasks/trace_instrumentation.py | 371 ----------- src/sentry/conf/server.py | 4 - src/sentry/features/temporary.py | 2 - src/sentry/issues/grouptype.py | 6 - src/sentry/options/defaults.py | 24 - src/sentry/search/snuba/executors.py | 1 - src/sentry/snuba/referrer.py | 3 - src/sentry/tasks/post_process.py | 5 - src/sentry/taskworker/namespaces.py | 5 - tests/sentry/autopilot/__init__.py | 0 tests/sentry/autopilot/tasks/__init__.py | 0 .../tasks/test_missing_sdk_integration.py | 583 ------------------ .../sentry/autopilot/tasks/test_sdk_update.py | 255 -------- .../tasks/test_trace_instrumentation.py | 311 ---------- tests/sentry/tasks/test_post_process.py | 80 --- 25 files changed, 2285 deletions(-) delete mode 100644 src/sentry/autopilot/__init__.py delete mode 100644 src/sentry/autopilot/apps.py delete mode 100644 src/sentry/autopilot/grouptype.py delete mode 100644 src/sentry/autopilot/migrations/__init__.py delete mode 100644 src/sentry/autopilot/models.py delete mode 100644 src/sentry/autopilot/tasks/__init__.py delete mode 100644 src/sentry/autopilot/tasks/common.py delete mode 100644 src/sentry/autopilot/tasks/missing_sdk_integration.py delete mode 100644 src/sentry/autopilot/tasks/sdk_update.py delete mode 100644 src/sentry/autopilot/tasks/trace_instrumentation.py delete mode 100644 tests/sentry/autopilot/__init__.py delete mode 100644 tests/sentry/autopilot/tasks/__init__.py delete mode 100644 tests/sentry/autopilot/tasks/test_missing_sdk_integration.py delete mode 100644 tests/sentry/autopilot/tasks/test_sdk_update.py delete mode 100644 tests/sentry/autopilot/tasks/test_trace_instrumentation.py 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/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..86470f49795ae7 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -458,7 +458,6 @@ def env( "crispy_forms", "rest_framework", "sentry", - "sentry.autopilot", "sentry.analytics", "sentry.auth_v2", "sentry.incidents.apps.Config", @@ -865,9 +864,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/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/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/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/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() From 15e1ac5e0efd94174c38ca9c544ce4aaf71a2924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vjeran=20Grozdani=C4=87?= Date: Fri, 22 May 2026 13:28:55 +0200 Subject: [PATCH 4/8] meta: Remove autopilot CODEOWNERS entries (#116085) The autopilot project has been removed, so clean up its two CODEOWNERS entries from the Telemetry Experience section. Co-authored-by: Claude --- .github/CODEOWNERS | 2 -- 1 file changed, 2 deletions(-) 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 From d5b0a6474e73ede809d6d964c2317518b15d7bfa Mon Sep 17 00:00:00 2001 From: Giovanni Barillari Date: Fri, 22 May 2026 13:42:51 +0200 Subject: [PATCH 5/8] feat(apigw): expose proxy latency metrics by target (#116086) SSIA --- src/apigw/config.py | 3 ++- src/apigw/proxy.py | 26 +++++++++++++++++++++++++- src/apigw/views/proxy.py | 3 ++- 3 files changed, 29 insertions(+), 3 deletions(-) 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( From c9ada5f372c4c51f914eb2a156f552683cc6ee6f Mon Sep 17 00:00:00 2001 From: Steffy Fort Date: Fri, 22 May 2026 16:39:47 +0200 Subject: [PATCH 6/8] feat: add SENTRY_ALLOWED_IPS to allow IP, overwrite SENTRY_DISALLOWD_IPS (#115773) This feature add the possibility to overwrite `SENTRY_DISALLOWED` with a simple IP (like `10.1.2.31 inside the default blocked range `10.0.0.0/8`) Fixes #100943 Signed-off-by: fe80 Co-authored-by: Mark Story --- src/sentry/conf/server.py | 2 ++ src/sentry/net/socket.py | 10 +++++++++- src/sentry/testutils/helpers/socket.py | 17 ++++++++++++++++- tests/sentry/net/test_socket.py | 22 +++++++++++++++++++--- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 86470f49795ae7..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. 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/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/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: From c855e7bd572d984c659aa11d498048de00a16be3 Mon Sep 17 00:00:00 2001 From: Reinaldy Rafli Date: Fri, 22 May 2026 22:10:36 +0700 Subject: [PATCH 7/8] fix(self-hosted): avoid install wizard mail TLS/SSL immutable errors (#114011) Closes https://github.com/getsentry/self-hosted/issues/4282 Treat mail.use-tls and mail.use-ssl as a linked pair in the required system options response for the install wizard. If either option is configured on disk with a non-default value, mark both as diskPriority so the wizard does not submit them back through `PUT /internal/options/?query=is:required`. This avoids the immutable_option failure during first-run setup when self-hosted email settings were already configured in `config.yml`, including the reported `mail.use-tls: true` + `mail.use-ssl: false` case. Add regression coverage for the disk-configured TLS/SSL case and for the control case where both values still match their defaults. --- src/sentry/api/endpoints/system_options.py | 21 +++++++++++++- .../api/endpoints/test_system_options.py | 28 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) 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/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) From e5ae9678c72b5365d435239903b3f48ade9f277c Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Fri, 22 May 2026 11:35:12 -0400 Subject: [PATCH 8/8] =?UTF-8?q?feat(autofix):=20Switch=20inspection=20to?= =?UTF-8?q?=20single=20llm=20call=20using=20gemini=20flas=E2=80=A6=20(#116?= =?UTF-8?q?071)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …h-lite This previously used Anthropic Sonnet in a multi turn agent. And it was taking far too long. Switch over to an one shot approach using gemini flash-lite which should be much faster. --- src/sentry/seer/autofix/introspection.py | 69 +++++++++++++----------- 1 file changed, 39 insertions(+), 30 deletions(-) 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(