Skip to content

Commit 62dfec9

Browse files
authored
feat(metrics): Stronger recursion protection (#2426)
1 parent 99aea33 commit 62dfec9

File tree

2 files changed

+67
-11
lines changed

2 files changed

+67
-11
lines changed

sentry_sdk/metrics.py

+36-11
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import zlib
88
from functools import wraps, partial
99
from threading import Event, Lock, Thread
10+
from contextlib import contextmanager
1011

1112
from sentry_sdk._compat import text_type
1213
from sentry_sdk.hub import Hub
@@ -26,6 +27,7 @@
2627
from typing import Iterable
2728
from typing import Callable
2829
from typing import Optional
30+
from typing import Generator
2931
from typing import Tuple
3032

3133
from sentry_sdk._types import BucketKey
@@ -53,21 +55,33 @@
5355
)
5456

5557

58+
@contextmanager
59+
def recursion_protection():
60+
# type: () -> Generator[bool, None, None]
61+
"""Enters recursion protection and returns the old flag."""
62+
try:
63+
in_metrics = _thread_local.in_metrics
64+
except AttributeError:
65+
in_metrics = False
66+
_thread_local.in_metrics = True
67+
try:
68+
yield in_metrics
69+
finally:
70+
_thread_local.in_metrics = in_metrics
71+
72+
5673
def metrics_noop(func):
5774
# type: (Any) -> Any
75+
"""Convenient decorator that uses `recursion_protection` to
76+
make a function a noop.
77+
"""
78+
5879
@wraps(func)
5980
def new_func(*args, **kwargs):
6081
# type: (*Any, **Any) -> Any
61-
try:
62-
in_metrics = _thread_local.in_metrics
63-
except AttributeError:
64-
in_metrics = False
65-
_thread_local.in_metrics = True
66-
try:
82+
with recursion_protection() as in_metrics:
6783
if not in_metrics:
6884
return func(*args, **kwargs)
69-
finally:
70-
_thread_local.in_metrics = in_metrics
7185

7286
return new_func
7387

@@ -449,7 +463,16 @@ def _emit(
449463
encoded_metrics = _encode_metrics(flushable_buckets)
450464
metric_item = Item(payload=encoded_metrics, type="statsd")
451465
envelope = Envelope(items=[metric_item])
452-
self._capture_func(envelope)
466+
467+
# A malfunctioning transport might create a forever loop of metric
468+
# emission when it emits a metric in capture_envelope. We still
469+
# allow the capture to take place, but interior metric incr calls
470+
# or similar will be disabled. In the background thread this can
471+
# never happen, but in the force flush case which happens in the
472+
# foreground we might make it here unprotected.
473+
with recursion_protection():
474+
self._capture_func(envelope)
475+
453476
return envelope
454477

455478
def _serialize_tags(
@@ -495,8 +518,10 @@ def _get_aggregator_and_update_tags(key, tags):
495518

496519
callback = client.options.get("_experiments", {}).get("before_emit_metric")
497520
if callback is not None:
498-
if not callback(key, updated_tags):
499-
return None, updated_tags
521+
with recursion_protection() as in_metrics:
522+
if not in_metrics:
523+
if not callback(key, updated_tags):
524+
return None, updated_tags
500525

501526
return client.metrics_aggregator, updated_tags
502527

tests/test_metrics.py

+31
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,8 @@ def before_emit(key, tags):
418418
return False
419419
tags["extra"] = "foo"
420420
del tags["release"]
421+
# this better be a noop!
422+
metrics.incr("shitty-recursion")
421423
return True
422424

423425
sentry_init(
@@ -501,3 +503,32 @@ def test_tag_serialization(sentry_init, capture_envelopes):
501503
"release": "fun-release",
502504
"environment": "not-fun-env",
503505
}
506+
507+
508+
def test_flush_recursion_protection(sentry_init, capture_envelopes, monkeypatch):
509+
sentry_init(
510+
release="fun-release",
511+
environment="not-fun-env",
512+
_experiments={"enable_metrics": True},
513+
)
514+
envelopes = capture_envelopes()
515+
test_client = Hub.current.client
516+
517+
real_capture_envelope = test_client.transport.capture_envelope
518+
519+
def bad_capture_envelope(*args, **kwargs):
520+
metrics.incr("bad-metric")
521+
return real_capture_envelope(*args, **kwargs)
522+
523+
monkeypatch.setattr(test_client.transport, "capture_envelope", bad_capture_envelope)
524+
525+
metrics.incr("counter")
526+
527+
# flush twice to see the inner metric
528+
Hub.current.flush()
529+
Hub.current.flush()
530+
531+
(envelope,) = envelopes
532+
m = parse_metrics(envelope.items[0].payload.get_bytes())
533+
assert len(m) == 1
534+
assert m[0][1] == "counter@none"

0 commit comments

Comments
 (0)