Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
93dc014
feat(refactor): Move common exceptions out of the discover db (#111713)
manessaraj Mar 27, 2026
6bcb137
fix(aci): Include project alerts on monitor list page (#111690)
malwilley Mar 27, 2026
df62fec
feat(autofix): Handle PR creation in group ai autofix endpoint (#111565)
Zylphrex Mar 27, 2026
20b0021
fix(seer-explorer): Keep optimistic thinking block until assistant re…
Mihir-Mavalankar Mar 27, 2026
5a8400b
ref(scm): Wrap raw response with data key and provide the headers (#1…
cmanallen Mar 27, 2026
cef45a3
fix(explore): Contain traces table overflow (#111183)
nsdeschenes Mar 27, 2026
c57e28e
fix(seer-explorer): Prevent optimistic state clearing on rethink with…
Mihir-Mavalankar Mar 27, 2026
b8b2220
ref(components): extracted a DragReorderButton component (#110982)
JoshuaKGoldberg Mar 27, 2026
28331f1
feat(identity): Add OAuth2ApiStep for API-driven OAuth2 flows (#111578)
evanpurkhiser Mar 27, 2026
1e1d235
feat(pipeline): Add OrganizationPipelineEndpoint for API-driven pipel…
evanpurkhiser Mar 27, 2026
a7cd4d8
fix(logs): Prevent cell action menu clicks from toggling row visibili…
JoshuaKGoldberg Mar 27, 2026
6b00c83
feat(logs): Add JSON pretty-printing for log attributes (#111077)
JoshuaKGoldberg Mar 27, 2026
492a2ed
fix(workflow): Replace `getSantry` with dedicated GH app for api sche…
Jeffreyhung Mar 27, 2026
2c1e70a
ref(pipeline): Move `None` out of ApiPipelineSteps type (#111711)
evanpurkhiser Mar 27, 2026
38f6946
perf(workflows): Avoid ~0.6s regression in backported OrganizationCom…
kcons Mar 27, 2026
6504972
ref(cells): Update region_name to cell_name in process_control_outbox…
lynnagara Mar 27, 2026
0ca9c49
feat(preprod): Improve Slack alert messages for size analysis monitor…
mtopo27 Mar 27, 2026
2a2f917
feat(CODEOWNERS): add data-browsing and streaming owners for spans sc…
lvthanh03 Mar 27, 2026
81e7059
fix(integrations): Disallow modifying a repo's integration (#111739)
cmanallen Mar 27, 2026
dc99731
chore(ACI): Document detector filter params (#111742)
ceorourke Mar 27, 2026
fa1d0a6
feat(objectstore): enable token generator in objectstore client (#105…
matt-codecov Mar 27, 2026
07acff6
chore(autofix): Remove unneeded seat based seer check (#111733)
Zylphrex Mar 27, 2026
efe2906
chore(autofix): Add some metrics for explorer autofix triggers (#111734)
Zylphrex Mar 27, 2026
9189dee
feat(cells): migrate clear_region_cache to clear_cell_cache (#111681)
lynnagara Mar 27, 2026
bfe315e
fix(cells): Update controlsiloUrlPatterns (#111751)
evanpurkhiser Mar 27, 2026
7863d2d
ref(scm): Report more granular failure metrics (#111722)
cmanallen Mar 27, 2026
ecb1c25
chore(autofix): Remove autofix-on-explorer-v2 flag (#111727)
Zylphrex Mar 27, 2026
655cefc
fix(seer): Align project grouping record deletion with Seer API (#111…
sentry[bot] Mar 27, 2026
bcee2ef
feat(autofix): Autotrigger root cause if legacy autofix ran (#111718)
Zylphrex Mar 27, 2026
f2883c3
ref(cells): Introduce new cell config keys (#111749)
lynnagara Mar 27, 2026
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
1 change: 1 addition & 0 deletions .agents/skills/cell-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ This applies to:
- DB columns: new columns must be nullable or have defaults; don't drop a column in the same deploy that stops writing it; when **renaming a Python field**, set `db_column="old_name"` to avoid a schema migration entirely — the DB column stays unchanged and is safe across rolling deploys
- API response shape changes
- Any data written to outboxes, queues, or caches that may be read by older code
- **Taskworker/Celery task names and kwargs** — the `name=` string is serialized into Kafka/the broker; in-flight tasks carry the old name and old kwarg names. Keep the old name registered during the transition (via `alias=` on `@instrumented_task`, or by keeping the old task as a shim that calls the new function directly). Remove it in a follow-up deploy once the queue has drained.

#### region -> cell Rename

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,13 @@ def process_my_category(object_identifier: int, payload: Any, **kwds: Any) -> No

## Control Outbox Receivers

Control outbox signals include an additional `region_name` argument:
Control outbox signals include an additional `cell_name` argument:

- `sender`: `OutboxCategory` enum value
- `payload`: `dict | None`
- `object_identifier`: `int`
- `shard_identifier`: `int`
- `region_name`: `str` — the target region
- `cell_name`: `str` — the target cell
- `shard_scope`: `int`
- `date_added`: `datetime`
- `scheduled_for`: `datetime`
Expand All @@ -100,13 +100,13 @@ from sentry.receivers.outbox import maybe_process_tombstone


@receiver(process_control_outbox, sender=OutboxCategory.MY_CATEGORY)
def process_my_category(object_identifier: int, region_name: str, **kwds: Any) -> None:
def process_my_category(object_identifier: int, cell_name: str, **kwds: Any) -> None:
if (instance := maybe_process_tombstone(
MyModel, object_identifier, cell_name=region_name
MyModel, object_identifier, cell_name=cell_name
)) is None:
return
# Replicate to the specific cell
my_cell_service.sync(cell_name=region_name, data=serialize(instance))
my_cell_service.sync(cell_name=cell_name, data=serialize(instance))
```

### Template: Control Pure-RPC Receiver
Expand All @@ -118,7 +118,7 @@ For categories where the receiver makes an RPC call without looking up a model:
def process_my_category(
payload: Mapping[str, Any], shard_identifier: int, **kwds: Any
) -> None:
my_region_service.do_something(
my_cell_service.do_something(
organization_id=shard_identifier,
data=payload["data"],
)
Expand All @@ -144,4 +144,4 @@ The tombstone system drives `HybridCloudForeignKey` cascade deletes across silos

**When to use**: Any receiver that needs to distinguish between "object was created/updated" and "object was deleted". Not needed for payload-only categories (audit logs, IP events) where the payload carries all necessary data.

**`region_name` parameter**: Pass `region_name` for control outbox receivers (tombstone goes to the cell). Omit for cell outbox receivers (tombstone goes to control).
**`cell_name` parameter**: Pass `cell_name` for control outbox receivers (tombstone goes to the cell). Omit for cell outbox receivers (tombstone goes to control).
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get

/src/sentry/search/events/ @getsentry/data-browsing
/src/sentry/search/eap/ @getsentry/data-browsing
/src/sentry/search/exceptions.py @getsentry/data-browsing

/src/sentry/issue_detection/ @getsentry/issue-detection-backend
/tests/sentry/issue_detection/ @getsentry/issue-detection-backend
Expand Down Expand Up @@ -778,6 +779,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get

# Span buffer + process-segments are co-owned by streaming platform and vis for now.
/src/sentry/spans/ @getsentry/data-browsing @getsentry/streaming-platform
/src/sentry/scripts/spans/ @getsentry/data-browsing @getsentry/streaming-platform
/tests/sentry/spans/ @getsentry/data-browsing @getsentry/streaming-platform

# Streaming platform
Expand Down
21 changes: 13 additions & 8 deletions .github/workflows/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
- name: Getsentry Token
id: getsentry
uses: getsentry/action-github-app-token@5c1e90706fe007857338ac1bfbd7a4177db2f789 # v4.0.0
- name: github app token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app_id: ${{ vars.SENTRY_INTERNAL_APP_ID }}
private_key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }}
app-id: ${{ vars.SENTRY_API_SCHEMA_UPDATER_APP_CLIENT_ID }}
private-key: ${{ secrets.SENTRY_API_SCHEMA_UPDATER_PRIVATE_KEY }}
repositories: |
sentry
sentry-api-schema

- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7

Expand All @@ -42,7 +45,9 @@ jobs:
ref: 'main'
repository: getsentry/sentry-api-schema
path: sentry-api-schema
token: ${{ steps.getsentry.outputs.token }}
# using app token so that `Git Commit & Push` step has the right permissions
# https://github.com/stefanzweifel/git-auto-commit-action/tree/master?tab=readme-ov-file#push-to-protected-branches
token: ${{ steps.app-token.outputs.token }}

- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4
if: steps.changes.outputs.api_docs == 'true'
Expand Down Expand Up @@ -75,5 +80,5 @@ jobs:
repository: sentry-api-schema
branch: main
commit_message: Generated
commit_user_email: bot@getsentry.com
commit_user_name: openapi-getsentry-bot
commit_user_email: 271575301+sentry-api-schema-updater[bot]@users.noreply.github.com
commit_user_name: sentry-api-schema-updater[bot]
124 changes: 124 additions & 0 deletions src/sentry/api/endpoints/organization_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from __future__ import annotations

import logging

from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import control_silo_endpoint
from sentry.api.bases.organization import (
ControlSiloOrganizationEndpoint,
OrganizationPermission,
)
from sentry.exceptions import NotRegistered
from sentry.identity.pipeline import IdentityPipeline
from sentry.integrations.pipeline import (
IntegrationPipeline,
IntegrationPipelineError,
initialize_integration_pipeline,
)
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.pipeline.base import Pipeline
from sentry.pipeline.types import PipelineStepAction

logger = logging.getLogger(__name__)

# All pipeline classes that can be driven via the API. The endpoint tries each
# in order and uses whichever one has a valid session for the request.
PIPELINE_CLASSES = (IntegrationPipeline, IdentityPipeline)


class PipelinePermission(OrganizationPermission):
scope_map = {
"GET": ["org:read", "org:write", "org:admin", "org:integrations"],
"POST": ["org:write", "org:admin", "org:integrations"],
}


def _get_api_pipeline(
request: Request, organization: RpcOrganization, pipeline_name: str
) -> Response | Pipeline:
"""Look up an active API-ready pipeline from the session, or return an error Response."""
pipelines = {cls.pipeline_name: cls for cls in PIPELINE_CLASSES}
if pipeline_name not in pipelines:
return Response({"detail": "Invalid pipeline type"}, status=404)

pipeline = pipelines[pipeline_name].get_for_request(request._request)
if not pipeline or not pipeline.organization:
return Response({"detail": "No active pipeline session."}, status=404)

if not pipeline.is_valid() or pipeline.organization.id != organization.id:
return Response({"detail": "Invalid pipeline state."}, status=404)

if not pipeline.is_api_ready():
return Response({"detail": "Pipeline does not support API mode."}, status=400)

return pipeline


@control_silo_endpoint
class OrganizationPipelineEndpoint(ControlSiloOrganizationEndpoint):
owner = ApiOwner.ENTERPRISE
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
"POST": ApiPublishStatus.EXPERIMENTAL,
}
permission_classes = (PipelinePermission,)

def get(
self, request: Request, organization: RpcOrganization, pipeline_name: str, **kwargs: object
) -> Response:
result = _get_api_pipeline(request, organization, pipeline_name)
if isinstance(result, Response):
return result
return Response(result.get_current_step_info())

def post(
self, request: Request, organization: RpcOrganization, pipeline_name: str, **kwargs: object
) -> Response:
if request.data.get("action") == "initialize":
return self._initialize_pipeline(request, organization, pipeline_name)

result = _get_api_pipeline(request, organization, pipeline_name)
if isinstance(result, Response):
return result
pipeline = result

step_result = pipeline.api_advance(request._request, request.data)

response_data = step_result.serialize()
if step_result.action == PipelineStepAction.ADVANCE:
response_data.update(pipeline.get_current_step_info())

if step_result.action == PipelineStepAction.ERROR:
return Response(response_data, status=400)

return Response(response_data)

def _initialize_pipeline(
self, request: Request, organization: RpcOrganization, pipeline_name: str
) -> Response:
if pipeline_name != IntegrationPipeline.pipeline_name:
return Response(
{"detail": "Initialization not supported for this pipeline."}, status=400
)

provider_id = request.data.get("provider")
if not provider_id:
return Response({"detail": "provider is required."}, status=400)

try:
pipeline = initialize_integration_pipeline(request._request, organization, provider_id)
except NotRegistered:
return Response({"detail": f"Unknown provider: {provider_id}"}, status=404)
except IntegrationPipelineError as e:
return Response({"detail": str(e)}, status=404 if e.not_found else 400)

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

if not pipeline.is_api_ready():
return Response({"detail": "Pipeline does not support API mode."}, status=400)

pipeline.set_api_mode()

return Response(pipeline.get_current_step_info())
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from sentry.api.endpoints.organization_insights_tree import OrganizationInsightsTreeEndpoint
from sentry.api.endpoints.organization_intercom_jwt import OrganizationIntercomJwtEndpoint
from sentry.api.endpoints.organization_missing_org_members import OrganizationMissingMembersEndpoint
from sentry.api.endpoints.organization_pipeline import OrganizationPipelineEndpoint
from sentry.api.endpoints.organization_plugin_deprecation_info import (
OrganizationPluginDeprecationInfoEndpoint,
)
Expand Down Expand Up @@ -2038,6 +2039,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
ExternalUserDetailsEndpoint.as_view(),
name="sentry-api-0-organization-external-user-details",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/pipeline/(?P<pipeline_name>[^/]+)/$",
OrganizationPipelineEndpoint.as_view(),
name="sentry-api-0-organization-pipeline",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/integration-requests/$",
OrganizationIntegrationRequestEndpoint.as_view(),
Expand Down
8 changes: 7 additions & 1 deletion src/sentry/apidocs/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,13 @@ class DetectorParams:
location="query",
required=False,
type=str,
description="An optional search query for filtering monitors.",
description="""An optional search query for filtering monitors.

Available fields are:
- `name`
- `type`: e.g. `error`, `metric_issue`, `issue_stream`
- `assignee`: email, username, #team, me, none
""",
)

SORT = OpenApiParameter(
Expand Down
10 changes: 7 additions & 3 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -752,18 +752,22 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
# An enum is better because there shouldn't be multiple "modes".
SENTRY_MODE = SentryMode.SELF_HOSTED

# If this instance is a region silo, which region is it running in?
SENTRY_REGION = os.environ.get("SENTRY_REGION", None)
# If this instance is a cell silo, which cell is it running in?
SENTRY_LOCAL_CELL = os.environ.get("SENTRY_REGION", None)

# Returns the customer single tenant ID.
CUSTOMER_ID = os.environ.get("CUSTOMER_ID", None)

# List of the available cells (e.g. "us1", "us2", "de1")
SENTRY_REGION_CONFIG: list[CellConfig] = []
SENTRY_CELLS: list[CellConfig] = []

# Mapping of localities (e.g. "us", "de") to their constituent cells (e.g. "us1", "us2")
SENTRY_LOCALITIES: list[LocalityConfig] = []

# TODO(cells): Superceded by SENTRY_LOCAL_CELL and SENTRY_CELLS. Remove once migration is complete.
SENTRY_REGION = os.environ.get("SENTRY_REGION", None)
SENTRY_REGION_CONFIG: list[CellConfig] = []

# Shared secret used to sign cross-region RPC requests.
RPC_SHARED_SECRET: list[str] | None = None

Expand Down
2 changes: 1 addition & 1 deletion src/sentry/hybridcloud/models/outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ def send_signal(self) -> None:
process_control_outbox.send(
sender=OutboxCategory(self.category),
payload=self.payload,
region_name=self.cell_name,
cell_name=self.cell_name,
object_identifier=self.object_identifier,
shard_identifier=self.shard_identifier,
shard_scope=self.shard_scope,
Expand Down
8 changes: 4 additions & 4 deletions src/sentry/hybridcloud/outbox/category.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,25 +98,25 @@ def receiver(
object_identifier: int,
payload: Mapping[str, Any] | None,
shard_identifier: int,
region_name: str,
cell_name: str,
*args: Any,
**kwds: Any,
) -> None:
from sentry.receivers.outbox import maybe_process_tombstone

maybe_instance: HasControlReplicationHandlers | None = maybe_process_tombstone(
cast(Any, model), object_identifier, cell_name=region_name
cast(Any, model), object_identifier, cell_name=cell_name
)
if maybe_instance is None:
model.handle_async_deletion(
identifier=object_identifier,
cell_name=region_name,
cell_name=cell_name,
shard_identifier=shard_identifier,
payload=payload,
)
else:
maybe_instance.handle_async_replication(
shard_identifier=shard_identifier, cell_name=region_name
shard_identifier=shard_identifier, cell_name=cell_name
)

process_control_outbox.connect(receiver, weak=False, sender=self)
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/hybridcloud/outbox/signals.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.dispatch import Signal

process_cell_outbox = Signal() # ["payload", "object_identifier"]
process_control_outbox = Signal() # ["payload", "region_name", "object_identifier"]
process_control_outbox = Signal() # ["payload", "cell_name", "object_identifier"]
Loading
Loading