Skip to content

Commit 3ab5d5b

Browse files
feat(seer): Seer webhooks + generic webhook broadcaster (#96222)
Implements a generic webhook broadcaster in `broadcast_webhooks_for_organization` in `src/sentry/sentry_apps/webhooks.py` and calls it via Seer RPC. We'll fully pass the event type and payload to it from Seer and have Seer handle what's actually sent there. We also register the webhook event types: ``` "seer.root_cause_started", "seer.root_cause_completed", "seer.solution_started", "seer.solution_completed", "seer.coding_started", "seer.coding_completed", "seer.pr_created", ``` and the Seer RPC function is guarded by a feature flag `organizations:seer-webhooks` --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 580a7f0 commit 3ab5d5b

File tree

9 files changed

+939
-5
lines changed

9 files changed

+939
-5
lines changed

src/sentry/features/temporary.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,8 @@ def register_temporary_features(manager: FeatureManager):
398398
manager.add("organizations:insights-mobile-screens-module", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
399399
# Removes performance landing page from sidebar and updates transaction summary breadcrumbs for insights
400400
manager.add("organizations:insights-performance-landing-removal", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
401+
# Seer Webhooks to be sent and listened to
402+
manager.add("organizations:seer-webhooks", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
401403
# Enable new SentryApp webhook request endpoint
402404
manager.add("organizations:sentry-app-webhook-requests", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
403405
# Enable standalone span ingestion

src/sentry/seer/endpoints/seer_rpc.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue, StrArray
3636
from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter, TraceItemFilter
3737

38-
from sentry import options
38+
from sentry import features, options
3939
from sentry.api.api_owners import ApiOwner
4040
from sentry.api.api_publish_status import ApiPublishStatus
4141
from sentry.api.authentication import AuthenticationSiloLimit, StandardAuthentication
@@ -52,7 +52,7 @@
5252
from sentry.integrations.github_enterprise.integration import GitHubEnterpriseIntegration
5353
from sentry.integrations.services.integration import integration_service
5454
from sentry.integrations.types import IntegrationProviderSlug
55-
from sentry.models.organization import Organization
55+
from sentry.models.organization import Organization, OrganizationStatus
5656
from sentry.models.repository import Repository
5757
from sentry.search.eap.resolver import SearchResolver
5858
from sentry.search.eap.spans.definitions import SPAN_DEFINITIONS
@@ -75,6 +75,7 @@
7575
get_latest_issue_event,
7676
)
7777
from sentry.seer.seer_setup import get_seer_org_acknowledgement
78+
from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization
7879
from sentry.silo.base import SiloMode
7980
from sentry.snuba.referrer import Referrer
8081
from sentry.utils import snuba_rpc
@@ -602,6 +603,56 @@ def get_github_enterprise_integration_config(
602603
}
603604

604605

606+
def send_seer_webhook(*, event_name: str, organization_id: int, payload: dict) -> dict:
607+
"""
608+
Send a seer webhook event for an organization.
609+
610+
Args:
611+
event_name: The sub-name of seer event (e.g., "root_cause_started")
612+
organization_id: The ID of the organization to send the webhook for
613+
payload: The webhook payload data
614+
615+
Returns:
616+
dict: Status of the webhook sending operation
617+
"""
618+
# Validate event_name by constructing the full event type and checking if it's valid
619+
from sentry.sentry_apps.metrics import SentryAppEventType
620+
621+
event_type = f"seer.{event_name}"
622+
try:
623+
SentryAppEventType(event_type)
624+
except ValueError:
625+
logger.exception(
626+
"seer.webhook_invalid_event_type",
627+
extra={"event_type": event_type},
628+
)
629+
return {"success": False, "error": f"Invalid event type: {event_type}"}
630+
631+
# Handle organization lookup safely
632+
try:
633+
organization = Organization.objects.get(
634+
id=organization_id, status=OrganizationStatus.ACTIVE
635+
)
636+
except Organization.DoesNotExist:
637+
logger.exception(
638+
"seer.webhook_organization_not_found_or_not_active",
639+
extra={"organization_id": organization_id},
640+
)
641+
return {"success": False, "error": "Organization not found or not active"}
642+
643+
if not features.has("organizations:seer-webhooks", organization):
644+
return {"success": False, "error": "Seer webhooks are not enabled for this organization"}
645+
646+
broadcast_webhooks_for_organization.delay(
647+
resource_name="seer",
648+
event_name=event_name,
649+
organization_id=organization_id,
650+
payload=payload,
651+
)
652+
653+
return {"success": True}
654+
655+
605656
seer_method_registry: dict[str, Callable[..., dict[str, Any]]] = {
606657
"get_organization_slug": get_organization_slug,
607658
"get_sentry_organization_ids": get_sentry_organization_ids,
@@ -621,6 +672,7 @@ def get_github_enterprise_integration_config(
621672
"get_profiles_for_trace": rpc_get_profiles_for_trace,
622673
"get_issues_for_transaction": rpc_get_issues_for_transaction,
623674
"get_github_enterprise_integration_config": get_github_enterprise_integration_config,
675+
"send_seer_webhook": send_seer_webhook,
624676
}
625677

626678

src/sentry/sentry_apps/metrics.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,12 @@ class SentryAppEventType(StrEnum):
136136
WEBHOOK_UPDATE = "webhook_update"
137137
INSTALLATION_CREATE = "install_create"
138138
INSTALLATION_WEBHOOK_UPDATE = "installation_webhook_update"
139+
140+
# seer webhooks
141+
SEER_ROOT_CAUSE_STARTED = "seer.root_cause_started"
142+
SEER_ROOT_CAUSE_COMPLETED = "seer.root_cause_completed"
143+
SEER_SOLUTION_STARTED = "seer.solution_started"
144+
SEER_SOLUTION_COMPLETED = "seer.solution_completed"
145+
SEER_CODING_STARTED = "seer.coding_started"
146+
SEER_CODING_COMPLETED = "seer.coding_completed"
147+
SEER_PR_CREATED = "seer.pr_created"

src/sentry/sentry_apps/models/sentry_app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
REQUIRED_EVENT_PERMISSIONS = {
3939
"issue": "event:read",
4040
"error": "event:read",
41+
"seer": "event:read",
4142
"project": "project:read",
4243
"member": "member:read",
4344
"organization": "org:read",

src/sentry/sentry_apps/tasks/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .sentry_apps import (
2+
broadcast_webhooks_for_organization,
23
build_comment_webhook,
34
clear_region_cache,
45
create_or_update_service_hooks_for_sentry_app,
@@ -13,15 +14,16 @@
1314
from .service_hooks import process_service_hook
1415

1516
__all__ = (
17+
"broadcast_webhooks_for_organization",
1618
"build_comment_webhook",
1719
"clear_region_cache",
1820
"create_or_update_service_hooks_for_sentry_app",
1921
"installation_webhook",
2022
"process_resource_change_bound",
21-
"send_resource_change_webhook",
22-
"workflow_notification",
2323
"process_service_hook",
24+
"regenerate_service_hooks_for_installation",
2425
"send_alert_webhook",
2526
"send_alert_webhook_v2",
26-
"regenerate_service_hooks_for_installation",
27+
"send_resource_change_webhook",
28+
"workflow_notification",
2729
)

src/sentry/sentry_apps/tasks/sentry_apps.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,3 +892,91 @@ def regenerate_service_hooks_for_installation(
892892
events=events,
893893
url=webhook_url,
894894
)
895+
896+
897+
@instrumented_task(
898+
name="sentry.sentry_apps.tasks.sentry_apps.broadcast_webhooks_for_organization",
899+
taskworker_config=TaskworkerConfig(
900+
namespace=sentryapp_tasks,
901+
retry=Retry(
902+
times=3,
903+
delay=60 * 5,
904+
),
905+
processing_deadline_duration=30,
906+
),
907+
**TASK_OPTIONS,
908+
)
909+
def broadcast_webhooks_for_organization(
910+
*,
911+
resource_name: str,
912+
event_name: str,
913+
organization_id: int,
914+
payload: dict[str, Any],
915+
**kwargs: Any,
916+
) -> None:
917+
"""
918+
Send a webhook event to all relevant installations for an organization.
919+
920+
Args:
921+
resource_name: The resource name (e.g., "seer", "issue", "error")
922+
event_name: The event name (e.g., "root_cause_started", "created")
923+
organization_id: The ID of the organization to send webhooks for
924+
payload: The webhook payload data
925+
926+
Returns:
927+
dict: Status of the webhook sending operation including success status,
928+
message, and error details if applicable
929+
"""
930+
# Construct full event type for validation
931+
event_type = f"{resource_name}.{event_name}"
932+
933+
# Validate event type by checking if it's a valid SentryAppEventType
934+
try:
935+
SentryAppEventType(event_type)
936+
except ValueError:
937+
logger.exception("sentry_app.webhook_invalid_event_type", extra={"event_type": event_type})
938+
939+
raise SentryAppSentryError(
940+
message=f"Invalid event type: {event_type}",
941+
)
942+
943+
with SentryAppInteractionEvent(
944+
operation_type=SentryAppInteractionType.PREPARE_WEBHOOK,
945+
event_type=event_type,
946+
).capture():
947+
# Get installations for this organization
948+
installations = app_service.installations_for_organization(organization_id=organization_id)
949+
950+
# Filter for installations that subscribe to the event category
951+
from sentry.sentry_apps.logic import consolidate_events
952+
953+
relevant_installations = [
954+
installation
955+
for installation in installations
956+
if resource_name in consolidate_events(installation.sentry_app.events)
957+
]
958+
959+
if not relevant_installations:
960+
logger.error(
961+
"sentry_app.webhook_no_installations_subscribed",
962+
extra={
963+
"resource_name": resource_name,
964+
"organization_id": organization_id,
965+
},
966+
)
967+
return
968+
969+
# Send the webhook to each relevant installation
970+
for installation in relevant_installations:
971+
if installation:
972+
send_resource_change_webhook.delay(installation.id, event_type, payload)
973+
974+
logger.info(
975+
"sentry_app.webhook_queued",
976+
extra={"event_type": event_type, "installation_id": installation.id},
977+
)
978+
else:
979+
logger.error(
980+
"sentry_app.webhook_no_installation",
981+
extra={"event_type": event_type, "organization_id": organization_id},
982+
)

src/sentry/sentry_apps/utils/webhooks.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ class InstallationActionType(SentryAppActionType):
4040
DELETED = "deleted"
4141

4242

43+
class SeerActionType(SentryAppActionType):
44+
ROOT_CAUSE_STARTED = "root_cause_started"
45+
ROOT_CAUSE_COMPLETED = "root_cause_completed"
46+
SOLUTION_STARTED = "solution_started"
47+
SOLUTION_COMPLETED = "solution_completed"
48+
CODING_STARTED = "coding_started"
49+
CODING_COMPLETED = "coding_completed"
50+
PR_CREATED = "pr_created"
51+
52+
4353
class SentryAppResourceType(StrEnum):
4454

4555
@staticmethod
@@ -57,6 +67,7 @@ def map_sentry_app_webhook_events(
5767
COMMENT = "comment"
5868
INSTALLATION = "installation"
5969
METRIC_ALERT = "metric_alert"
70+
SEER = "seer"
6071

6172
# Represents an issue alert resource
6273
EVENT_ALERT = "event_alert"
@@ -75,6 +86,9 @@ def map_sentry_app_webhook_events(
7586
SentryAppResourceType.COMMENT: SentryAppResourceType.map_sentry_app_webhook_events(
7687
SentryAppResourceType.COMMENT.value, CommentActionType
7788
),
89+
SentryAppResourceType.SEER: SentryAppResourceType.map_sentry_app_webhook_events(
90+
SentryAppResourceType.SEER.value, SeerActionType
91+
),
7892
}
7993
# We present Webhook Subscriptions per-resource (Issue, Project, etc.), not
8094
# per-event-type (issue.created, project.deleted, etc.). These are valid

tests/sentry/seer/endpoints/test_seer_rpc.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,3 +593,123 @@ def test_get_sentry_organization_ids_multiple_orgs_same_repo(self) -> None:
593593
)
594594

595595
assert result == {"org_ids": [self.organization.id, org2.id]}
596+
597+
def test_send_seer_webhook_invalid_event_name(self) -> None:
598+
"""Test that send_seer_webhook returns error for invalid event names"""
599+
from sentry.seer.endpoints.seer_rpc import send_seer_webhook
600+
601+
# Test with an invalid event name
602+
result = send_seer_webhook(
603+
event_name="invalid_event_name",
604+
organization_id=self.organization.id,
605+
payload={"test": "data"},
606+
)
607+
608+
assert result == {
609+
"success": False,
610+
"error": "Invalid event type: seer.invalid_event_name",
611+
}
612+
613+
def test_send_seer_webhook_organization_does_not_exist(self) -> None:
614+
"""Test that send_seer_webhook returns error for non-existent organization"""
615+
from sentry.seer.endpoints.seer_rpc import send_seer_webhook
616+
617+
# Test with a non-existent organization ID
618+
result = send_seer_webhook(
619+
event_name="root_cause_started",
620+
organization_id=99999,
621+
payload={"test": "data"},
622+
)
623+
624+
assert result == {
625+
"success": False,
626+
"error": "Organization not found or not active",
627+
}
628+
629+
def test_send_seer_webhook_organization_inactive(self) -> None:
630+
"""Test that send_seer_webhook returns error for inactive organization"""
631+
from sentry.models.organization import OrganizationStatus
632+
from sentry.seer.endpoints.seer_rpc import send_seer_webhook
633+
634+
# Create an inactive organization
635+
inactive_org = self.create_organization(status=OrganizationStatus.PENDING_DELETION)
636+
637+
result = send_seer_webhook(
638+
event_name="root_cause_started",
639+
organization_id=inactive_org.id,
640+
payload={"test": "data"},
641+
)
642+
643+
assert result == {
644+
"success": False,
645+
"error": "Organization not found or not active",
646+
}
647+
648+
@patch("sentry.features.has")
649+
def test_send_seer_webhook_feature_disabled(self, mock_features_has) -> None:
650+
"""Test that send_seer_webhook returns error when feature is disabled"""
651+
from sentry.seer.endpoints.seer_rpc import send_seer_webhook
652+
653+
mock_features_has.return_value = False
654+
655+
result = send_seer_webhook(
656+
event_name="root_cause_started",
657+
organization_id=self.organization.id,
658+
payload={"test": "data"},
659+
)
660+
661+
assert result == {
662+
"success": False,
663+
"error": "Seer webhooks are not enabled for this organization",
664+
}
665+
mock_features_has.assert_called_once_with("organizations:seer-webhooks", self.organization)
666+
667+
@patch("sentry.features.has")
668+
@patch("sentry.sentry_apps.tasks.sentry_apps.broadcast_webhooks_for_organization.delay")
669+
def test_send_seer_webhook_success(self, mock_delay, mock_features_has) -> None:
670+
"""Test that send_seer_webhook successfully enqueues webhook when all conditions are met"""
671+
from sentry.seer.endpoints.seer_rpc import send_seer_webhook
672+
673+
mock_features_has.return_value = True
674+
675+
result = send_seer_webhook(
676+
event_name="root_cause_started",
677+
organization_id=self.organization.id,
678+
payload={"test": "data"},
679+
)
680+
681+
assert result == {"success": True}
682+
mock_features_has.assert_called_once_with("organizations:seer-webhooks", self.organization)
683+
mock_delay.assert_called_once_with(
684+
resource_name="seer",
685+
event_name="root_cause_started",
686+
organization_id=self.organization.id,
687+
payload={"test": "data"},
688+
)
689+
690+
@patch("sentry.features.has")
691+
@patch("sentry.sentry_apps.tasks.sentry_apps.broadcast_webhooks_for_organization.delay")
692+
def test_send_seer_webhook_all_valid_event_names(self, mock_delay, mock_features_has) -> None:
693+
"""Test that send_seer_webhook works with all valid seer event names"""
694+
from sentry.seer.endpoints.seer_rpc import send_seer_webhook
695+
from sentry.sentry_apps.metrics import SentryAppEventType
696+
697+
mock_features_has.return_value = True
698+
699+
# Get all seer event types
700+
seer_events = [
701+
event_type.value.split(".", 1)[1] # Remove "seer." prefix
702+
for event_type in SentryAppEventType
703+
if event_type.value.startswith("seer.")
704+
]
705+
706+
for event_name in seer_events:
707+
result = send_seer_webhook(
708+
event_name=event_name,
709+
organization_id=self.organization.id,
710+
payload={"test": "data"},
711+
)
712+
assert result == {"success": True}
713+
714+
# Verify that the task was called for each valid event
715+
assert mock_delay.call_count == len(seer_events)

0 commit comments

Comments
 (0)