Skip to content

Commit ce8e001

Browse files
committed
re-raise exceptions from request_field_extractor + prom metrics
1 parent 4f0065a commit ce8e001

File tree

5 files changed

+158
-33
lines changed

5 files changed

+158
-33
lines changed

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
[tool.black]
22
line-length = 100
33
target-version = ['py36']
4+
5+
[project]
6+
dynamic = ["version"]
7+
8+
[tool.setuptools_scm]

reddit_decider/__init__.py

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import dataclass
55
from datetime import timedelta
66
from enum import Enum
7+
from sys import version_info as py_version
78
from typing import Any
89
from typing import Callable
910
from typing import Dict
@@ -30,6 +31,15 @@
3031
from rust_decider import ValueTypeMismatchException
3132
from typing_extensions import Literal
3233

34+
from .prometheus_metrics import experiments_client_counter
35+
36+
# get package's version for metrics
37+
if py_version >= (3, 8):
38+
from importlib.metadata import version as pkg_version, PackageNotFoundError
39+
else:
40+
from importlib_metadata import version as pkg_version, PackageNotFoundError
41+
42+
_pkg_version = pkg_version("reddit-experiments")
3343

3444
logger = logging.getLogger(__name__)
3545

@@ -990,56 +1000,76 @@ def _minimal_decider(
9901000
)
9911001

9921002
def make_object_for_context(self, name: str, span: Span) -> Decider:
1003+
# initialize rust decider from watched manifest file
9931004
rs_decider = None
9941005
try:
9951006
rs_decider = self._filewatcher.get_data()
9961007
except WatchedFileNotAvailableError as exc:
1008+
experiments_client_counter.labels(
1009+
operation="make_object_for_context",
1010+
success="false",
1011+
error_type="watched_file_not_available",
1012+
pkg_version=_pkg_version,
1013+
).inc()
1014+
9971015
logger.error(f"Experiment config file unavailable: {exc}")
9981016

1017+
# check for `span`'s presence
9991018
if span is None:
1019+
experiments_client_counter.labels(
1020+
operation="make_object_for_context",
1021+
success="false",
1022+
error_type="missing_span",
1023+
pkg_version=_pkg_version,
1024+
).inc()
1025+
10001026
logger.debug("`span` is `None` in reddit_decider `make_object_for_context()`.")
10011027
return self._minimal_decider(internal=rs_decider, name=name, span=span)
10021028

1003-
request = None
1029+
# check for `span.context`'s presence
1030+
request = getattr(span, "context", None)
1031+
1032+
if request is None:
1033+
experiments_client_counter.labels(
1034+
operation="make_object_for_context",
1035+
success="false",
1036+
error_type="missing_span_context",
1037+
pkg_version=_pkg_version,
1038+
).inc()
1039+
1040+
return self._minimal_decider(
1041+
internal=rs_decider,
1042+
name=name,
1043+
span=span,
1044+
)
1045+
1046+
# extract fields from `span.context` if `self._request_field_extractor` is defined
10041047
parsed_extracted_fields = None
10051048
try:
1006-
request = span.context
1007-
10081049
if self._request_field_extractor:
10091050
extracted_fields = self._request_field_extractor(request)
10101051
# prune any invalid keys/values
10111052
parsed_extracted_fields = self._prune_extracted_dict(
10121053
extracted_dict=extracted_fields
10131054
)
10141055
except Exception as exc:
1056+
experiments_client_counter.labels(
1057+
operation="make_object_for_context",
1058+
success="false",
1059+
error_type="request_field_extractor",
1060+
pkg_version=_pkg_version,
1061+
).inc()
1062+
10151063
logger.info(
10161064
f"Unable to extract fields from `request_field_extractor()` in `make_object_for_context()`. details: {exc}"
10171065
)
1066+
# re-raise exception raised by `_request_field_extractor`
1067+
# since it's user-defined & should be made visible
1068+
raise exc
10181069

1019-
ec = None
1020-
try:
1021-
# if `edge_context` is inaccessible, bail early
1022-
if request is None:
1023-
return self._minimal_decider(
1024-
internal=rs_decider,
1025-
name=name,
1026-
span=span,
1027-
parsed_extracted_fields=parsed_extracted_fields,
1028-
)
1029-
1030-
ec = request.edge_context
1031-
1032-
if ec is None:
1033-
return self._minimal_decider(
1034-
internal=rs_decider,
1035-
name=name,
1036-
span=span,
1037-
parsed_extracted_fields=parsed_extracted_fields,
1038-
)
1039-
except Exception as exc:
1040-
logger.info(
1041-
f"Unable to access `request.edge_context` in `make_object_for_context()`. details: {exc}"
1042-
)
1070+
ec = getattr(request, "edge_context", None)
1071+
# if `edge_context` is inaccessible, bail field extraction early
1072+
if ec is None:
10431073
return self._minimal_decider(
10441074
internal=rs_decider,
10451075
name=name,
@@ -1048,7 +1078,6 @@ def make_object_for_context(self, name: str, span: Span) -> Decider:
10481078
)
10491079

10501080
# All fields below are derived from `edge_context`
1051-
10521081
user_id = None
10531082
logged_in = None
10541083
cookie_created_timestamp = None
@@ -1139,6 +1168,13 @@ def make_object_for_context(self, name: str, span: Span) -> Decider:
11391168
extracted_fields=parsed_extracted_fields,
11401169
)
11411170
except Exception as exc:
1171+
experiments_client_counter.labels(
1172+
operation="make_object_for_context",
1173+
success="false",
1174+
error_type="DeciderContext_init_failed",
1175+
pkg_version=_pkg_version,
1176+
).inc()
1177+
11421178
logger.warning(
11431179
f"Could not create full DeciderContext() (defaulting to empty DeciderContext()): {exc}"
11441180
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from prometheus_client import Counter
2+
3+
experiments_client_counter = Counter(
4+
"experiments_py_client_total",
5+
"Count of successful/failed Experiments.py operations (with error_type) in reddit-experiments package",
6+
["operation", "success", "error_type", "pkg_version"],
7+
)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ black==21.4b2
44
reddit-decider==1.4.1
55
flake8==3.9.1
66
mypy==0.790
7+
prometheus-client>=0.12.0,<1.0
78
pyramid==2.0 # required for `from baseplate.frameworks.pyramid import BaseplateRequest` which calls `import pyramid.events`
89
pydocstyle==5.1.1
910
pytest==6.2.5

tests/decider_tests.py

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ def setUp(self):
8686
super().setUp()
8787
self.event_logger = mock.Mock(spec=DebugLogger)
8888
self.mock_span = mock.MagicMock(spec=ServerSpan)
89-
self.mock_span.context = None
9089

9190
def test_make_client_without_timeout_set(self, file_watcher_mock):
9291
with create_temp_config_file({}) as f:
@@ -219,7 +218,8 @@ def test_make_object_for_context_and_decider_context(self):
219218
self.assertEqual(decider_event_dict["canonical_url"], CANONICAL_URL)
220219
self.assertEqual(decider_event_dict["request"]["canonical_url"], CANONICAL_URL)
221220

222-
def test_make_object_for_context_and_decider_context_without_span(self):
221+
@mock.patch("reddit_decider.experiments_client_counter.labels")
222+
def test_make_object_for_context_without_span(self, metric_counter_labels):
223223
with create_temp_config_file({}) as f:
224224
decider_ctx_factory = decider_client_from_config(
225225
{"experiments.path": f.name, "experiments.timeout": "2 seconds"},
@@ -236,13 +236,56 @@ def test_make_object_for_context_and_decider_context_without_span(self):
236236
assert len(captured.records) == 1
237237
self.assertEqual(["WARNING:root:Dummy warning"], captured.output)
238238

239+
metric_counter_labels.assert_called_once_with(
240+
operation="make_object_for_context",
241+
success="false",
242+
error_type="missing_span",
243+
pkg_version=mock.ANY,
244+
)
245+
239246
self.assertIsInstance(decider, Decider)
240247

241248
decider_ctx_dict = decider._decider_context.to_dict()
242249
self.assertEqual(decider_ctx_dict["user_id"], None)
243250

244-
def test_make_object_for_context_and_decider_context_with_broken_decider_field_extractor(self):
245-
def broken_decider_field_extractor(_request: RequestContext):
251+
@mock.patch("reddit_decider.experiments_client_counter.labels")
252+
def test_make_object_for_context_with_span_context_as_None(self, metric_counter_labels):
253+
with create_temp_config_file({}) as f:
254+
decider_ctx_factory = decider_client_from_config(
255+
{"experiments.path": f.name, "experiments.timeout": "2 seconds"},
256+
self.event_logger,
257+
prefix="experiments.",
258+
request_field_extractor=decider_field_extractor,
259+
)
260+
261+
mock_span = mock.MagicMock(spec=ServerSpan)
262+
# span is missing context
263+
264+
with self.assertLogs(logger, logging.WARN) as captured:
265+
# ensure no warnings are printed except for the dummy one
266+
# https://stackoverflow.com/a/61381576/4260179
267+
logger.warning("Dummy warning")
268+
269+
decider = decider_ctx_factory.make_object_for_context(name="test", span=mock_span)
270+
assert len(captured.records) == 1
271+
self.assertEqual(["WARNING:root:Dummy warning"], captured.output)
272+
273+
metric_counter_labels.assert_called_once_with(
274+
operation="make_object_for_context",
275+
success="false",
276+
error_type="missing_span_context",
277+
pkg_version=mock.ANY,
278+
)
279+
280+
self.assertIsInstance(decider, Decider)
281+
282+
decider_ctx_dict = decider._decider_context.to_dict()
283+
self.assertEqual(decider_ctx_dict["user_id"], None)
284+
285+
def test_make_object_for_context_and_decider_context_with_malformed_decider_field_extractor(
286+
self,
287+
):
288+
def decider_field_extractor_with_malformed_fields(_request: RequestContext):
246289
return {
247290
"app_name": {},
248291
"build_number": BUILD_NUMBER,
@@ -256,7 +299,7 @@ def broken_decider_field_extractor(_request: RequestContext):
256299
{"experiments.path": f.name, "experiments.timeout": "2 seconds"},
257300
self.event_logger,
258301
prefix="experiments.",
259-
request_field_extractor=broken_decider_field_extractor,
302+
request_field_extractor=decider_field_extractor_with_malformed_fields,
260303
)
261304

262305
with self.assertLogs() as captured:
@@ -280,6 +323,39 @@ def broken_decider_field_extractor(_request: RequestContext):
280323
for x in captured.records
281324
)
282325

326+
@mock.patch("reddit_decider.experiments_client_counter.labels")
327+
def test_make_object_for_context_with_broken_decider_field_extractor_raises_exception(
328+
self, metric_counter_labels
329+
):
330+
class SomeException(Exception):
331+
pass
332+
333+
def broken_decider_field_extractor(_request: RequestContext):
334+
raise SomeException("bad extractor")
335+
336+
with create_temp_config_file({}) as f:
337+
decider_ctx_factory = decider_client_from_config(
338+
{"experiments.path": f.name, "experiments.timeout": "2 seconds"},
339+
self.event_logger,
340+
prefix="experiments.",
341+
request_field_extractor=broken_decider_field_extractor,
342+
)
343+
344+
with self.assertRaises(SomeException) as e:
345+
decider_ctx_factory.make_object_for_context(name="test", span=self.mock_span)
346+
347+
self.assertEqual(
348+
str(e.exception),
349+
"bad extractor",
350+
)
351+
352+
metric_counter_labels.assert_called_once_with(
353+
operation="make_object_for_context",
354+
success="false",
355+
error_type="request_field_extractor",
356+
pkg_version=mock.ANY,
357+
)
358+
283359

284360
# Todo: test DeciderClient()
285361
# @mock.patch("reddit_decider.FileWatcher")

0 commit comments

Comments
 (0)