Skip to content

Commit 7cfcaf3

Browse files
authored
chore(integrations): oauth2 identity pipeline metrics (#79325)
1 parent 3ed5dab commit 7cfcaf3

File tree

4 files changed

+145
-72
lines changed

4 files changed

+145
-72
lines changed

src/sentry/identity/oauth2.py

Lines changed: 103 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313

1414
from sentry.auth.exceptions import IdentityNotValid
1515
from sentry.http import safe_urlopen, safe_urlread
16+
from sentry.integrations.utils.metrics import (
17+
IntegrationPipelineViewEvent,
18+
IntegrationPipelineViewType,
19+
)
1620
from sentry.pipeline import PipelineView
1721
from sentry.shared_integrations.exceptions import ApiError
1822
from sentry.utils.http import absolute_uri
@@ -23,6 +27,7 @@
2327

2428
logger = logging.getLogger(__name__)
2529
ERR_INVALID_STATE = "An error occurred while validating your request."
30+
ERR_TOKEN_RETRIEVAL = "Failed to retrieve token from the upstream service."
2631

2732

2833
class OAuth2Provider(Provider):
@@ -207,6 +212,20 @@ def refresh_identity(self, identity, *args, **kwargs):
207212
from rest_framework.request import Request
208213

209214

215+
def record_event(event: IntegrationPipelineViewType, provider: str):
216+
from sentry.integrations.base import INTEGRATION_PROVIDER_TO_TYPE, IntegrationProviderSlug
217+
218+
try:
219+
provider_slug = IntegrationProviderSlug(provider)
220+
domain = INTEGRATION_PROVIDER_TO_TYPE[provider_slug]
221+
except ValueError:
222+
provider_slug = "unknown"
223+
domain = "unknown"
224+
logger.exception("oauth2.record_event.invalid_provider", extra={"provider": provider})
225+
226+
return IntegrationPipelineViewEvent(event, domain, provider_slug)
227+
228+
210229
class OAuth2LoginView(PipelineView):
211230
authorize_url = None
212231
client_id = None
@@ -238,22 +257,23 @@ def get_authorize_params(self, state, redirect_uri):
238257

239258
@method_decorator(csrf_exempt)
240259
def dispatch(self, request: Request, pipeline) -> HttpResponse:
241-
for param in ("code", "error", "state"):
242-
if param in request.GET:
243-
return pipeline.next_step()
260+
with record_event(IntegrationPipelineViewType.OAUTH_LOGIN, pipeline.provider.key).capture():
261+
for param in ("code", "error", "state"):
262+
if param in request.GET:
263+
return pipeline.next_step()
244264

245-
state = secrets.token_hex()
265+
state = secrets.token_hex()
246266

247-
params = self.get_authorize_params(
248-
state=state, redirect_uri=absolute_uri(pipeline.redirect_url())
249-
)
250-
redirect_uri = f"{self.get_authorize_url()}?{urlencode(params)}"
267+
params = self.get_authorize_params(
268+
state=state, redirect_uri=absolute_uri(pipeline.redirect_url())
269+
)
270+
redirect_uri = f"{self.get_authorize_url()}?{urlencode(params)}"
251271

252-
pipeline.bind_state("state", state)
253-
if request.subdomain:
254-
pipeline.bind_state("subdomain", request.subdomain)
272+
pipeline.bind_state("state", state)
273+
if request.subdomain:
274+
pipeline.bind_state("subdomain", request.subdomain)
255275

256-
return self.redirect(redirect_uri)
276+
return self.redirect(redirect_uri)
257277

258278

259279
class OAuth2CallbackView(PipelineView):
@@ -280,70 +300,90 @@ def get_token_params(self, code, redirect_uri):
280300
}
281301

282302
def exchange_token(self, request: Request, pipeline, code):
283-
# TODO: this needs the auth yet
284-
data = self.get_token_params(code=code, redirect_uri=absolute_uri(pipeline.redirect_url()))
285-
verify_ssl = pipeline.config.get("verify_ssl", True)
286-
try:
287-
req = safe_urlopen(self.access_token_url, data=data, verify_ssl=verify_ssl)
288-
body = safe_urlread(req)
289-
if req.headers.get("Content-Type", "").startswith("application/x-www-form-urlencoded"):
290-
return dict(parse_qsl(body))
291-
return orjson.loads(body)
292-
except SSLError:
293-
logger.info(
294-
"identity.oauth2.ssl-error",
295-
extra={"url": self.access_token_url, "verify_ssl": verify_ssl},
303+
with record_event(
304+
IntegrationPipelineViewType.TOKEN_EXCHANGE, pipeline.provider.key
305+
).capture() as lifecycle:
306+
# TODO: this needs the auth yet
307+
data = self.get_token_params(
308+
code=code, redirect_uri=absolute_uri(pipeline.redirect_url())
296309
)
297-
url = self.access_token_url
298-
return {
299-
"error": "Could not verify SSL certificate",
300-
"error_description": f"Ensure that {url} has a valid SSL certificate",
301-
}
302-
except ConnectionError:
303-
url = self.access_token_url
304-
logger.info("identity.oauth2.connection-error", extra={"url": url})
305-
return {
306-
"error": "Could not connect to host or service",
307-
"error_description": f"Ensure that {url} is open to connections",
308-
}
309-
except orjson.JSONDecodeError:
310-
logger.info("identity.oauth2.json-error", extra={"url": self.access_token_url})
311-
return {
312-
"error": "Could not decode a JSON Response",
313-
"error_description": "We were not able to parse a JSON response, please try again.",
314-
}
310+
verify_ssl = pipeline.config.get("verify_ssl", True)
311+
try:
312+
req = safe_urlopen(self.access_token_url, data=data, verify_ssl=verify_ssl)
313+
body = safe_urlread(req)
314+
if req.headers.get("Content-Type", "").startswith(
315+
"application/x-www-form-urlencoded"
316+
):
317+
return dict(parse_qsl(body))
318+
return orjson.loads(body)
319+
except SSLError:
320+
logger.info(
321+
"identity.oauth2.ssl-error",
322+
extra={"url": self.access_token_url, "verify_ssl": verify_ssl},
323+
)
324+
lifecycle.record_failure({"failure_reason": "ssl_error"})
325+
url = self.access_token_url
326+
return {
327+
"error": "Could not verify SSL certificate",
328+
"error_description": f"Ensure that {url} has a valid SSL certificate",
329+
}
330+
except ConnectionError:
331+
url = self.access_token_url
332+
logger.info("identity.oauth2.connection-error", extra={"url": url})
333+
lifecycle.record_failure({"failure_reason": "connection_error"})
334+
return {
335+
"error": "Could not connect to host or service",
336+
"error_description": f"Ensure that {url} is open to connections",
337+
}
338+
except orjson.JSONDecodeError:
339+
logger.info("identity.oauth2.json-error", extra={"url": self.access_token_url})
340+
lifecycle.record_failure({"failure_reason": "json_error"})
341+
return {
342+
"error": "Could not decode a JSON Response",
343+
"error_description": "We were not able to parse a JSON response, please try again.",
344+
}
315345

316346
def dispatch(self, request: Request, pipeline) -> HttpResponse:
317-
error = request.GET.get("error")
318-
state = request.GET.get("state")
319-
code = request.GET.get("code")
320-
321-
if error:
322-
pipeline.logger.info("identity.token-exchange-error", extra={"error": error})
323-
return pipeline.error(ERR_INVALID_STATE)
347+
with record_event(
348+
IntegrationPipelineViewType.OAUTH_CALLBACK, pipeline.provider.key
349+
).capture() as lifecycle:
350+
error = request.GET.get("error")
351+
state = request.GET.get("state")
352+
code = request.GET.get("code")
353+
354+
if error:
355+
pipeline.logger.info("identity.token-exchange-error", extra={"error": error})
356+
lifecycle.record_failure(
357+
{"failure_reason": "token_exchange_error", "msg": ERR_INVALID_STATE}
358+
)
359+
return pipeline.error(ERR_INVALID_STATE)
324360

325-
if state != pipeline.fetch_state("state"):
326-
pipeline.logger.info(
327-
"identity.token-exchange-error",
328-
extra={
329-
"error": "invalid_state",
330-
"state": state,
331-
"pipeline_state": pipeline.fetch_state("state"),
332-
"code": code,
333-
},
334-
)
335-
return pipeline.error(ERR_INVALID_STATE)
361+
if state != pipeline.fetch_state("state"):
362+
pipeline.logger.info(
363+
"identity.token-exchange-error",
364+
extra={
365+
"error": "invalid_state",
366+
"state": state,
367+
"pipeline_state": pipeline.fetch_state("state"),
368+
"code": code,
369+
},
370+
)
371+
lifecycle.record_failure(
372+
{"failure_reason": "token_exchange_error", "msg": ERR_INVALID_STATE}
373+
)
374+
return pipeline.error(ERR_INVALID_STATE)
336375

376+
# separate lifecycle event inside exchange_token
337377
data = self.exchange_token(request, pipeline, code)
338378

379+
# these errors are based off of the results of exchange_token, lifecycle errors are captured inside
339380
if "error_description" in data:
340381
error = data.get("error")
341-
pipeline.logger.info("identity.token-exchange-error", extra={"error": error})
342382
return pipeline.error(data["error_description"])
343383

344384
if "error" in data:
345385
pipeline.logger.info("identity.token-exchange-error", extra={"error": data["error"]})
346-
return pipeline.error("Failed to retrieve token from the upstream service.")
386+
return pipeline.error(ERR_TOKEN_RETRIEVAL)
347387

348388
# we can either expect the API to be implicit and say "im looking for
349389
# blah within state data" or we need to pass implementation + call a

src/sentry/integrations/base.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ class IntegrationProviderSlug(StrEnum):
146146
GITHUB_ENTERPRISE = "github_enterprise"
147147
GITLAB = "gitlab"
148148
BITBUCKET = "bitbucket"
149+
BITBUCKET_SERVER = "bitbucket_server"
149150
PAGERDUTY = "pagerduty"
150151
OPSGENIE = "opsgenie"
151152

@@ -159,16 +160,13 @@ class IntegrationProviderSlug(StrEnum):
159160
IntegrationDomain.PROJECT_MANAGEMENT: [
160161
IntegrationProviderSlug.JIRA,
161162
IntegrationProviderSlug.JIRA_SERVER,
162-
IntegrationProviderSlug.GITHUB,
163-
IntegrationProviderSlug.GITHUB_ENTERPRISE,
164-
IntegrationProviderSlug.GITLAB,
165-
IntegrationProviderSlug.AZURE_DEVOPS,
166163
],
167164
IntegrationDomain.SOURCE_CODE_MANAGEMENT: [
168165
IntegrationProviderSlug.GITHUB,
169166
IntegrationProviderSlug.GITHUB_ENTERPRISE,
170167
IntegrationProviderSlug.GITLAB,
171168
IntegrationProviderSlug.BITBUCKET,
169+
IntegrationProviderSlug.BITBUCKET_SERVER,
172170
IntegrationProviderSlug.AZURE_DEVOPS,
173171
],
174172
IntegrationDomain.ON_CALL_SCHEDULING: [
@@ -177,6 +175,10 @@ class IntegrationProviderSlug(StrEnum):
177175
],
178176
}
179177

178+
INTEGRATION_PROVIDER_TO_TYPE = {
179+
v: k for k, values in INTEGRATION_TYPE_TO_PROVIDER.items() for v in values
180+
}
181+
180182

181183
class IntegrationProvider(PipelineProvider, abc.ABC):
182184
"""

src/sentry/integrations/utils/metrics.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ class IntegrationPipelineViewType(Enum):
229229
# IdentityProviderPipeline
230230
IDENTITY_LOGIN = "IDENTITY_LOGIN"
231231
IDENTITY_LINK = "IDENTITY_LINK"
232+
TOKEN_EXCHANGE = "TOKEN_EXCHANGE"
232233

233234
# GitHub
234235
OAUTH_LOGIN = "OAUTH_LOGIN"

tests/sentry/identity/test_oauth2.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections import namedtuple
22
from functools import cached_property
3+
from unittest.mock import patch
34
from urllib.parse import parse_qs, parse_qsl, urlparse
45

56
import responses
@@ -10,13 +11,16 @@
1011
from sentry.identity.oauth2 import OAuth2CallbackView, OAuth2LoginView
1112
from sentry.identity.pipeline import IdentityProviderPipeline
1213
from sentry.identity.providers.dummy import DummyProvider
14+
from sentry.integrations.utils.metrics import EventLifecycleOutcome
1315
from sentry.testutils.cases import TestCase
1416
from sentry.testutils.silo import control_silo_test
1517

1618
MockResponse = namedtuple("MockResponse", ["headers", "content"])
1719

1820

1921
@control_silo_test
22+
@patch("sentry.integrations.base.INTEGRATION_PROVIDER_TO_TYPE", return_value={"dummy": "dummy"})
23+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
2024
class OAuth2CallbackViewTest(TestCase):
2125
def setUp(self):
2226
sentry.identity.register(DummyProvider)
@@ -36,8 +40,18 @@ def view(self):
3640
client_secret="secret-value",
3741
)
3842

43+
def assert_failure_metric(self, mock_record, error_msg):
44+
(event_failures,) = (
45+
call for call in mock_record.mock_calls if call.args[0] == EventLifecycleOutcome.FAILURE
46+
)
47+
assert event_failures.args[1]["failure_reason"] == error_msg
48+
3949
@responses.activate
40-
def test_exchange_token_success(self):
50+
def test_exchange_token_success(
51+
self,
52+
mock_record,
53+
mock_integration_const,
54+
):
4155
responses.add(
4256
responses.POST, "https://example.org/oauth/token", json={"token": "a-fake-token"}
4357
)
@@ -59,8 +73,13 @@ def test_exchange_token_success(self):
5973
"redirect_uri": "http://testserver/extensions/default/setup/",
6074
}
6175

76+
assert len(mock_record.mock_calls) == 2
77+
start, success = mock_record.mock_calls
78+
assert start.args[0] == EventLifecycleOutcome.STARTED
79+
assert success.args[0] == EventLifecycleOutcome.SUCCESS
80+
6281
@responses.activate
63-
def test_exchange_token_success_customer_domains(self):
82+
def test_exchange_token_success_customer_domains(self, mock_record, mock_integration_const):
6483
responses.add(
6584
responses.POST, "https://example.org/oauth/token", json={"token": "a-fake-token"}
6685
)
@@ -82,8 +101,13 @@ def test_exchange_token_success_customer_domains(self):
82101
"redirect_uri": "http://testserver/extensions/default/setup/",
83102
}
84103

104+
assert len(mock_record.mock_calls) == 2
105+
start, success = mock_record.mock_calls
106+
assert start.args[0] == EventLifecycleOutcome.STARTED
107+
assert success.args[0] == EventLifecycleOutcome.SUCCESS
108+
85109
@responses.activate
86-
def test_exchange_token_ssl_error(self):
110+
def test_exchange_token_ssl_error(self, mock_record, mock_integration_const):
87111
def ssl_error(request):
88112
raise SSLError("Could not build connection")
89113

@@ -98,8 +122,10 @@ def ssl_error(request):
98122
assert "error_description" in result
99123
assert "SSL" in result["error_description"]
100124

125+
self.assert_failure_metric(mock_record, "ssl_error")
126+
101127
@responses.activate
102-
def test_connection_error(self):
128+
def test_connection_error(self, mock_record, mock_integration_const):
103129
def connection_error(request):
104130
raise ConnectionError("Name or service not known")
105131

@@ -114,8 +140,10 @@ def connection_error(request):
114140
assert "connect" in result["error"]
115141
assert "error_description" in result
116142

143+
self.assert_failure_metric(mock_record, "connection_error")
144+
117145
@responses.activate
118-
def test_exchange_token_no_json(self):
146+
def test_exchange_token_no_json(self, mock_record, mock_integration_const):
119147
responses.add(responses.POST, "https://example.org/oauth/token", body="")
120148
pipeline = IdentityProviderPipeline(request=self.request, provider_key="dummy")
121149
code = "auth-code"
@@ -125,6 +153,8 @@ def test_exchange_token_no_json(self):
125153
assert "error_description" in result
126154
assert "JSON" in result["error_description"]
127155

156+
self.assert_failure_metric(mock_record, "json_error")
157+
128158

129159
@control_silo_test
130160
class OAuth2LoginViewTest(TestCase):

0 commit comments

Comments
 (0)