Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/sentry/metrics/minimetrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import sentry_sdk

try:
from sentry_sdk.metrics import Metric, MetricsAggregator # type: ignore
from sentry_sdk.metrics import Metric, MetricsAggregator, metrics_noop # type: ignore

have_minimetrics = True
except ImportError:
Expand All @@ -25,17 +25,22 @@ def patch_sentry_sdk():
real_add = MetricsAggregator.add
real_emit = MetricsAggregator._emit

@wraps(real_add)
def tracked_add(self, ty, *args, **kwargs):
real_add(self, ty, *args, **kwargs)
@metrics_noop
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual fix #1

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would honestly fix it in a different way, since this code works under the assumption that the SDK sets internally the thread local. I would honestly implement it differently and more reliably by just adding the @enter_minimetrics decorator to the monkeypatched functions and protecting the call to the SDK inside of the MinimetricsMetricsBackend.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to #57782 but with the finally implementation since it doesn't clear up the state in case of an exception.

Copy link
Contributor Author

@mitsuhiko mitsuhiko Oct 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this code works under the assumption that the SDK sets internally the thread local

I don't believe the code makes such an assumption as metrics_noop sets the thread local.

The risk of a second thread local is this one:

I temporarily considered moving metrics_noop to sentry itself, but the protection still makes sense in the main SDK. Reason being that the transport is in parts (via client) invoked from the flush logic and this can create metrics cycles even without the sentry specific metrics system.

I believe if they do not use the same thread local we have paths where only minimetrics protects itself, but not the outer code path.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok so it sets also the thread local, I thought it was only checking it and not triggering the function.

def report_tracked_add(ty):
metrics.incr(
key="minimetrics.add",
amount=1,
tags={"metric_type": ty},
sample_rate=1.0,
)

@wraps(real_add)
def tracked_add(self, ty, *args, **kwargs):
real_add(self, ty, *args, **kwargs)
report_tracked_add(ty)

@wraps(real_emit)
@metrics_noop
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual fix #2.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note here: i want to add a change to the python SDK which protects the manual flush call. Once that lands, the real_emit must not be marked as metrics_noop or it stops being called. The unittests should catch this though.

def patched_emit(self, flushable_buckets: Iterable[Tuple[int, Dict[Any, Metric]]]):
flushable_metrics = []
stats_by_type: Any = {}
Expand Down
69 changes: 64 additions & 5 deletions tests/sentry/metrics/test_minimetrics.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from typing import Any, Dict
from unittest import mock

import pytest
from sentry_sdk import Client, Hub, Transport

from sentry.metrics.composite_experimental import CompositeExperimentalMetricsBackend
from sentry.metrics.minimetrics import (
MiniMetricsMetricsBackend,
before_emit_metric,
Expand All @@ -11,6 +13,15 @@
from sentry.testutils.helpers import override_options


def full_flush(hub):
# first flush flushes the metrics
hub.client.flush()

# second flush should really not do anything unless the first
# flush accidentally created more metrics
hub.client.flush()


def parse_metrics(bytes: bytes):
rv = []
for line in bytes.splitlines():
Expand Down Expand Up @@ -77,7 +88,13 @@ def hub():

@pytest.fixture(scope="function")
def backend():
return MiniMetricsMetricsBackend(prefix="sentrytest.")
# Make sure we also patch the global metrics backend as the backend
# will forward internal metrics to it (back into itself). If we don't
# set this up correctly, we might accidentally break our recursion
# protection and not see these tests fail.
rv = MiniMetricsMetricsBackend(prefix="sentrytest.")
with mock.patch("sentry.utils.metrics.backend", new=rv):
yield rv


@pytest.mark.skipif(not have_minimetrics, reason="no minimetrics")
Expand All @@ -89,7 +106,7 @@ def backend():
)
def test_incr_called_with_no_tags(backend, hub):
backend.incr(key="foo", tags={"x": "y"})
hub.client.flush()
full_flush(hub)

metrics = hub.client.transport.get_metrics()

Expand All @@ -113,7 +130,7 @@ def test_incr_called_with_no_tags(backend, hub):
)
def test_incr_called_with_no_tags_and_no_common_tags(backend, hub):
backend.incr(key="foo", tags={"x": "y"})
hub.client.flush()
full_flush(hub)

metrics = hub.client.transport.get_metrics()

Expand All @@ -138,7 +155,7 @@ def test_incr_called_with_no_tags_and_no_common_tags(backend, hub):
def test_incr_called_with_tag_value_as_list(backend, hub):
# The minimetrics backend supports the list type.
backend.incr(key="foo", tags={"x": ["bar", "baz"]})
hub.client.flush()
full_flush(hub)

metrics = hub.client.transport.get_metrics()

Expand All @@ -159,7 +176,7 @@ def test_incr_called_with_tag_value_as_list(backend, hub):
def test_gauge_as_counter(backend, hub):
# The minimetrics backend supports the list type.
backend.gauge(key="foo", value=42.0)
hub.client.flush()
full_flush(hub)

metrics = hub.client.transport.get_metrics()

Expand All @@ -171,6 +188,48 @@ def test_gauge_as_counter(backend, hub):
assert len(hub.client.metrics_aggregator.buckets) == 0


@pytest.mark.skipif(not have_minimetrics, reason="no minimetrics")
@override_options(
{
"delightful_metrics.enable_capture_envelope": True,
"delightful_metrics.enable_common_tags": True,
"delightful_metrics.minimetrics_sample_rate": 1.0,
"delightful_metrics.allow_all_incr": True,
"delightful_metrics.allow_all_timing": True,
"delightful_metrics.allow_all_gauge": True,
}
)
def test_composite_backend_does_not_recurse(hub):
composite_backend = CompositeExperimentalMetricsBackend(
primary_backend="sentry.metrics.dummy.DummyMetricsBackend"
)
accessed = set()

class TrackingCompositeBackend:
def __getattr__(self, name):
accessed.add(name)
return getattr(composite_backend, name)

# make sure the backend feeds back to itself
with mock.patch("sentry.utils.metrics.backend", new=TrackingCompositeBackend()):
composite_backend.incr(key="sentrytest.composite", tags={"x": "bar"})
full_flush(hub)

# make sure that we did actually internally forward to the composite
# backend so the test does not accidentally succeed.
assert "incr" in accessed
assert "timing" in accessed

metrics = hub.client.transport.get_metrics()

# the minimetrics.add metric must not show up
assert len(metrics) == 1
assert metrics[0][1] == "sentry.sentrytest.composite@none"
assert metrics[0][4]["x"] == "bar"

assert len(hub.client.metrics_aggregator.buckets) == 0


def test_did_you_remove_type_ignore():
from importlib.metadata import version

Expand Down