Skip to content

Commit b6650ee

Browse files
feat(seer-infra-telemetry): Implement monitoring provider connection API endpoints (#117478)
Resolves https://linear.app/getsentry/issue/CW-1499/monitoring-provider-connection-api-endpoints. Adds API endpoints for listing, connecting, and disconnecting monitoring providers (currently Datadog & GCP). These endpoints wrap the existing identity pipeline to let users initiate the per-user OAuth flow for connecting their monitoring provider accounts to Sentry. The identity providers for Datadog & GCP were previously implemented in #117035 & #117279, respectively. All endpoints are gated behind the `organizations:seer-infra-telemetry` feature flag, which is enabled only internally in the Sentry org for now. Note that monitoring provider connections are per-user (each user links their own account via OAuth), not per-organization. There is some provider-specific config baked into this implementation: Datadog requires a `site` parameter in the POST body (e.g. `datadoghq.com`) for site resolution, while GCP needs no extra config. For Datadog, the `IdentityProvider` is not created in the POST endpoint; it is auto-created by `IdentityPipeline.finish_pipeline()` using the Datadog org UUID from the MCP whoami response as the external ID (see #117491 & #117705). For GCP, the `IdentityProvider` is created upfront by the endpoint. `GET /api/0/organizations/{org}/monitoring-providers/` lists the available providers and the current user's connection status. This will be used to inform the monitoring provider settings UI tracked in https://linear.app/getsentry/issue/CW-1500/monitoring-providers-settings-page. `POST /api/0/organizations/{org}/monitoring-providers/{provider}/` represents a request to connect a given monitoring provider for a given user. It initiates the OAuth flow and returns a redirect URL to the provider's authorize page, based on the identity pipeline for that provider. `DELETE /api/0/organizations/{org}/monitoring-providers/{provider}/` represents a request to disconnect/delete a given monitoring provider for a given user. It deletes the current user's linked identity for that provider, if one exists. **Note on support for a single user connecting multiple Datadog orgs:** - For v0, we're mainly considering the case where a user has a single Datadog org connected, but in the future we will support multiple - The GET endpoint currently collapses to one entry per provider type (shows `connected: true` if _any_ exist - The POST endpoint currently allows multiple connections to be created - The DELETE endpoint currently deletes all identities for the provider (`idp__type=provider_key`) --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 8f26d3d commit b6650ee

9 files changed

Lines changed: 511 additions & 3 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from django.http import HttpResponseRedirect
6+
from rest_framework.request import Request
7+
from rest_framework.response import Response
8+
9+
from sentry import features
10+
from sentry.api.api_owners import ApiOwner
11+
from sentry.api.api_publish_status import ApiPublishStatus
12+
from sentry.api.base import control_silo_endpoint
13+
from sentry.api.bases.organization import ControlSiloOrganizationEndpoint
14+
from sentry.api.endpoints.organization_monitoring_provider_index import (
15+
MONITORING_PROVIDERS,
16+
MonitoringProviderPermission,
17+
)
18+
from sentry.identity import default_manager as identity_manager
19+
from sentry.identity.pipeline import IdentityPipeline
20+
from sentry.organizations.services.organization.model import RpcOrganization
21+
from sentry.users.models.identity import Identity, IdentityProvider
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
@control_silo_endpoint
27+
class OrganizationMonitoringProviderDetailsEndpoint(ControlSiloOrganizationEndpoint):
28+
owner = ApiOwner.CODING_WORKFLOWS
29+
publish_status = {
30+
"POST": ApiPublishStatus.PRIVATE,
31+
"DELETE": ApiPublishStatus.PRIVATE,
32+
}
33+
permission_classes = (MonitoringProviderPermission,)
34+
35+
def post(
36+
self, request: Request, organization: RpcOrganization, provider_key: str, **kwargs: object
37+
) -> Response:
38+
if not features.has("organizations:seer-infra-telemetry", organization, actor=request.user):
39+
return Response(status=404)
40+
41+
if provider_key not in MONITORING_PROVIDERS:
42+
return Response({"detail": "Unknown monitoring provider."}, status=400)
43+
44+
provider_type = identity_manager.get(provider_key)
45+
try:
46+
config = provider_type.get_pipeline_config(request.data)
47+
except ValueError as e:
48+
return Response({"detail": str(e)}, status=400)
49+
50+
idp: IdentityProvider | None = None
51+
if not provider_type.auto_create_provider_model:
52+
idp, _ = IdentityProvider.objects.get_or_create(type=provider_key, external_id="")
53+
54+
pipeline = IdentityPipeline(
55+
request=request._request,
56+
provider_key=provider_key,
57+
organization=organization,
58+
provider_model=idp,
59+
config=config,
60+
)
61+
pipeline.initialize()
62+
63+
response = pipeline.current_step()
64+
65+
if isinstance(response, HttpResponseRedirect):
66+
return Response({"redirectUrl": response.url})
67+
68+
logger.error(
69+
"monitoring_provider.connect.unexpected_response",
70+
extra={"provider": provider_key, "response_type": type(response).__name__},
71+
)
72+
return Response({"detail": "Failed to start OAuth flow."}, status=500)
73+
74+
def delete(
75+
self, request: Request, organization: RpcOrganization, provider_key: str, **kwargs: object
76+
) -> Response:
77+
if not features.has("organizations:seer-infra-telemetry", organization, actor=request.user):
78+
return Response(status=404)
79+
80+
if provider_key not in MONITORING_PROVIDERS:
81+
return Response({"detail": "Unknown monitoring provider."}, status=400)
82+
83+
identities = list(
84+
Identity.objects.filter(
85+
idp__type=provider_key,
86+
user_id=request.user.id, # type: ignore[misc]
87+
)
88+
)
89+
90+
if not identities:
91+
return Response({"detail": "Not connected to this provider."}, status=404)
92+
93+
for identity in identities:
94+
identity.delete()
95+
96+
return Response(status=204)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
from rest_framework.request import Request
4+
from rest_framework.response import Response
5+
6+
from sentry import features
7+
from sentry.api.api_owners import ApiOwner
8+
from sentry.api.api_publish_status import ApiPublishStatus
9+
from sentry.api.base import control_silo_endpoint
10+
from sentry.api.bases.organization import (
11+
ControlSiloOrganizationEndpoint,
12+
OrganizationPermission,
13+
)
14+
from sentry.organizations.services.organization.model import RpcOrganization
15+
from sentry.users.models.identity import Identity
16+
17+
MONITORING_PROVIDERS: dict[str, dict[str, str]] = {
18+
"datadog": {"name": "Datadog"},
19+
"gcp": {"name": "Google Cloud Platform"},
20+
}
21+
22+
23+
class MonitoringProviderPermission(OrganizationPermission):
24+
scope_map = {
25+
"GET": ["org:read", "org:write", "org:admin"],
26+
"POST": ["org:read", "org:write", "org:admin"],
27+
"DELETE": ["org:read", "org:write", "org:admin"],
28+
}
29+
30+
31+
@control_silo_endpoint
32+
class OrganizationMonitoringProviderIndexEndpoint(ControlSiloOrganizationEndpoint):
33+
owner = ApiOwner.CODING_WORKFLOWS
34+
publish_status = {
35+
"GET": ApiPublishStatus.PRIVATE,
36+
}
37+
permission_classes = (MonitoringProviderPermission,)
38+
39+
def get(self, request: Request, organization: RpcOrganization, **kwargs: object) -> Response:
40+
if not features.has("organizations:seer-infra-telemetry", organization, actor=request.user):
41+
return Response(status=404)
42+
43+
connected_identities = {
44+
identity.idp.type: identity
45+
for identity in Identity.objects.filter(
46+
idp__type__in=MONITORING_PROVIDERS.keys(),
47+
user_id=request.user.id, # type: ignore[misc]
48+
).select_related("idp")
49+
}
50+
51+
providers = []
52+
for key, meta in MONITORING_PROVIDERS.items():
53+
identity = connected_identities.get(key)
54+
providers.append(
55+
{
56+
"provider": key,
57+
"name": meta["name"],
58+
"connected": identity is not None,
59+
}
60+
)
61+
62+
return Response({"providers": providers})

src/sentry/api/urls.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
from sentry.api.endpoints.organization_insights_tree import OrganizationInsightsTreeEndpoint
2020
from sentry.api.endpoints.organization_intercom_jwt import OrganizationIntercomJwtEndpoint
2121
from sentry.api.endpoints.organization_missing_org_members import OrganizationMissingMembersEndpoint
22+
from sentry.api.endpoints.organization_monitoring_provider_details import (
23+
OrganizationMonitoringProviderDetailsEndpoint,
24+
)
25+
from sentry.api.endpoints.organization_monitoring_provider_index import (
26+
OrganizationMonitoringProviderIndexEndpoint,
27+
)
2228
from sentry.api.endpoints.organization_pipeline import OrganizationPipelineEndpoint
2329
from sentry.api.endpoints.organization_plugin_deprecation_info import (
2430
OrganizationPluginDeprecationInfoEndpoint,
@@ -1907,6 +1913,16 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
19071913
OrganizationIssueTimeSeriesEndpoint.as_view(),
19081914
name="sentry-api-0-organization-issue-timeseries",
19091915
),
1916+
re_path(
1917+
r"^(?P<organization_id_or_slug>[^/]+)/monitoring-providers/$",
1918+
OrganizationMonitoringProviderIndexEndpoint.as_view(),
1919+
name="sentry-api-0-organization-monitoring-providers",
1920+
),
1921+
re_path(
1922+
r"^(?P<organization_id_or_slug>[^/]+)/monitoring-providers/(?P<provider_key>[^/]+)/$",
1923+
OrganizationMonitoringProviderDetailsEndpoint.as_view(),
1924+
name="sentry-api-0-organization-monitoring-provider-details",
1925+
),
19101926
re_path(
19111927
r"^(?P<organization_id_or_slug>[^/]+)/integrations/$",
19121928
OrganizationIntegrationsEndpoint.as_view(),

src/sentry/identity/base.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ def __init__(self, **config):
2424
self.config = config
2525
self.logger = logging.getLogger(f"sentry.identity.{self.key}")
2626

27+
def get_pipeline_config(self, data: dict[str, Any]) -> dict[str, str]:
28+
"""
29+
Extract and validate provider-specific configuration from request data.
30+
31+
Raises ValueError if required configuration is missing or invalid.
32+
"""
33+
return {}
34+
2735
def build_identity(self, state):
2836
"""
2937
Return a mapping containing the identity information.

src/sentry/identity/datadog/provider.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,12 @@ class DatadogIdentityProvider(OAuth2Provider):
260260
"monitors_read",
261261
)
262262

263+
def get_pipeline_config(self, data: dict[str, Any]) -> dict[str, str]:
264+
site = data.get("site")
265+
if not site:
266+
raise ValueError("Datadog requires a 'site' parameter (e.g. 'datadoghq.com').")
267+
return {"site": site}
268+
263269
def _get_mcp_base_url(self) -> str:
264270
return f"https://mcp.{self._get_oauth_parameter('site')}"
265271

src/sentry/identity/manager.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
__all__ = ["IdentityManager"]
22

3-
43
from sentry.exceptions import NotRegistered
4+
from sentry.identity.base import Provider
55

66

77
class IdentityManager:
@@ -15,10 +15,11 @@ def __iter__(self):
1515
def all(self):
1616
for key in self.__values.keys():
1717
provider = self.get(key)
18-
if provider.is_configured():
18+
is_configured = getattr(provider, "is_configured", None)
19+
if is_configured is None or is_configured():
1920
yield provider
2021

21-
def get(self, key, **kwargs):
22+
def get(self, key: str, **kwargs) -> Provider:
2223
try:
2324
cls = self.__values[key]
2425
except KeyError:

static/app/utils/api/knownSentryApiUrls.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ export type KnownSentryApiUrls =
272272
| '/organizations/$organizationIdOrSlug/metrics-estimation-stats/'
273273
| '/organizations/$organizationIdOrSlug/metrics/data/'
274274
| '/organizations/$organizationIdOrSlug/missing-members/'
275+
| '/organizations/$organizationIdOrSlug/monitoring-providers/'
276+
| '/organizations/$organizationIdOrSlug/monitoring-providers/$providerKey/'
275277
| '/organizations/$organizationIdOrSlug/monitors-count/'
276278
| '/organizations/$organizationIdOrSlug/monitors-schedule-buckets/'
277279
| '/organizations/$organizationIdOrSlug/monitors-schedule-data/'

0 commit comments

Comments
 (0)