Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
manager.add("organizations:preprod-enforce-size-quota", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable enforcement of preprod distribution quota checks (when disabled, distribution quota checks always return True)
manager.add("organizations:preprod-enforce-distribution-quota", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable preprod size analysis webhooks
manager.add("organizations:preprod-size-analysis-webhooks", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable preprod size monitors frontend
manager.add("organizations:preprod-size-monitors-frontend", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable preprod snapshots product feature
Expand Down
5 changes: 5 additions & 0 deletions src/sentry/preprod/size_analysis/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
)
from sentry.preprod.size_analysis.models import ComparisonResults, SizeAnalysisResults
from sentry.preprod.size_analysis.utils import build_size_metrics_map, can_compare_size_metrics
from sentry.preprod.size_analysis.webhooks import send_size_analysis_webhook
from sentry.preprod.vcs.status_checks.size.tasks import create_preprod_status_check_task
from sentry.silo.base import SiloMode
from sentry.tasks.base import instrumented_task
Expand Down Expand Up @@ -72,6 +73,7 @@ def compare_preprod_artifact_size_analysis(
"preprod.size_analysis.compare.artifact_no_commit_comparison",
extra={"artifact_id": artifact_id},
)
send_size_analysis_webhook(artifact=artifact, organization_id=org_id)
return

comparisons: list[dict[str, PreprodArtifactSizeMetrics]] = []
Expand All @@ -92,6 +94,7 @@ def compare_preprod_artifact_size_analysis(
"caller": "compare_build_config_mismatch",
}
)
send_size_analysis_webhook(artifact=artifact, organization_id=org_id)
return

base_size_metrics_qs = PreprodArtifactSizeMetrics.objects.filter(
Expand Down Expand Up @@ -249,6 +252,8 @@ def compare_preprod_artifact_size_analysis(
},
)

send_size_analysis_webhook(artifact=artifact, organization_id=org_id)


@instrumented_task(
name="sentry.preprod.tasks.manual_size_analysis_comparison",
Expand Down
225 changes: 225 additions & 0 deletions src/sentry/preprod/size_analysis/webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
from __future__ import annotations

import logging
from typing import Any

from sentry import features
from sentry.preprod.models import (
PreprodArtifact,
PreprodArtifactSizeComparison,
PreprodArtifactSizeMetrics,
)
from sentry.sentry_apps.metrics import SentryAppEventType
from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization
from sentry.sentry_apps.utils.webhooks import SizeAnalysisActionType

logger = logging.getLogger(__name__)


def build_webhook_payload(
artifact: PreprodArtifact,
) -> dict[str, Any] | None:
"""
Build the size_analysis.completed webhook payload for a given artifact.

Returns None if the webhook should not be fired (e.g. NOT_RAN state).
"""
main_metric = (
PreprodArtifactSizeMetrics.objects.filter(
preprod_artifact=artifact,
metrics_artifact_type=PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT,
)
.select_related("preprod_artifact")
.first()
)

if main_metric is None:
logger.info(
"preprod.size_analysis.webhook.no_main_metric",
extra={"artifact_id": artifact.id},
)
return None

if main_metric.state == PreprodArtifactSizeMetrics.SizeAnalysisState.NOT_RAN:
return None

if main_metric.state in (
PreprodArtifactSizeMetrics.SizeAnalysisState.PENDING,
PreprodArtifactSizeMetrics.SizeAnalysisState.PROCESSING,
):
logger.info(
"preprod.size_analysis.webhook.not_terminal",
extra={"artifact_id": artifact.id, "state": main_metric.state},
)
return None

analysis_failed = main_metric.state == PreprodArtifactSizeMetrics.SizeAnalysisState.FAILED

error_code = _map_error_code(main_metric.error_code) if analysis_failed else None
error_message = main_metric.error_message if analysis_failed else None

comparison_data = _build_comparison_data(artifact, main_metric)
git_data = _build_git_data(artifact)

comparison_failed = comparison_data is not None and comparison_data["status"] == "error"
status = "error" if analysis_failed or comparison_failed else "success"

mobile_app_info = artifact.get_mobile_app_info()

return {
"buildId": str(artifact.id),
"status": status,
"errorCode": error_code,
"errorMessage": error_message,
"projectSlug": artifact.project.slug,
"platform": _get_platform(artifact),
"artifactType": _get_artifact_type(artifact),
"downloadSize": main_metric.max_download_size if not analysis_failed else None,
"installSize": main_metric.max_install_size if not analysis_failed else None,
"app": {
"name": mobile_app_info.app_name if mobile_app_info else None,
"version": mobile_app_info.build_version if mobile_app_info else None,
"buildNumber": mobile_app_info.build_number if mobile_app_info else None,
},
"comparison": comparison_data,
"git": git_data,
}


def send_size_analysis_webhook(
artifact: PreprodArtifact,
organization_id: int,
) -> None:
"""
Send the size_analysis.completed webhook for a given artifact, if applicable.

Checks feature flag, builds the payload, and enqueues via the generic broadcaster.
"""
organization = artifact.project.organization

if not features.has("organizations:preprod-size-analysis-webhooks", organization):
logger.info(
"preprod.size_analysis.webhook.feature_disabled",
extra={
"artifact_id": artifact.id,
"organization_id": organization_id,
},
)
return

payload = build_webhook_payload(artifact)
if payload is None:
logger.info(
"preprod.size_analysis.webhook.no_payload",
extra={"artifact_id": artifact.id},
)
return

event_name = SizeAnalysisActionType.COMPLETED.value

try:
broadcast_webhooks_for_organization.delay(
resource_name="size_analysis",
event_name=event_name,
organization_id=organization_id,
payload=payload,
)
except Exception:
logger.exception(
"preprod.size_analysis.webhook.broadcast_failed",
extra={
"artifact_id": artifact.id,
"organization_id": organization_id,
"event_type": SentryAppEventType.SIZE_ANALYSIS_COMPLETED.value,
},
)


def _build_comparison_data(
artifact: PreprodArtifact,
main_metric: PreprodArtifactSizeMetrics,
) -> dict[str, Any] | None:
"""Build the comparison sub-object, or None if no comparison exists."""
comparison = (
PreprodArtifactSizeComparison.objects.filter(
head_size_analysis=main_metric,
)
.select_related("base_size_analysis", "base_size_analysis__preprod_artifact")
.order_by("-date_added")
.first()
)

if comparison is None:
return None

comparison_succeeded = comparison.state == PreprodArtifactSizeComparison.State.SUCCESS
comparison_status = "success" if comparison_succeeded else "error"

base_artifact = comparison.base_size_analysis.preprod_artifact

download_size_change: int | None = None
install_size_change: int | None = None

if comparison_succeeded:
head_download = main_metric.max_download_size
head_install = main_metric.max_install_size
base_download = comparison.base_size_analysis.max_download_size
base_install = comparison.base_size_analysis.max_install_size

if (
head_download is not None
and base_download is not None
and head_install is not None
and base_install is not None
):
download_size_change = head_download - base_download
install_size_change = head_install - base_install

return {
"status": comparison_status,
"baseBuildId": str(base_artifact.id),
"downloadSizeChange": download_size_change,
"installSizeChange": install_size_change,
}


def _build_git_data(artifact: PreprodArtifact) -> dict[str, Any] | None:
"""Build the git sub-object, or None if no git context exists."""
commit_comparison = artifact.commit_comparison
if commit_comparison is None:
return None

return {
"headSha": commit_comparison.head_sha,
"baseSha": commit_comparison.base_sha,
"headRef": commit_comparison.head_ref,
"baseRef": commit_comparison.base_ref,
"repoName": commit_comparison.head_repo_name,
"prNumber": commit_comparison.pr_number,
}


def _get_platform(artifact: PreprodArtifact) -> str | None:
platform = artifact.platform
if platform is None:
return None
return platform.value


def _get_artifact_type(artifact: PreprodArtifact) -> str | None:
if artifact.artifact_type is None:
return None
try:
return PreprodArtifact.ArtifactType(artifact.artifact_type).to_str()
except (ValueError, AttributeError):
return None


def _map_error_code(error_code: int | None) -> str | None:
"""Map the integer error code to the string representation for the webhook."""
if error_code is None:
return None
try:
return PreprodArtifactSizeMetrics.ErrorCode(error_code).name
except (ValueError, AttributeError):
return None
4 changes: 4 additions & 0 deletions src/sentry/preprod/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from sentry.preprod.quotas import has_installable_quota, has_size_quota
from sentry.preprod.size_analysis.models import SizeAnalysisResults
from sentry.preprod.size_analysis.tasks import compare_preprod_artifact_size_analysis
from sentry.preprod.size_analysis.webhooks import send_size_analysis_webhook
from sentry.preprod.vcs.pr_comments.tasks import create_preprod_pr_comment_task
from sentry.preprod.vcs.status_checks.size.tasks import create_preprod_status_check_task
from sentry.silo.base import SiloMode
Expand Down Expand Up @@ -606,6 +607,9 @@ def _assemble_preprod_artifact_size_analysis(
},
)

# Fire webhook for analysis failure (comparison task won't be triggered)
send_size_analysis_webhook(artifact=preprod_artifact, organization_id=org_id)

# Re-raise to trigger further error handling if needed
raise
finally:
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/sentry_apps/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,6 @@ class SentryAppEventType(StrEnum):
SEER_IMPACT_ASSESSMENT_STARTED = "seer.impact_assessment_started"
SEER_IMPACT_ASSESSMENT_COMPLETED = "seer.impact_assessment_completed"
SEER_PR_CREATED = "seer.pr_created"

# size analysis webhooks
SIZE_ANALYSIS_COMPLETED = "size_analysis.completed"
1 change: 1 addition & 0 deletions src/sentry/sentry_apps/models/sentry_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"organization": "org:read",
"team": "team:read",
"comment": "event:read",
"size_analysis": "project:read",
}

# The only events valid for Sentry Apps are the ones listed in the values of
Expand Down
8 changes: 8 additions & 0 deletions src/sentry/sentry_apps/utils/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ class SeerActionType(SentryAppActionType):
PR_CREATED = "pr_created"


class SizeAnalysisActionType(SentryAppActionType):
COMPLETED = "completed"


class SentryAppResourceType(StrEnum):
@staticmethod
def map_sentry_app_webhook_events(
Expand All @@ -71,6 +75,7 @@ def map_sentry_app_webhook_events(
INSTALLATION = "installation"
METRIC_ALERT = "metric_alert"
SEER = "seer"
SIZE_ANALYSIS = "size_analysis"

# Represents an issue alert resource
EVENT_ALERT = "event_alert"
Expand All @@ -92,6 +97,9 @@ def map_sentry_app_webhook_events(
SentryAppResourceType.SEER: SentryAppResourceType.map_sentry_app_webhook_events(
SentryAppResourceType.SEER.value, SeerActionType
),
SentryAppResourceType.SIZE_ANALYSIS: SentryAppResourceType.map_sentry_app_webhook_events(
SentryAppResourceType.SIZE_ANALYSIS.value, SizeAnalysisActionType
),
}
# We present Webhook Subscriptions per-resource (Issue, Project, etc.), not
# per-event-type (issue.created, project.deleted, etc.). These are valid
Expand Down
Loading
Loading