Skip to content

Commit 3a41301

Browse files
authored
Add a new DualWriteMetricsBackend (#97754)
This is very similar to the previously removed `CompositeExperimentalMetricsBackend`, and does what its name implies: You can configure it with a primary and secondary backend/options, and an allowlist of prefixes. All metrics that match the allowlist are routed to the secondary backend in addition to the primary one.
1 parent a1371c0 commit 3a41301

File tree

2 files changed

+163
-0
lines changed

2 files changed

+163
-0
lines changed

src/sentry/metrics/dualwrite.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
from typing import Any
2+
3+
from sentry.metrics.base import MetricsBackend, Tags
4+
from sentry.metrics.dummy import DummyMetricsBackend
5+
from sentry.utils.imports import import_string
6+
7+
__all__ = ["DualWriteMetricsBackend"]
8+
9+
10+
def _initialize_backend(backend: str | None, backend_args: dict[str, Any]) -> MetricsBackend:
11+
if backend is None:
12+
return DummyMetricsBackend()
13+
else:
14+
cls: type[MetricsBackend] = import_string(backend)
15+
return cls(**backend_args)
16+
17+
18+
class DualWriteMetricsBackend(MetricsBackend):
19+
def __init__(self, **kwargs: Any):
20+
super().__init__()
21+
self._primary_backend = _initialize_backend(
22+
kwargs.pop("primary_backend", None), kwargs.pop("primary_backend_args", {})
23+
)
24+
self._secondary_backend = _initialize_backend(
25+
kwargs.pop("secondary_backend", None), kwargs.pop("secondary_backend_args", {})
26+
)
27+
28+
self._allow_prefixes = tuple(kwargs.pop("allow_prefixes", []))
29+
30+
def _is_allowed(self, key: str) -> bool:
31+
return key.startswith(self._allow_prefixes)
32+
33+
def incr(
34+
self,
35+
key: str,
36+
instance: str | None = None,
37+
tags: Tags | None = None,
38+
amount: float | int = 1,
39+
sample_rate: float = 1,
40+
unit: str | None = None,
41+
stacklevel: int = 0,
42+
) -> None:
43+
self._primary_backend.incr(key, instance, tags, amount, sample_rate, unit, stacklevel + 1)
44+
if self._is_allowed(key):
45+
self._secondary_backend.incr(
46+
key, instance, tags, amount, sample_rate, unit, stacklevel + 1
47+
)
48+
49+
def timing(
50+
self,
51+
key: str,
52+
value: float,
53+
instance: str | None = None,
54+
tags: Tags | None = None,
55+
sample_rate: float = 1,
56+
stacklevel: int = 0,
57+
) -> None:
58+
self._primary_backend.timing(key, value, instance, tags, sample_rate, stacklevel + 1)
59+
if self._is_allowed(key):
60+
self._secondary_backend.timing(key, value, instance, tags, sample_rate, stacklevel + 1)
61+
62+
def gauge(
63+
self,
64+
key: str,
65+
value: float,
66+
instance: str | None = None,
67+
tags: Tags | None = None,
68+
sample_rate: float = 1,
69+
unit: str | None = None,
70+
stacklevel: int = 0,
71+
) -> None:
72+
self._primary_backend.gauge(key, value, instance, tags, sample_rate, unit, stacklevel + 1)
73+
if self._is_allowed(key):
74+
self._secondary_backend.gauge(
75+
key, value, instance, tags, sample_rate, unit, stacklevel + 1
76+
)
77+
78+
def distribution(
79+
self,
80+
key: str,
81+
value: float,
82+
instance: str | None = None,
83+
tags: Tags | None = None,
84+
sample_rate: float = 1,
85+
unit: str | None = None,
86+
stacklevel: int = 0,
87+
) -> None:
88+
self._primary_backend.distribution(
89+
key, value, instance, tags, sample_rate, unit, stacklevel + 1
90+
)
91+
if self._is_allowed(key):
92+
self._secondary_backend.distribution(
93+
key, value, instance, tags, sample_rate, unit, stacklevel + 1
94+
)
95+
96+
def event(
97+
self,
98+
title: str,
99+
message: str,
100+
alert_type: str | None = None,
101+
aggregation_key: str | None = None,
102+
source_type_name: str | None = None,
103+
priority: str | None = None,
104+
instance: str | None = None,
105+
tags: Tags | None = None,
106+
stacklevel: int = 0,
107+
) -> None:
108+
self._primary_backend.event(
109+
title,
110+
message,
111+
alert_type,
112+
aggregation_key,
113+
source_type_name,
114+
priority,
115+
instance,
116+
tags,
117+
stacklevel + 1,
118+
)
119+
if self._is_allowed(title):
120+
self._secondary_backend.event(
121+
title,
122+
message,
123+
alert_type,
124+
aggregation_key,
125+
source_type_name,
126+
priority,
127+
instance,
128+
tags,
129+
stacklevel + 1,
130+
)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from unittest import mock
2+
3+
from sentry.metrics.dualwrite import DualWriteMetricsBackend
4+
5+
6+
@mock.patch("datadog.threadstats.base.ThreadStats.timing")
7+
@mock.patch("datadog.dogstatsd.base.DogStatsd.distribution")
8+
def test_dualwrite_distribution(distribution, timing):
9+
backend = DualWriteMetricsBackend(
10+
primary_backend="sentry.metrics.datadog.DatadogMetricsBackend",
11+
secondary_backend="sentry.metrics.precise_dogstatsd.PreciseDogStatsdMetricsBackend",
12+
allow_prefixes=["foo"],
13+
)
14+
15+
backend.distribution("foo", 100, tags={"some": "stuff"}, unit="byte")
16+
# datadog treats distributions as timing
17+
timing.assert_called_once()
18+
distribution.assert_called_once()
19+
20+
timing.reset_mock()
21+
distribution.reset_mock()
22+
23+
backend.timing("foo", 100, tags={"some": "stuff"})
24+
# precise datadog treats timing as distribution
25+
timing.assert_called_once()
26+
distribution.assert_called_once()
27+
28+
timing.reset_mock()
29+
distribution.reset_mock()
30+
31+
backend.timing("bar", 100, tags={"some": "stuff"})
32+
timing.assert_called_once()
33+
distribution.assert_not_called()

0 commit comments

Comments
 (0)