Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/sentry/integrations/github/webhook_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,10 @@ class GithubWebhookType(StrEnum):
PULL_REQUEST_REVIEW = "pull_request_review"
PULL_REQUEST_REVIEW_COMMENT = "pull_request_review_comment"
PUSH = "push"


# Event type strings (X-GitHub-Event header values) that the region webhook endpoint processes.
# INSTALLATION is handled in control only.
REGION_PROCESSED_GITHUB_EVENTS = frozenset(
t.value for t in GithubWebhookType if t != GithubWebhookType.INSTALLATION
)
26 changes: 25 additions & 1 deletion src/sentry/middleware/integrations/parsers/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
GitHubIntegrationsWebhookEndpoint,
get_github_external_id,
)
from sentry.integrations.github.webhook_types import GITHUB_WEBHOOK_TYPE_HEADER, GithubWebhookType
from sentry.integrations.github.webhook_types import (
GITHUB_WEBHOOK_TYPE_HEADER,
REGION_PROCESSED_GITHUB_EVENTS,
GithubWebhookType,
)
from sentry.integrations.middleware.hybrid_cloud.parser import BaseRequestParser
from sentry.integrations.models.integration import Integration
from sentry.integrations.services.integration.model import RpcIntegration
Expand Down Expand Up @@ -131,6 +135,26 @@ def get_response(self) -> HttpResponseBase:
if codecov_regions:
self.try_forward_to_codecov(event=event)

github_event = self.request.META.get(GITHUB_WEBHOOK_TYPE_HEADER)
mailbox_name = f"{self.provider}:{self.get_mailbox_identifier(integration, event)}"
allowlist = options.get("github.webhook.drop-unprocessed-events.mailbox-allowlist") or ()
mailbox_in_allowlist = any(mailbox_name == m for m in allowlist)
Comment on lines +140 to +141
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the plan to mainline this once we are confident it has a positive effect?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct.


# Only drop when we have a known unprocessed event type. Missing or empty
# X-GitHub-Event is malformed; let the request be forwarded so the region
# returns 400 and GitHub is notified of the delivery failure.
if (
github_event
and github_event not in REGION_PROCESSED_GITHUB_EVENTS
and options.get("github.webhook.drop-unprocessed-events.enabled")
and mailbox_in_allowlist
):
metrics.incr(
"github.webhook.drop_unprocessed_event",
tags={"event_type": github_event or "unknown"},
)
return HttpResponse(status=202)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant get_mailbox_identifier double-call inflates metrics

Low Severity

get_mailbox_identifier is invoked twice when the webhook is not dropped: once for mailbox_name and again for identifier in get_response_from_webhookpayload. When mailbox bucketing is disabled, each call triggers metrics.incr for hybridcloud.webhookpayload.mailbox_routing, so metrics are double-counted for every non-dropped webhook.

Fix in Cursor Fix in Web

response = self.get_response_from_webhookpayload(
regions=regions,
identifier=self.get_mailbox_identifier(integration, event),
Expand Down
11 changes: 11 additions & 0 deletions src/sentry/options/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,17 @@
default=False,
flags=FLAG_AUTOMATOR_MODIFIABLE,
)
register(
"github.webhook.drop-unprocessed-events.enabled",
default=False,
flags=FLAG_AUTOMATOR_MODIFIABLE,
)
register(
"github.webhook.drop-unprocessed-events.mailbox-allowlist",
type=Sequence,
default=[],
flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE,
)

# GitHub Console SDK App (separate app for repository invitations)
register("github-console-sdk-app.id", default=0, flags=FLAG_AUTOMATOR_MODIFIABLE)
Expand Down
171 changes: 171 additions & 0 deletions tests/sentry/middleware/integrations/parsers/test_github.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest.mock import Mock, patch

import pytest
import responses
from django.db import router, transaction
Expand Down Expand Up @@ -411,6 +413,175 @@ def test_webhook_without_repository_falls_back_to_single_mailbox(self) -> None:
)


@control_silo_test
class GithubRequestParserDropUnprocessedEventsTest(TestCase):
"""Tests for dropping GitHub webhook events that the region does not process."""

factory = RequestFactory()
path = reverse("sentry-integration-github-webhook")

def get_response(self, req: HttpRequest) -> HttpResponse:
return HttpResponse(status=200, content="passthrough")

def get_integration(self) -> Integration:
return self.create_integration(
organization=self.organization,
external_id="1",
provider="github",
)

@override_settings(SILO_MODE=SiloMode.CONTROL)
@override_regions(region_config)
@responses.activate
@patch("sentry.middleware.integrations.parsers.github.metrics")
def test_drops_unprocessed_event_when_flag_and_allowlist(self, mock_metrics: Mock) -> None:
"""With flag on and mailbox in allowlist, status event is dropped and metric is incremented."""
integration = self.get_integration()
mailbox_name = f"github:{integration.id}"
with override_options(
{
"github.webhook.drop-unprocessed-events.enabled": True,
"github.webhook.drop-unprocessed-events.mailbox-allowlist": [mailbox_name],
}
):
request = self.factory.post(
self.path,
data={"installation": {"id": "1"}, "repository": {"id": 123}},
content_type="application/json",
headers={"X-GITHUB-EVENT": "status"},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't handle status in any regions.

)
parser = GithubRequestParser(request=request, response_handler=self.get_response)
response = parser.get_response()

assert isinstance(response, HttpResponse)
assert response.status_code == status.HTTP_202_ACCEPTED
assert_no_webhook_payloads()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def assert_no_webhook_payloads() -> None:
messages = WebhookPayload.objects.filter().count()
assert messages == 0, "No webhookpayload messages should be created"

mock_metrics.incr.assert_any_call(
"github.webhook.drop_unprocessed_event",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We dropped it and track it.

tags={"event_type": "status"},
)

@override_settings(SILO_MODE=SiloMode.CONTROL)
@override_regions(region_config)
@override_options(
{
"github.webhook.drop-unprocessed-events.enabled": False,
"github.webhook.drop-unprocessed-events.mailbox-allowlist": ["github:99999"],
}
)
@responses.activate
def test_does_not_drop_when_flag_off_creates_payloads(self) -> None:
"""With flag off, unprocessed event still creates WebhookPayloads."""
integration = self.get_integration()
request = self.factory.post(
self.path,
data={"installation": {"id": "1"}, "repository": {"id": 123}},
content_type="application/json",
headers={"X-GITHUB-EVENT": "status"},
)
parser = GithubRequestParser(request=request, response_handler=self.get_response)
response = parser.get_response()

assert isinstance(response, HttpResponse)
assert response.status_code == status.HTTP_202_ACCEPTED
assert_webhook_payloads_for_mailbox(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It got recorded into the table even though it's a status event.

request=request,
mailbox_name=f"github:{integration.id}",
region_names=[region.name],
)

@override_settings(SILO_MODE=SiloMode.CONTROL)
@override_regions(region_config)
@override_options(
{
"github.webhook.drop-unprocessed-events.enabled": True,
"github.webhook.drop-unprocessed-events.mailbox-allowlist": ["github:other"],
}
)
@responses.activate
def test_does_not_drop_when_mailbox_not_in_allowlist_creates_payloads(self) -> None:
"""With mailbox not in allowlist, unprocessed event still creates WebhookPayloads."""
integration = self.get_integration()
request = self.factory.post(
self.path,
data={"installation": {"id": "1"}, "repository": {"id": 123}},
content_type="application/json",
headers={"X-GITHUB-EVENT": "status"},
)
parser = GithubRequestParser(request=request, response_handler=self.get_response)
response = parser.get_response()

assert isinstance(response, HttpResponse)
assert response.status_code == status.HTTP_202_ACCEPTED
assert_webhook_payloads_for_mailbox(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Written to DB as expected.

request=request,
mailbox_name=f"github:{integration.id}",
region_names=[region.name],
)

@override_settings(SILO_MODE=SiloMode.CONTROL)
@override_regions(region_config)
@responses.activate
def test_supported_event_never_dropped_even_with_flag_and_allowlist(self) -> None:
"""Supported event (push) is never dropped even when flag on and mailbox in allowlist."""
integration = self.get_integration()
with override_options(
{
"github.webhook.drop-unprocessed-events.enabled": True,
"github.webhook.drop-unprocessed-events.mailbox-allowlist": [
f"github:{integration.id}"
],
}
):
request = self.factory.post(
self.path,
data={"installation": {"id": "1"}, "repository": {"id": 123}},
content_type="application/json",
headers={"X-GITHUB-EVENT": GithubWebhookType.PUSH.value},
)
parser = GithubRequestParser(request=request, response_handler=self.get_response)
response = parser.get_response()

assert isinstance(response, HttpResponse)
assert response.status_code == status.HTTP_202_ACCEPTED
assert_webhook_payloads_for_mailbox(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Written to DB as expected.

request=request,
mailbox_name=f"github:{integration.id}",
region_names=[region.name],
)

@override_settings(SILO_MODE=SiloMode.CONTROL)
@override_regions(region_config)
@responses.activate
def test_missing_x_github_event_not_dropped_forwards_to_region(self) -> None:
"""Missing X-GitHub-Event is not dropped; request is forwarded so region returns 400."""
integration = self.get_integration()
with override_options(
{
"github.webhook.drop-unprocessed-events.enabled": True,
"github.webhook.drop-unprocessed-events.mailbox-allowlist": [
f"github:{integration.id}"
],
}
):
request = self.factory.post(
self.path,
data={"installation": {"id": "1"}, "repository": {"id": 123}},
content_type="application/json",
# No X-GitHub-Event header
)
parser = GithubRequestParser(request=request, response_handler=self.get_response)
response = parser.get_response()

assert isinstance(response, HttpResponse)
assert response.status_code == status.HTTP_202_ACCEPTED
assert_webhook_payloads_for_mailbox(
request=request,
mailbox_name=f"github:{integration.id}",
region_names=[region.name],
)


@control_silo_test
class GithubRequestParserTypeRoutingTest(GithubRequestParserTest):
"""
Expand Down
Loading