Skip to content

Commit ee683a4

Browse files
authored
Merge pull request #112 from reddit/re_raise_some_exceptions_and_add_metrics
Re-raise exceptions from `request_field_extractor` + prom metrics
2 parents 4f0065a + 7c78ac1 commit ee683a4

File tree

6 files changed

+149
-38
lines changed

6 files changed

+149
-38
lines changed

docs/index.rst

+4-3
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ Setup :code:`reddit-experiments` in your application's configuration file:
3939
...
4040
4141
# optional: a path to the file where experiment configuration is written
42-
# (default: /var/local/experiments.json)
43-
experiments.path = /var/local/foo.json
42+
# default: /var/local/experiments.json
43+
# note: production systems load the experiments.json file under nested `live-data/` dir
44+
experiments.path = /var/local/live-data/experiments.json
4445
4546
# optional: how long to wait for the experiments file to exist before failing
4647
# default:
@@ -50,7 +51,7 @@ Setup :code:`reddit-experiments` in your application's configuration file:
5051
5152
# optional: the base amount of time for exponential backoff while waiting
5253
# for the file to be available.
53-
# (default: no backoff time between tries)
54+
# default: no backoff time between tries
5455
experiments.backoff = 1 second
5556
5657
...

pyproject.toml

+5
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

+51-30
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import logging
2+
import sys
23

34
from copy import deepcopy
45
from dataclasses import dataclass
56
from datetime import timedelta
67
from enum import Enum
78
from typing import Any
89
from typing import Callable
10+
from typing import cast
911
from typing import Dict
1012
from typing import IO
1113
from typing import List
@@ -30,6 +32,20 @@
3032
from rust_decider import ValueTypeMismatchException
3133
from typing_extensions import Literal
3234

35+
from .prometheus_metrics import experiments_client_counter
36+
37+
# get package's version for prometheus metrics
38+
if sys.version_info >= (3, 8):
39+
from importlib.metadata import version as pkg_version, PackageNotFoundError
40+
else:
41+
from importlib_metadata import version as pkg_version, PackageNotFoundError
42+
43+
try:
44+
# see https://github.com/python/mypy/issues/8823#issuecomment-1484368501
45+
# for why cast is used (mypy)
46+
_pkg_version = cast(Callable[[str], str], pkg_version)("reddit-experiments")
47+
except PackageNotFoundError:
48+
_pkg_version = ""
3349

3450
logger = logging.getLogger(__name__)
3551

@@ -990,56 +1006,61 @@ def _minimal_decider(
9901006
)
9911007

9921008
def make_object_for_context(self, name: str, span: Span) -> Decider:
1009+
def inc_failure_counter(failure_type: str) -> None:
1010+
experiments_client_counter.labels(
1011+
operation="make_object_for_context",
1012+
success="false",
1013+
error_type=failure_type,
1014+
pkg_version=_pkg_version,
1015+
).inc()
1016+
1017+
# initialize rust decider from watched manifest file
9931018
rs_decider = None
9941019
try:
9951020
rs_decider = self._filewatcher.get_data()
9961021
except WatchedFileNotAvailableError as exc:
1022+
inc_failure_counter("watched_file_not_available")
9971023
logger.error(f"Experiment config file unavailable: {exc}")
9981024

1025+
# check for `span`'s presence
9991026
if span is None:
1027+
inc_failure_counter("missing:'span'")
10001028
logger.debug("`span` is `None` in reddit_decider `make_object_for_context()`.")
10011029
return self._minimal_decider(internal=rs_decider, name=name, span=span)
10021030

1003-
request = None
1031+
# check for `span.context`'s presence
1032+
request = getattr(span, "context", None)
1033+
1034+
if request is None:
1035+
inc_failure_counter("missing:'span.context'")
1036+
return self._minimal_decider(
1037+
internal=rs_decider,
1038+
name=name,
1039+
span=span,
1040+
)
1041+
1042+
# extract fields from `span.context` if `self._request_field_extractor` is defined
10041043
parsed_extracted_fields = None
10051044
try:
1006-
request = span.context
1007-
10081045
if self._request_field_extractor:
10091046
extracted_fields = self._request_field_extractor(request)
10101047
# prune any invalid keys/values
10111048
parsed_extracted_fields = self._prune_extracted_dict(
10121049
extracted_dict=extracted_fields
10131050
)
10141051
except Exception as exc:
1015-
logger.info(
1052+
inc_failure_counter("request_field_extractor")
1053+
logger.error(
10161054
f"Unable to extract fields from `request_field_extractor()` in `make_object_for_context()`. details: {exc}"
10171055
)
1018-
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-
)
1056+
# re-raise exception raised by `_request_field_extractor`
1057+
# since it's user-defined & should be made visible
1058+
raise exc
1059+
1060+
ec = getattr(request, "edge_context", None)
1061+
# if `edge_context` is inaccessible, bail field extraction early
1062+
if ec is None:
1063+
inc_failure_counter("missing:'request.edge_context'")
10431064
return self._minimal_decider(
10441065
internal=rs_decider,
10451066
name=name,
@@ -1048,7 +1069,6 @@ def make_object_for_context(self, name: str, span: Span) -> Decider:
10481069
)
10491070

10501071
# All fields below are derived from `edge_context`
1051-
10521072
user_id = None
10531073
logged_in = None
10541074
cookie_created_timestamp = None
@@ -1139,6 +1159,7 @@ def make_object_for_context(self, name: str, span: Span) -> Decider:
11391159
extracted_fields=parsed_extracted_fields,
11401160
)
11411161
except Exception as exc:
1162+
inc_failure_counter("DeciderContext_init_failed")
11421163
logger.warning(
11431164
f"Could not create full DeciderContext() (defaulting to empty DeciderContext()): {exc}"
11441165
)

reddit_decider/prometheus_metrics.py

+7
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

+1
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

+81-5
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)