From b89a298598103b5d50bbedfc4b7d1de1ea96cd96 Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Mon, 4 May 2026 16:43:43 -0700 Subject: [PATCH 1/4] chore(integrations): log Jira issue.updated webhook payloads behind a flag Adds a temporary `organizations:jira-issue-updated-payload-logging` Flagpole feature that, when enabled for a linked org, causes the Jira Cloud `issue.updated` webhook handler to log the full webhook payload plus a focused log line whenever the changelog contains a `project` change. We need this data to design issue-link rewriting when a Jira issue is moved between projects. --- src/sentry/features/temporary.py | 2 + .../jira/webhooks/issue_updated.py | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 7d20ac461b9bd6..63f28d666bcec1 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -169,6 +169,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-github-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github_enterprise-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-gitlab-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Temporary: log full Jira Cloud `issue.updated` webhook payloads so we can design project-change link rewriting. + manager.add("organizations:jira-issue-updated-payload-logging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable inviting billing members to organizations at the member limit. manager.add("organizations:invite-billing", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=False, api_expose=False) # Enable inviting members to organizations. diff --git a/src/sentry/integrations/jira/webhooks/issue_updated.py b/src/sentry/integrations/jira/webhooks/issue_updated.py index cbb1ab6b642c83..2ef3a1325dd58c 100644 --- a/src/sentry/integrations/jira/webhooks/issue_updated.py +++ b/src/sentry/integrations/jira/webhooks/issue_updated.py @@ -10,11 +10,14 @@ from rest_framework.response import Response from sentry_sdk import Scope +from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint +from sentry.integrations.services.integration import integration_service from sentry.integrations.utils.atlassian_connect import get_integration_from_jwt from sentry.integrations.utils.scope import bind_org_context_from_integration +from sentry.organizations.services.organization import organization_service from sentry.ratelimits.config import RateLimitConfig from sentry.shared_integrations.exceptions import ApiError from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -24,6 +27,26 @@ logger = logging.getLogger(__name__) +PAYLOAD_LOGGING_FEATURE = "organizations:jira-issue-updated-payload-logging" + + +def _payload_logging_enabled(integration_id: int) -> bool: + """True if any org linked to this Jira integration has the + `jira-issue-updated-payload-logging` feature enabled. + + A Jira integration can be shared by multiple Sentry orgs, and + `features.has` needs an `Organization`, so we have to walk the linked + `OrganizationIntegration` rows and look each org up. + """ + contexts = integration_service.organization_contexts(integration_id=integration_id) + for oi in contexts.organization_integrations: + org = organization_service.get_organization_by_id( + id=oi.organization_id, include_teams=False, include_projects=False + ) + if org and features.has(PAYLOAD_LOGGING_FEATURE, org.organization): + return True + return False + @cell_silo_endpoint class JiraIssueUpdatedWebhook(JiraWebhookBase): @@ -75,6 +98,36 @@ def post(self, request: Request, *args, **kwargs) -> Response: sentry_sdk.set_tag("integration_id", rpc_integration.id) data = request.data + + # Temporary: when a linked org has the + # `jira-issue-updated-payload-logging` feature enabled, log the full + # webhook payload so we can see exactly what Jira sends us (especially + # for `project` changes, which we want to use to update the linked + # Jira issue link in Sentry). + if _payload_logging_enabled(rpc_integration.id): + issue = data.get("issue") or {} + fields = issue.get("fields") or {} + changelog_items = (data.get("changelog") or {}).get("items") or [] + payload_extra = { + "integration_id": rpc_integration.id, + "issue_key": issue.get("key"), + "issue_id": issue.get("id"), + "webhook_event": data.get("webhookEvent"), + "changed_fields": [item.get("field") for item in changelog_items], + "project": fields.get("project"), + "payload": data, + } + logger.info("jira.issue-updated.payload", extra=payload_extra) + project_change = next( + (item for item in changelog_items if item.get("field") == "project"), + None, + ) + if project_change is not None: + logger.info( + "jira.issue-updated.project-changed", + extra={**payload_extra, "project_change": project_change}, + ) + if not data.get("changelog"): logger.info("jira.missing-changelog", extra={"integration_id": rpc_integration.id}) return self.respond() From f18cce3b490bce468b912a85c5f3e7c9c9e60c5a Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Tue, 5 May 2026 10:28:50 -0700 Subject: [PATCH 2/4] fix(integrations): Don't fail Jira issue.updated webhook on payload-logging errors The temporary `_payload_logging_enabled` feature-flag check makes RPC calls (`integration_service.organization_contexts` plus per-org `organization_service.get_organization_by_id`) on every issue.updated webhook before the real handlers run. Any transient RPC failure propagated up and turned into a 500, skipping `handle_assignee_change` and `handle_status_change` entirely. Wrap the diagnostic block in `try/except` so failures are reported via `logger.exception` but the webhook still drives assignee and status sync. Add a regression test that forces `_payload_logging_enabled` to raise and asserts the assignee handler still runs. Co-Authored-By: Claude Opus 4.7 Co-authored-by: Cursor --- .../jira/webhooks/issue_updated.py | 54 +++++++++++-------- .../sentry/integrations/jira/test_webhooks.py | 28 ++++++++++ 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/issue_updated.py b/src/sentry/integrations/jira/webhooks/issue_updated.py index 2ef3a1325dd58c..0c33036d085239 100644 --- a/src/sentry/integrations/jira/webhooks/issue_updated.py +++ b/src/sentry/integrations/jira/webhooks/issue_updated.py @@ -104,29 +104,39 @@ def post(self, request: Request, *args, **kwargs) -> Response: # webhook payload so we can see exactly what Jira sends us (especially # for `project` changes, which we want to use to update the linked # Jira issue link in Sentry). - if _payload_logging_enabled(rpc_integration.id): - issue = data.get("issue") or {} - fields = issue.get("fields") or {} - changelog_items = (data.get("changelog") or {}).get("items") or [] - payload_extra = { - "integration_id": rpc_integration.id, - "issue_key": issue.get("key"), - "issue_id": issue.get("id"), - "webhook_event": data.get("webhookEvent"), - "changed_fields": [item.get("field") for item in changelog_items], - "project": fields.get("project"), - "payload": data, - } - logger.info("jira.issue-updated.payload", extra=payload_extra) - project_change = next( - (item for item in changelog_items if item.get("field") == "project"), - None, - ) - if project_change is not None: - logger.info( - "jira.issue-updated.project-changed", - extra={**payload_extra, "project_change": project_change}, + # + # This is purely diagnostic, so swallow any failure (including transient + # RPC errors from the feature-flag check) rather than letting it skip + # the real `handle_assignee_change` / `handle_status_change` calls below. + try: + if _payload_logging_enabled(rpc_integration.id): + issue = data.get("issue") or {} + fields = issue.get("fields") or {} + changelog_items = (data.get("changelog") or {}).get("items") or [] + payload_extra = { + "integration_id": rpc_integration.id, + "issue_key": issue.get("key"), + "issue_id": issue.get("id"), + "webhook_event": data.get("webhookEvent"), + "changed_fields": [item.get("field") for item in changelog_items], + "project": fields.get("project"), + "payload": data, + } + logger.info("jira.issue-updated.payload", extra=payload_extra) + project_change = next( + (item for item in changelog_items if item.get("field") == "project"), + None, ) + if project_change is not None: + logger.info( + "jira.issue-updated.project-changed", + extra={**payload_extra, "project_change": project_change}, + ) + except Exception: + logger.exception( + "jira.issue-updated.payload-logging-failed", + extra={"integration_id": rpc_integration.id}, + ) if not data.get("changelog"): logger.info("jira.missing-changelog", extra={"integration_id": rpc_integration.id}) diff --git a/tests/sentry/integrations/jira/test_webhooks.py b/tests/sentry/integrations/jira/test_webhooks.py index 05e1c9f5798b28..e436a1afccc4dd 100644 --- a/tests/sentry/integrations/jira/test_webhooks.py +++ b/tests/sentry/integrations/jira/test_webhooks.py @@ -216,6 +216,34 @@ def test_missing_changelog(self) -> None: data = StubService.get_stub_data("jira", "changelog_missing.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) + @patch("sentry.integrations.jira.webhooks.issue_updated.logger") + @patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound") + @patch("sentry.integrations.jira.webhooks.issue_updated._payload_logging_enabled") + def test_payload_logging_failure_does_not_skip_handlers( + self, + mock_payload_logging_enabled: MagicMock, + mock_sync_group_assignee_inbound: MagicMock, + mock_logger: MagicMock, + ) -> None: + # The diagnostic payload-logging path makes RPC calls that could fail + # transiently. Those failures must not prevent the real webhook + # handlers from running. + mock_payload_logging_enabled.side_effect = RuntimeError("transient RPC failure") + + with patch( + "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", + return_value=self.integration, + ): + data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") + self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) + + mock_sync_group_assignee_inbound.assert_called_with( + self.integration, "jess@sentry.io", "APP-123", assign=True + ) + assert ( + mock_logger.exception.call_args.args[0] == "jira.issue-updated.payload-logging-failed" + ) + class MockErroringJiraEndpoint(JiraWebhookBase): permission_classes = () From 9f8a7ae2e1957e9c1c726de3c8b2be3dae3bbaed Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Tue, 5 May 2026 13:10:12 -0700 Subject: [PATCH 3/4] perf(integrations): Batch org lookups in Jira issue.updated payload-logging check The newly-added `_payload_logging_enabled` check fanned out one `organization_service.get_organization_by_id` RPC per linked Sentry org just to evaluate a feature flag, on every webhook regardless of whether the flag was on for any org. A Jira integration shared by N orgs paid N RPCs per `issue.updated` webhook. Replace the per-org `get_organization_by_id` loop with a single batched `Organization.objects.get_many_from_cache(...)`. The endpoint is `@cell_silo_endpoint`, so the region-cache path is always available. Net per webhook: 0 per-org `get_organization_by_id` calls in the feature check, down from N. Co-authored-by: Cursor --- .../jira/webhooks/issue_updated.py | 28 ++++++++++--------- .../sentry/integrations/jira/test_webhooks.py | 19 +++++++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/issue_updated.py b/src/sentry/integrations/jira/webhooks/issue_updated.py index 0c33036d085239..3b6ae14b9ae5f3 100644 --- a/src/sentry/integrations/jira/webhooks/issue_updated.py +++ b/src/sentry/integrations/jira/webhooks/issue_updated.py @@ -14,10 +14,12 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint -from sentry.integrations.services.integration import integration_service from sentry.integrations.utils.atlassian_connect import get_integration_from_jwt -from sentry.integrations.utils.scope import bind_org_context_from_integration -from sentry.organizations.services.organization import organization_service +from sentry.integrations.utils.scope import ( + bind_org_context_from_integration, + get_org_integrations, +) +from sentry.models.organization import Organization from sentry.ratelimits.config import RateLimitConfig from sentry.shared_integrations.exceptions import ApiError from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -35,17 +37,17 @@ def _payload_logging_enabled(integration_id: int) -> bool: `jira-issue-updated-payload-logging` feature enabled. A Jira integration can be shared by multiple Sentry orgs, and - `features.has` needs an `Organization`, so we have to walk the linked - `OrganizationIntegration` rows and look each org up. + `features.has` needs an `Organization`, so we walk the linked + `OrganizationIntegration` rows and batch the org lookup through the + region-silo cache (the endpoint is `@cell_silo_endpoint`, so the + region-cache path is always available) to avoid issuing N RPCs on every + webhook. """ - contexts = integration_service.organization_contexts(integration_id=integration_id) - for oi in contexts.organization_integrations: - org = organization_service.get_organization_by_id( - id=oi.organization_id, include_teams=False, include_projects=False - ) - if org and features.has(PAYLOAD_LOGGING_FEATURE, org.organization): - return True - return False + org_ids = [oi.organization_id for oi in get_org_integrations(integration_id)] + if not org_ids: + return False + organizations = Organization.objects.get_many_from_cache(org_ids) + return any(features.has(PAYLOAD_LOGGING_FEATURE, org) for org in organizations) @cell_silo_endpoint diff --git a/tests/sentry/integrations/jira/test_webhooks.py b/tests/sentry/integrations/jira/test_webhooks.py index e436a1afccc4dd..e743aad77dd0a9 100644 --- a/tests/sentry/integrations/jira/test_webhooks.py +++ b/tests/sentry/integrations/jira/test_webhooks.py @@ -16,6 +16,7 @@ from sentry.organizations.services.organization.serial import serialize_rpc_organization from sentry.shared_integrations.exceptions import ApiError from sentry.testutils.cases import APITestCase, TestCase +from sentry.testutils.helpers.features import with_feature from sentry.testutils.helpers.options import override_options from sentry.viewer_context import ActorType, get_viewer_context @@ -244,6 +245,24 @@ def test_payload_logging_failure_does_not_skip_handlers( mock_logger.exception.call_args.args[0] == "jira.issue-updated.payload-logging-failed" ) + @with_feature("organizations:jira-issue-updated-payload-logging") + @patch("sentry.integrations.jira.webhooks.issue_updated.logger") + @patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound") + def test_payload_logging_logs_when_feature_enabled( + self, + mock_sync_group_assignee_inbound: MagicMock, + mock_logger: MagicMock, + ) -> None: + with patch( + "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", + return_value=self.integration, + ): + data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") + self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) + + info_event_names = [call.args[0] for call in mock_logger.info.call_args_list] + assert "jira.issue-updated.payload" in info_event_names + class MockErroringJiraEndpoint(JiraWebhookBase): permission_classes = () From 5e248f63eea7dd3fd47aeeb3b020ad7833bbbe4f Mon Sep 17 00:00:00 2001 From: Grant Patterson Date: Tue, 5 May 2026 15:45:38 -0700 Subject: [PATCH 4/4] Capture exception in Sentry SDK instead --- .../integrations/jira/webhooks/issue_updated.py | 7 ++----- tests/sentry/integrations/jira/test_webhooks.py | 11 +++++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/sentry/integrations/jira/webhooks/issue_updated.py b/src/sentry/integrations/jira/webhooks/issue_updated.py index 3b6ae14b9ae5f3..913a38931dcbf4 100644 --- a/src/sentry/integrations/jira/webhooks/issue_updated.py +++ b/src/sentry/integrations/jira/webhooks/issue_updated.py @@ -134,11 +134,8 @@ def post(self, request: Request, *args, **kwargs) -> Response: "jira.issue-updated.project-changed", extra={**payload_extra, "project_change": project_change}, ) - except Exception: - logger.exception( - "jira.issue-updated.payload-logging-failed", - extra={"integration_id": rpc_integration.id}, - ) + except Exception as e: + sentry_sdk.capture_exception(e) if not data.get("changelog"): logger.info("jira.missing-changelog", extra={"integration_id": rpc_integration.id}) diff --git a/tests/sentry/integrations/jira/test_webhooks.py b/tests/sentry/integrations/jira/test_webhooks.py index e743aad77dd0a9..3f70c653c4d194 100644 --- a/tests/sentry/integrations/jira/test_webhooks.py +++ b/tests/sentry/integrations/jira/test_webhooks.py @@ -217,19 +217,20 @@ def test_missing_changelog(self) -> None: data = StubService.get_stub_data("jira", "changelog_missing.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) - @patch("sentry.integrations.jira.webhooks.issue_updated.logger") + @patch("sentry.integrations.jira.webhooks.issue_updated.sentry_sdk.capture_exception") @patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound") @patch("sentry.integrations.jira.webhooks.issue_updated._payload_logging_enabled") def test_payload_logging_failure_does_not_skip_handlers( self, mock_payload_logging_enabled: MagicMock, mock_sync_group_assignee_inbound: MagicMock, - mock_logger: MagicMock, + mock_capture_exception: MagicMock, ) -> None: # The diagnostic payload-logging path makes RPC calls that could fail # transiently. Those failures must not prevent the real webhook # handlers from running. - mock_payload_logging_enabled.side_effect = RuntimeError("transient RPC failure") + error = RuntimeError("transient RPC failure") + mock_payload_logging_enabled.side_effect = error with patch( "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", @@ -241,9 +242,7 @@ def test_payload_logging_failure_does_not_skip_handlers( mock_sync_group_assignee_inbound.assert_called_with( self.integration, "jess@sentry.io", "APP-123", assign=True ) - assert ( - mock_logger.exception.call_args.args[0] == "jira.issue-updated.payload-logging-failed" - ) + mock_capture_exception.assert_called_once_with(error) @with_feature("organizations:jira-issue-updated-payload-logging") @patch("sentry.integrations.jira.webhooks.issue_updated.logger")