Skip to content

Commit c349d25

Browse files
authored
Convert InstrumentedClient class to decorator (#499)
This makes it so that we're dealing with a client object directly, rather than an `InstrumentedClient`. This makes things like autocomplete, docstrings, and typing a bit better.
1 parent d3428db commit c349d25

File tree

6 files changed

+101
-70
lines changed

6 files changed

+101
-70
lines changed

jbi/services/bugzilla.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
BugzillaWebhooksResponse,
1818
)
1919

20-
from .common import InstrumentedClient, ServiceHealth
20+
from .common import ServiceHealth, instrument
2121

2222
settings = environment.get_settings()
2323

@@ -28,6 +28,15 @@ class BugzillaClientError(Exception):
2828
"""Errors raised by `BugzillaClient`."""
2929

3030

31+
instrumented_method = instrument(
32+
prefix="bugzilla",
33+
exceptions=(
34+
BugzillaClientError,
35+
requests.RequestException,
36+
),
37+
)
38+
39+
3140
class BugzillaClient:
3241
"""A wrapper around `requests` to interact with a Bugzilla REST API."""
3342

@@ -57,6 +66,7 @@ def logged_in(self) -> bool:
5766
resp = self._call("GET", f"{self.base_url}/rest/whoami")
5867
return "id" in resp
5968

69+
@instrumented_method
6070
def get_bug(self, bugid) -> BugzillaBug:
6171
"""Retrieve details about the specified bug id."""
6272
# https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#rest-single-bug
@@ -77,6 +87,7 @@ def get_bug(self, bugid) -> BugzillaBug:
7787
bug = bug.copy(update={"comment": found})
7888
return bug
7989

90+
@instrumented_method
8091
def get_comments(self, bugid) -> list[BugzillaComment]:
8192
"""Retrieve the list of comments of the specified bug id."""
8293
# https://bugzilla.readthedocs.io/en/latest/api/core/v1/comment.html#rest-comments
@@ -89,6 +100,7 @@ def get_comments(self, bugid) -> list[BugzillaComment]:
89100
)
90101
return parse_obj_as(list[BugzillaComment], comments)
91102

103+
@instrumented_method
92104
def update_bug(self, bugid, **fields) -> BugzillaBug:
93105
"""Update the specified fields of the specified bug."""
94106
# https://bugzilla.readthedocs.io/en/latest/api/core/v1/bug.html#rest-update-bug
@@ -101,6 +113,7 @@ def update_bug(self, bugid, **fields) -> BugzillaBug:
101113
)
102114
return parsed.bugs[0]
103115

116+
@instrumented_method
104117
def list_webhooks(self):
105118
"""List the currently configured webhooks, including their status."""
106119
url = f"{self.base_url}/rest/webhooks/list"
@@ -116,23 +129,9 @@ def list_webhooks(self):
116129
@lru_cache(maxsize=1)
117130
def get_client():
118131
"""Get bugzilla service"""
119-
bugzilla_client = BugzillaClient(
132+
return BugzillaClient(
120133
settings.bugzilla_base_url, api_key=str(settings.bugzilla_api_key)
121134
)
122-
return InstrumentedClient(
123-
wrapped=bugzilla_client,
124-
prefix="bugzilla",
125-
methods=(
126-
"get_bug",
127-
"get_comments",
128-
"update_bugs",
129-
"list_webhooks",
130-
),
131-
exceptions=(
132-
BugzillaClientError,
133-
requests.RequestException,
134-
),
135-
)
136135

137136

138137
def check_health() -> ServiceHealth:

jbi/services/common.py

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
InstrumentedClient: wraps service clients so that we can track their usage
55
"""
66
import logging
7+
from functools import wraps
8+
from typing import Sequence, Type
79

810
import backoff
911
from statsd.defaults.env import statsd
@@ -18,33 +20,26 @@
1820
ServiceHealth = dict[str, bool]
1921

2022

21-
class InstrumentedClient:
22-
"""This class wraps an object and increments a counter every time
23-
the specified methods are called, and times their execution.
24-
It retries the methods if the specified exceptions are raised.
23+
def instrument(prefix: str, exceptions: Sequence[Type[Exception]]):
24+
"""This decorator wraps a function such that it increments a counter every
25+
time it is called and times its execution. It retries the function if the
26+
specified exceptions are raised.
2527
"""
2628

27-
def __init__(self, wrapped, prefix, methods, exceptions):
28-
self.wrapped = wrapped
29-
self.prefix = prefix
30-
self.methods = methods
31-
self.exceptions = exceptions
32-
33-
def __getattr__(self, attr):
34-
if attr not in self.methods:
35-
return getattr(self.wrapped, attr)
36-
29+
def decorator(func):
30+
@wraps(func)
3731
@backoff.on_exception(
3832
backoff.expo,
39-
self.exceptions,
33+
exceptions,
4034
max_tries=settings.max_retries + 1,
4135
)
42-
def wrapped_func(*args, **kwargs):
36+
def wrapper(*args, **kwargs):
4337
# Increment the call counter.
44-
statsd.incr(f"jbi.{self.prefix}.methods.{attr}.count")
38+
statsd.incr(f"jbi.{prefix}.methods.{func.__name__}.count")
4539
# Time its execution.
46-
with statsd.timer(f"jbi.{self.prefix}.methods.{attr}.timer"):
47-
return getattr(self.wrapped, attr)(*args, **kwargs)
40+
with statsd.timer(f"jbi.{prefix}.methods.{func.__name__}.timer"):
41+
return func(*args, **kwargs)
42+
43+
return wrapper
4844

49-
# The method was not called yet.
50-
return wrapped_func
45+
return decorator

jbi/services/jira.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from jbi import Operation, environment
1616
from jbi.models import ActionContext, BugzillaBug
1717

18-
from .common import InstrumentedClient, ServiceHealth
18+
from .common import ServiceHealth, instrument
1919

2020
if TYPE_CHECKING:
2121
from jbi.models import Actions
@@ -27,29 +27,30 @@
2727

2828
JIRA_DESCRIPTION_CHAR_LIMIT = 32767
2929

30+
instrumented_method = instrument(prefix="jira", exceptions=(errors.ApiError,))
31+
32+
33+
class JiraClient(Jira):
34+
"""Adapted Atlassian Jira client that wraps methods in our instrumentation
35+
decorator.
36+
"""
37+
38+
update_issue_field = instrumented_method(Jira.update_issue_field)
39+
set_issue_status = instrumented_method(Jira.set_issue_status)
40+
issue_add_comment = instrumented_method(Jira.issue_add_comment)
41+
create_issue = instrumented_method(Jira.create_issue)
42+
3043

3144
@lru_cache(maxsize=1)
3245
def get_client():
3346
"""Get atlassian Jira Service"""
34-
jira_client = Jira(
47+
return JiraClient(
3548
url=settings.jira_base_url,
3649
username=settings.jira_username,
3750
password=settings.jira_api_key, # package calls this param 'password' but actually expects an api key
3851
cloud=True, # we run against an instance of Jira cloud
3952
)
4053

41-
return InstrumentedClient(
42-
wrapped=jira_client,
43-
prefix="jira",
44-
methods=(
45-
"update_issue_field",
46-
"set_issue_status",
47-
"issue_add_comment",
48-
"create_issue",
49-
),
50-
exceptions=(errors.ApiError,),
51-
)
52-
5354

5455
def fetch_visible_projects() -> list[dict]:
5556
"""Return list of projects that are visible with the configured Jira credentials"""

tests/conftest.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
)
3232

3333

34+
@pytest.fixture(autouse=True)
35+
def mocked_statsd():
36+
with mock.patch("jbi.services.common.statsd") as _mocked_statsd:
37+
yield _mocked_statsd
38+
39+
3440
@pytest.fixture
3541
def anon_client():
3642
"""A test client with no authorization."""
@@ -66,7 +72,7 @@ def mocked_jira(request):
6672
yield None
6773
jira.get_client.cache_clear()
6874
else:
69-
with mock.patch("jbi.services.jira.Jira") as mocked_jira:
75+
with mock.patch("jbi.services.jira.JiraClient") as mocked_jira:
7076
yield mocked_jira()
7177
jira.get_client.cache_clear()
7278

tests/unit/services/test_bugzilla.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@
88
from jbi.services.bugzilla import BugzillaClientError, get_client
99

1010

11-
def test_timer_is_used_on_bugzilla_get_comments():
11+
@pytest.mark.no_mocked_bugzilla
12+
def test_timer_is_used_on_bugzilla_get_comments(mocked_responses, mocked_statsd):
1213
bugzilla_client = get_client()
13-
14-
with mock.patch("jbi.services.common.statsd") as mocked:
15-
bugzilla_client.get_comments([])
16-
17-
mocked.timer.assert_called_with("jbi.bugzilla.methods.get_comments.timer")
14+
mocked_responses.add(
15+
"GET",
16+
f"{get_settings().bugzilla_base_url}/rest/bug/42/comment",
17+
json={
18+
"bugs": {"42": {"comments": []}},
19+
},
20+
)
21+
bugzilla_client.get_comments(42)
22+
mocked_statsd.timer.assert_called_with("jbi.bugzilla.methods.get_comments.timer")
1823

1924

2025
@pytest.mark.no_mocked_bugzilla

tests/unit/services/test_jira.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import json
2-
from unittest import mock
32

43
import pytest
54
import responses
@@ -8,22 +7,48 @@
87
from jbi.services import jira
98

109

11-
def test_counter_is_incremented_on_jira_create_issue():
12-
jira_client = jira.get_client()
13-
14-
with mock.patch("jbi.services.common.statsd") as mocked:
15-
jira_client.create_issue({})
16-
17-
mocked.incr.assert_called_with("jbi.jira.methods.create_issue.count")
10+
@pytest.mark.no_mocked_jira
11+
def test_jira_create_issue_is_instrumented(
12+
mocked_responses, context_create_example, mocked_statsd
13+
):
14+
url = f"{get_settings().jira_base_url}rest/api/2/project/{context_create_example.jira.project}/components"
15+
mocked_responses.add(
16+
responses.GET,
17+
url,
18+
json=[
19+
{
20+
"id": "10000",
21+
"name": "Component 1",
22+
},
23+
{
24+
"id": "42",
25+
"name": "Remote Settings",
26+
},
27+
],
28+
)
1829

30+
url = f"{get_settings().jira_base_url}rest/api/2/issue"
31+
mocked_responses.add(
32+
responses.POST,
33+
url,
34+
json={
35+
"id": "10000",
36+
"key": "ED-24",
37+
},
38+
)
1939

20-
def test_timer_is_used_on_jira_create_issue():
40+
jira.create_jira_issue(
41+
context_create_example,
42+
"Description",
43+
sync_whiteboard_labels=False,
44+
components=["Remote Settings"],
45+
)
2146
jira_client = jira.get_client()
2247

23-
with mock.patch("jbi.services.common.statsd") as mocked:
24-
jira_client.create_issue({})
48+
jira_client.create_issue({})
2549

26-
mocked.timer.assert_called_with("jbi.jira.methods.create_issue.timer")
50+
mocked_statsd.incr.assert_called_with("jbi.jira.methods.create_issue.count")
51+
mocked_statsd.timer.assert_called_with("jbi.jira.methods.create_issue.timer")
2752

2853

2954
@pytest.mark.no_mocked_jira

0 commit comments

Comments
 (0)