From 012e00d9f41f52b804aeaaf9583e24980a7820de Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 11:37:03 +0200 Subject: [PATCH 01/36] feat(metrics): Move minimetrics code to the SDK --- sentry_sdk/_types.py | 53 ++++++++++++++++++++++++++++++++++++++++++ sentry_sdk/client.py | 13 +++++++++++ sentry_sdk/consts.py | 1 + sentry_sdk/envelope.py | 2 ++ 4 files changed, 69 insertions(+) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index cbead04e2e..5fd2a1ff50 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -13,7 +13,11 @@ from typing import Any from typing import Callable from typing import Dict + from typing import Iterable + from typing import List + from typing import Mapping from typing import Optional + from typing import Sequence from typing import Tuple from typing import Type from typing import Union @@ -87,3 +91,52 @@ MeasurementUnit = Union[DurationUnit, InformationUnit, FractionUnit, str] ProfilerMode = Literal["sleep", "thread", "gevent", "unknown"] + + # Unit of the metrics. + MetricUnit = Literal[ + "none", + "nanosecond", + "microsecond", + "millisecond", + "second", + "minute", + "hour", + "day", + "week", + "bit", + "byte", + "kilobyte", + "kibibyte", + "mebibyte", + "gigabyte", + "terabyte", + "tebibyte", + "petabyte", + "pebibyte", + "exabyte", + "exbibyte", + "ratio", + "percent", + ] + + # Type of the metric. + MetricType = Literal["d", "s", "g", "c"] + + # Value of the metric. + MetricValue = Union[int, float, str] + + # Tag key of a metric. + MetricTagKey = str + + # Internal representation of tags as a tuple of tuples (this is done in order to allow for the same key to exist + # multiple times). + MetricTagsInternal = Tuple[Tuple[MetricTagKey, str], ...] + + # External representation of tags as a dictionary. + MetricTagValue = Union[str, List[str], Tuple[str, ...]] + MetricTags = Mapping[MetricTagKey, MetricTagValue] + + # Value inside the generator for the metric value. + FlushedMetricValue = Union[int, float] + + BucketKey = Tuple[MetricType, str, MetricUnit, MetricTagsInternal] diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 10e983d736..a70eb52b64 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -229,6 +229,15 @@ def _capture_envelope(envelope): self.session_flusher = SessionFlusher(capture_func=_capture_envelope) + if self.options.get("_experiments", {}).get("enable_metrics"): + from sentry_sdk.metrics import MetricsAggregator + + self.metrics_aggregator = MetricsAggregator( + capture_func=_capture_envelope + ) + else: + self.metrics_aggregator = None + max_request_body_size = ("always", "never", "small", "medium") if self.options["max_request_body_size"] not in max_request_body_size: raise ValueError( @@ -610,6 +619,8 @@ def close( if self.transport is not None: self.flush(timeout=timeout, callback=callback) self.session_flusher.kill() + if self.metrics_aggregator is not None: + self.metrics_aggregator.kill() if self.monitor: self.monitor.kill() self.transport.kill() @@ -632,6 +643,8 @@ def flush( if timeout is None: timeout = self.options["shutdown_timeout"] self.session_flusher.flush() + if self.metrics_aggregator is not None: + self.metrics_aggregator.flush() self.transport.flush(timeout=timeout, callback=callback) def __enter__(self): diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 026db5f7ff..651cf6c366 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -41,6 +41,7 @@ "profiler_mode": Optional[ProfilerMode], "otel_powered_performance": Optional[bool], "transport_zlib_compression_level": Optional[int], + "enable_metrics": Optional[bool], }, total=False, ) diff --git a/sentry_sdk/envelope.py b/sentry_sdk/envelope.py index fed5ed4849..a3e4b5a940 100644 --- a/sentry_sdk/envelope.py +++ b/sentry_sdk/envelope.py @@ -260,6 +260,8 @@ def data_category(self): return "internal" elif ty == "profile": return "profile" + elif ty == "statsd": + return "statsd" else: return "default" From c0de8c2682e6a45127c6d7f4f7c2d859ba929210 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 11:42:14 +0200 Subject: [PATCH 02/36] fix: typing issues --- sentry_sdk/_types.py | 9 ++------- sentry_sdk/client.py | 3 +-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 5fd2a1ff50..10cea697ea 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -13,11 +13,9 @@ from typing import Any from typing import Callable from typing import Dict - from typing import Iterable from typing import List from typing import Mapping from typing import Optional - from typing import Sequence from typing import Tuple from typing import Type from typing import Union @@ -125,16 +123,13 @@ # Value of the metric. MetricValue = Union[int, float, str] - # Tag key of a metric. - MetricTagKey = str - # Internal representation of tags as a tuple of tuples (this is done in order to allow for the same key to exist # multiple times). - MetricTagsInternal = Tuple[Tuple[MetricTagKey, str], ...] + MetricTagsInternal = Tuple[Tuple[str, str], ...] # External representation of tags as a dictionary. MetricTagValue = Union[str, List[str], Tuple[str, ...]] - MetricTags = Mapping[MetricTagKey, MetricTagValue] + MetricTags = Mapping[str, MetricTagValue] # Value inside the generator for the metric value. FlushedMetricValue = Union[int, float] diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index a70eb52b64..97fd17e06b 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -229,14 +229,13 @@ def _capture_envelope(envelope): self.session_flusher = SessionFlusher(capture_func=_capture_envelope) + self.metrics_aggregator = None # type: Optional[MetricsAggregator] if self.options.get("_experiments", {}).get("enable_metrics"): from sentry_sdk.metrics import MetricsAggregator self.metrics_aggregator = MetricsAggregator( capture_func=_capture_envelope ) - else: - self.metrics_aggregator = None max_request_body_size = ("always", "never", "small", "medium") if self.options["max_request_body_size"] not in max_request_body_size: From 4a9c5370c956700ac352724ea647ce2b7014b726 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 11:42:58 +0200 Subject: [PATCH 03/36] fix: add missing metrics module --- sentry_sdk/metrics.py | 517 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 sentry_sdk/metrics.py diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py new file mode 100644 index 0000000000..6e2ea395a0 --- /dev/null +++ b/sentry_sdk/metrics.py @@ -0,0 +1,517 @@ +import os +import io +import re +import threading +import time +import zlib +from functools import wraps, partial +from threading import Event, Lock, Thread +from contextlib import contextmanager + +from sentry_sdk.hub import Hub +from sentry_sdk.envelope import Envelope, Item +from sentry_sdk._types import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + from typing import Dict + from typing import Iterable + from typing import Callable + from typing import Optional + from typing import Tuple + from typing import Iterator + + from sentry_sdk._types import MetricValue + from sentry_sdk._types import MetricUnit + from sentry_sdk._types import MetricType + from sentry_sdk._types import MetricTags + from sentry_sdk._types import MetricTagsInternal + from sentry_sdk._types import FlushedMetricValue + from sentry_sdk._types import BucketKey + +thread_local = threading.local() + +_sanitize_value = partial(re.compile(r"[^a-zA-Z0-9_/.]").sub, "") + + +def in_minimetrics(): + # type: (...) -> bool + try: + return thread_local.in_minimetrics + except AttributeError: + return False + + +def minimetrics_noop(func): + # type: (Any) -> Any + @wraps(func) + def new_func(*args, **kwargs): + # type: (*Any, **Any) -> Any + try: + in_minimetrics = thread_local.in_minimetrics + except AttributeError: + in_minimetrics = False + thread_local.in_minimetrics = True + try: + if not in_minimetrics: + return func(*args, **kwargs) + finally: + thread_local.in_minimetrics = in_minimetrics + + return new_func + + +class Metric(object): + __slots__ = () + + @property + def weight(self): + # type: (...) -> int + raise NotImplementedError() + + def add( + self, value # type: MetricValue + ): + # type: (...) -> None + raise NotImplementedError() + + def serialize_value(self): + # type: (...) -> Iterable[FlushedMetricValue] + raise NotImplementedError() + + +class CounterMetric(Metric): + __slots__ = ("value",) + + def __init__( + self, first # type: MetricValue + ): + # type: (...) -> None + self.value = float(first) + + @property + def weight(self): + # type: (...) -> int + return 1 + + def add( + self, value # type: MetricValue + ): + # type: (...) -> None + self.value += float(value) + + def serialize_value(self): + # type: (...) -> Iterable[FlushedMetricValue] + return (self.value,) + + +class GaugeMetric(Metric): + __slots__ = ( + "last", + "min", + "max", + "sum", + "count", + ) + + def __init__( + self, first # type: MetricValue + ): + # type: (...) -> None + first = float(first) + self.last = first + self.min = first + self.max = first + self.sum = first + self.count = 1 + + @property + def weight(self): + # type: (...) -> int + # Number of elements. + return 5 + + def add( + self, value # type: MetricValue + ): + # type: (...) -> None + value = float(value) + self.last = value + self.min = min(self.min, value) + self.max = max(self.max, value) + self.sum += value + self.count += 1 + + def serialize_value(self): + # type: (...) -> Iterable[FlushedMetricValue] + return ( + self.last, + self.min, + self.max, + self.sum, + self.count, + ) + + +class DistributionMetric(Metric): + __slots__ = ("value",) + + def __init__( + self, first # type: MetricValue + ): + # type(...) -> None + self.value = [float(first)] + + @property + def weight(self): + # type: (...) -> int + return len(self.value) + + def add( + self, value # type: MetricValue + ): + # type: (...) -> None + self.value.append(float(value)) + + def serialize_value(self): + # type: (...) -> Iterable[FlushedMetricValue] + return self.value + + +class SetMetric(Metric): + __slots__ = ("value",) + + def __init__( + self, first # type: MetricValue + ): + # type: (...) -> None + self.value = {first} + + @property + def weight(self): + # type: (...) -> int + return len(self.value) + + def add( + self, value # type: MetricValue + ): + # type: (...) -> None + self.value.add(value) + + def serialize_value(self): + # type: (...) -> Iterable[FlushedMetricValue] + def _hash(x): + # type: (MetricValue) -> int + if isinstance(x, str): + return zlib.crc32(x.encode("utf-8")) & 0xFFFFFFFF + return int(x) + + return (_hash(value) for value in self.value) + + +def _encode_metrics(flushable_buckets): + # type: (Iterable[Tuple[int, Dict[BucketKey, Metric]]]) -> bytes + out = io.BytesIO() + _write = out.write + + for timestamp, buckets in flushable_buckets: + for bucket_key, metric in buckets.items(): + metric_type, metric_name, metric_unit, metric_tags = bucket_key + metric_name = _sanitize_value(metric_name) or "invalid-metric-name" + _write(metric_name.encode("utf-8")) + _write(b"@") + _write(metric_unit.encode("utf-8")) + + for serialized_value in metric.serialize_value(): + _write(b":") + _write(str(serialized_value).encode("utf-8")) + + _write(b"|") + _write(metric_type.encode("ascii")) + + if metric_tags: + _write(b"|#") + first = True + for tag_key, tag_value in metric_tags: + tag_key = _sanitize_value(tag_key) + if not tag_key: + continue + if first: + first = False + else: + _write(b",") + _write(tag_key.encode("utf-8")) + _write(b":") + _write(_sanitize_value(tag_value).encode("utf-8")) + + _write(b"|T") + _write(str(timestamp).encode("ascii")) + _write(b"\n") + + return out.getvalue() + + +METRIC_TYPES = { + "c": CounterMetric, + "g": GaugeMetric, + "d": DistributionMetric, + "s": SetMetric, +} + + +class MetricsAggregator(object): + ROLLUP_IN_SECONDS = 10.0 + MAX_WEIGHT = 100000 + + def __init__( + self, + capture_func, # type: Callable[[Envelope], None] + ): + # type: (...) -> None + self.buckets = {} # type: Dict[int, Any] + self._buckets_total_weight = 0 + self._capture_func = capture_func + self._lock = Lock() + self._running = True + self._flush_event = Event() + self._force_flush = False + + self._flusher = None # type: Optional[Thread] + self._flusher_pid = None # type: Optional[int] + self._ensure_thread() + + def _ensure_thread(self): + # type: (...) -> None + """For forking processes we might need to restart this thread. + This ensures that our process actually has that thread running. + """ + pid = os.getpid() + if self._flusher_pid == pid: + return + with self._lock: + self._flusher_pid = pid + self._flusher = Thread(target=self._flush_loop) + self._flusher.daemon = True + self._flusher.start() + + def _flush_loop(self): + # type: (...) -> None + thread_local.in_minimetrics = True + while self._running or self._force_flush: + self._flush() + if self._running: + self._flush_event.wait(5.0) + + def _flush(self): + # type: (...) -> None + flushable_buckets = self._flushable_buckets() + if flushable_buckets: + self._emit(flushable_buckets) + + def _flushable_buckets(self): + # type: (...) -> (Iterable[Tuple[int, Dict[BucketKey, Metric]]]) + with self._lock: + force_flush = self._force_flush + cutoff = time.time() - self.ROLLUP_IN_SECONDS + flushable_buckets = () # type: Iterable[Tuple[int, Dict[BucketKey, Metric]]] + weight_to_remove = 0 + + if force_flush: + flushable_buckets = self.buckets.items() + self.buckets = {} + self._buckets_total_weight = 0 + self._force_flush = False + else: + flushable_buckets = [] + for buckets_timestamp, buckets in self.buckets.items(): + # If the timestamp of the bucket is newer that the rollup we want to skip it. + if buckets_timestamp <= cutoff: + flushable_buckets.append((buckets_timestamp, buckets)) + + # We will clear the elements while holding the lock, in order to avoid requesting it downstream again. + for buckets_timestamp, buckets in flushable_buckets: + for _, metric in buckets.items(): + weight_to_remove += metric.weight + del self.buckets[buckets_timestamp] + + self._buckets_total_weight -= weight_to_remove + + return flushable_buckets + + @minimetrics_noop + def add( + self, + ty, # type: MetricType + key, # type: str + value, # type: MetricValue + unit, # type: MetricUnit + tags, # type: Optional[MetricTags] + timestamp=None, # type: Optional[float] + ): + # type: (...) -> None + self._ensure_thread() + + if self._flusher is None: + return + + if timestamp is None: + timestamp = time.time() + + bucket_timestamp = int( + (timestamp // self.ROLLUP_IN_SECONDS) * self.ROLLUP_IN_SECONDS + ) + bucket_key = ( + ty, + key, + unit, + self._serialize_tags(tags), + ) + + with self._lock: + local_buckets = self.buckets.setdefault(bucket_timestamp, {}) + metric = local_buckets.get(bucket_key) + if metric is not None: + previous_weight = metric.weight + metric.add(value) + else: + metric = local_buckets[bucket_key] = METRIC_TYPES[ty](value) + previous_weight = 0 + + self._buckets_total_weight += metric.weight - previous_weight + + # Given the new weight we consider whether we want to force flush. + self._consider_force_flush() + + def kill(self): + # type: (...) -> None + if self._flusher is None: + return + + self._running = False + self._flush_event.set() + self._flusher.join() + self._flusher = None + + def flush(self): + # type: (...) -> None + self._force_flush = True + self._flush_event.set() + + def _consider_force_flush(self): + # type: (...) -> None + # It's important to acquire a lock around this method, since it will touch shared data structures. + total_weight = len(self.buckets) + self._buckets_total_weight + if total_weight >= self.MAX_WEIGHT: + self._force_flush = True + self._flush_event.set() + + def _emit( + self, + flushable_buckets, # type: (Iterable[Tuple[int, Dict[BucketKey, Metric]]]) + ): + # type: (...) -> None + encoded_metrics = _encode_metrics(flushable_buckets) + metric_item = Item(payload=encoded_metrics, type="statsd") + envelope = Envelope(items=[metric_item]) + self._capture_func(envelope) + + def _serialize_tags( + self, tags # type: Optional[MetricTags] + ): + # type: (...) -> MetricTagsInternal + if not tags: + return () + + rv = [] + for key, value in tags.items(): + # If the value is a collection, we want to flatten it. + if isinstance(value, (list, tuple)): + for inner_value in value: + rv.append((key, inner_value)) + else: + rv.append((key, value)) + + # It's very important to sort the tags in order to obtain the + # same bucket key. + return tuple(sorted(rv)) + + +def get_aggregator(): + # type: () -> Optional[MetricsAggregator] + """Returns the current metrics aggregator if there is one.""" + client = Hub.current.client + return client.metrics_aggregator if client is not None else None + + +def incr( + key, # type: str + value, # type: float + unit="none", # type: MetricUnit + tags=None, # type: Optional[MetricTags] + timestamp=None, # type: Optional[float] +): + # type: (...) -> None + """Increments a counter.""" + aggregator = get_aggregator() + if aggregator is not None: + aggregator.add("c", key, value, unit, tags, timestamp) + + +@contextmanager +def timing( + key, # type: str + tags=None, # type: Optional[MetricTags] + timestamp=None, # type: Optional[float] +): + # type: (...) -> Iterator[None] + """Emits a distribution with the time it takes to run the given code block.""" + aggregator = get_aggregator() + if aggregator is not None: + now = time.time() + try: + yield + finally: + elapsed = time.time() - now + aggregator.add("d", key, elapsed, "second", tags, timestamp) + else: + yield + + +def distribution( + key, # type: str + value, # type: float + unit="second", # type: MetricUnit + tags=None, # type: Optional[MetricTags] + timestamp=None, # type: Optional[float] +) -> None: + """Emits a distribution.""" + aggregator = get_aggregator() + if aggregator is not None: + aggregator.add("d", key, value, unit, tags, timestamp) + + +def set( + key, # type: str + value, # type: MetricValue + unit="none", # type: MetricUnit + tags=None, # type: Optional[MetricTags] + timestamp=None, # type: Optional[float] +) -> None: + """Emits a set.""" + aggregator = get_aggregator() + if aggregator is not None: + aggregator.add("s", key, value, unit, tags, timestamp) + + +def gauge( + key, # type: str + value, # type: float + unit="second", # type: MetricValue + tags=None, # type: Optional[MetricTags] + timestamp=None, # type: Optional[float] +) -> None: + """Emits a gauge.""" + # TODO: emit as gauge not as count + aggregator = get_aggregator() + if aggregator is not None: + aggregator.add("c", key, value, unit, tags, timestamp) From b0c410dda0ac64ec80a38a60b3e834eeee8f844e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 11:44:11 +0200 Subject: [PATCH 04/36] feat: Reuse timestamp in timing --- sentry_sdk/metrics.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 6e2ea395a0..9a623003f8 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -467,11 +467,14 @@ def timing( """Emits a distribution with the time it takes to run the given code block.""" aggregator = get_aggregator() if aggregator is not None: - now = time.time() + then = time.time() try: yield finally: - elapsed = time.time() - now + now = time.time() + elapsed = then - now + if timestamp is None: + timestamp = now aggregator.add("d", key, elapsed, "second", tags, timestamp) else: yield From 587cf9a3a1ecb6ed811fc1be41f00a01724b0019 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 14:36:34 +0200 Subject: [PATCH 05/36] test: Add tests --- sentry_sdk/metrics.py | 62 +++++++++-------- tests/test_metrics.py | 151 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 26 deletions(-) create mode 100644 tests/test_metrics.py diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 9a623003f8..7daa589a9d 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -9,6 +9,7 @@ from contextlib import contextmanager from sentry_sdk.hub import Hub +from sentry_sdk.utils import now from sentry_sdk.envelope import Envelope, Item from sentry_sdk._types import TYPE_CHECKING @@ -31,32 +32,32 @@ thread_local = threading.local() -_sanitize_value = partial(re.compile(r"[^a-zA-Z0-9_/.]").sub, "") +_sanitize_value = partial(re.compile(r"[^a-zA-Z0-9_/.@-]").sub, "") -def in_minimetrics(): +def in_metrics(): # type: (...) -> bool try: - return thread_local.in_minimetrics + return thread_local.in_metrics except AttributeError: return False -def minimetrics_noop(func): +def metrics_noop(func): # type: (Any) -> Any @wraps(func) def new_func(*args, **kwargs): # type: (*Any, **Any) -> Any try: - in_minimetrics = thread_local.in_minimetrics + in_metrics = thread_local.in_metrics except AttributeError: - in_minimetrics = False - thread_local.in_minimetrics = True + in_metrics = False + thread_local.in_metrics = True try: - if not in_minimetrics: + if not in_metrics: return func(*args, **kwargs) finally: - thread_local.in_minimetrics = in_minimetrics + thread_local.in_metrics = in_metrics return new_func @@ -296,7 +297,7 @@ def _ensure_thread(self): def _flush_loop(self): # type: (...) -> None - thread_local.in_minimetrics = True + thread_local.in_metrics = True while self._running or self._force_flush: self._flush() if self._running: @@ -338,7 +339,7 @@ def _flushable_buckets(self): return flushable_buckets - @minimetrics_noop + @metrics_noop def add( self, ty, # type: MetricType @@ -395,7 +396,7 @@ def kill(self): def flush(self): # type: (...) -> None self._force_flush = True - self._flush_event.set() + self._flush() def _consider_force_flush(self): # type: (...) -> None @@ -436,11 +437,23 @@ def _serialize_tags( return tuple(sorted(rv)) -def get_aggregator(): - # type: () -> Optional[MetricsAggregator] +def get_aggregator_and_update_tags(tags): + # type: (Optional[MetricTags]) -> Tuple[Optional[MetricsAggregator], Optional[MetricTags]] """Returns the current metrics aggregator if there is one.""" - client = Hub.current.client - return client.metrics_aggregator if client is not None else None + hub = Hub.current + client = hub.client + if client is None or client.metrics_aggregator is None: + return None, tags + + updated_tags = dict(tags or ()) + updated_tags.setdefault("release", client.options["release"]) + updated_tags.setdefault("environment", client.options["environment"]) + return client.metrics_aggregator, updated_tags + + +def enhance_tags(tags): + # type: (Optional[MetricTags]) -> MetricTags + return tags def incr( @@ -452,7 +465,7 @@ def incr( ): # type: (...) -> None """Increments a counter.""" - aggregator = get_aggregator() + aggregator, tags = get_aggregator_and_update_tags(tags) if aggregator is not None: aggregator.add("c", key, value, unit, tags, timestamp) @@ -465,16 +478,13 @@ def timing( ): # type: (...) -> Iterator[None] """Emits a distribution with the time it takes to run the given code block.""" - aggregator = get_aggregator() + aggregator, tags = get_aggregator_and_update_tags(tags) if aggregator is not None: - then = time.time() + then = now() try: yield finally: - now = time.time() - elapsed = then - now - if timestamp is None: - timestamp = now + elapsed = now() - then aggregator.add("d", key, elapsed, "second", tags, timestamp) else: yield @@ -488,7 +498,7 @@ def distribution( timestamp=None, # type: Optional[float] ) -> None: """Emits a distribution.""" - aggregator = get_aggregator() + aggregator, tags = get_aggregator_and_update_tags(tags) if aggregator is not None: aggregator.add("d", key, value, unit, tags, timestamp) @@ -501,7 +511,7 @@ def set( timestamp=None, # type: Optional[float] ) -> None: """Emits a set.""" - aggregator = get_aggregator() + aggregator, tags = get_aggregator_and_update_tags(tags) if aggregator is not None: aggregator.add("s", key, value, unit, tags, timestamp) @@ -515,6 +525,6 @@ def gauge( ) -> None: """Emits a gauge.""" # TODO: emit as gauge not as count - aggregator = get_aggregator() + aggregator, tags = get_aggregator_and_update_tags(tags) if aggregator is not None: aggregator.add("c", key, value, unit, tags, timestamp) diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000000..eef9edd6dd --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,151 @@ +import sentry_sdk +import time + +from sentry_sdk import Hub, metrics + + +def parse_metrics(bytes): + rv = [] + for line in bytes.splitlines(): + pieces = line.decode("utf-8").split("|") + payload = pieces[0].split(":") + name = payload[0] + values = payload[1:] + ty = pieces[1] + ts = None + tags = {} + for piece in pieces[2:]: + if piece[0] == "#": + for pair in piece[1:].split(","): + k, v = pair.split(":", 1) + tags[k] = v + elif piece[0] == "T": + ts = int(piece[1:]) + else: + raise ValueError("unknown piece %r" % (piece,)) + rv.append((ts, name, ty, values, tags)) + rv.sort() + return rv + + +def test_incr(sentry_init, capture_envelopes): + sentry_init( + release="fun-release", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + envelopes = capture_envelopes() + + metrics.incr("foobar", 1.0, tags={"foo": "bar", "blub": "blah"}) + metrics.incr("foobar", 2.0, tags={"foo": "bar", "blub": "blah"}) + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 1 + assert m[0][1] == "foobar@none" + assert m[0][2] == "c" + assert m[0][3] == ["3.0"] + assert m[0][4] == { + "blub": "blah", + "foo": "bar", + "release": "fun-release", + "environment": "not-fun-env", + } + + +def test_timing(sentry_init, capture_envelopes): + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + envelopes = capture_envelopes() + + with metrics.timing("whatever", tags={"blub": "blah"}): + time.sleep(0.1) + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 1 + assert m[0][1] == "whatever@second" + assert m[0][2] == "d" + assert len(m[0][3]) == 1 + assert float(m[0][3][0]) >= 0.1 + assert m[0][4] == { + "blub": "blah", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } + + +def test_distribution(sentry_init, capture_envelopes): + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + envelopes = capture_envelopes() + + metrics.distribution("dist", 1.0, tags={"a": "b"}) + metrics.distribution("dist", 2.0, tags={"a": "b"}) + metrics.distribution("dist", 2.0, tags={"a": "b"}) + metrics.distribution("dist", 3.0, tags={"a": "b"}) + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 1 + assert m[0][1] == "dist@second" + assert m[0][2] == "d" + assert len(m[0][3]) == 4 + assert sorted(map(float, m[0][3])) == [1.0, 2.0, 2.0, 3.0] + assert m[0][4] == { + "a": "b", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } + + +def test_set(sentry_init, capture_envelopes): + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + envelopes = capture_envelopes() + + metrics.set("my-set", "peter", tags={"magic": "puff"}) + metrics.set("my-set", "paul", tags={"magic": "puff"}) + metrics.set("my-set", "mary", tags={"magic": "puff"}) + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 1 + assert m[0][1] == "my-set@none" + assert m[0][2] == "s" + assert len(m[0][3]) == 3 + assert sorted(map(int, m[0][3])) == [354582103, 2513273657, 3329318813] + assert m[0][4] == { + "magic": "puff", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } \ No newline at end of file From 70463ca95bd3319b3b105c59967d6b4c7f625f05 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 14:44:29 +0200 Subject: [PATCH 06/36] feat: Enable gauges and add tests --- sentry_sdk/metrics.py | 5 ++--- tests/test_metrics.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 7daa589a9d..1889711f12 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -519,12 +519,11 @@ def set( def gauge( key, # type: str value, # type: float - unit="second", # type: MetricValue + unit="none", # type: MetricValue tags=None, # type: Optional[MetricTags] timestamp=None, # type: Optional[float] ) -> None: """Emits a gauge.""" - # TODO: emit as gauge not as count aggregator, tags = get_aggregator_and_update_tags(tags) if aggregator is not None: - aggregator.add("c", key, value, unit, tags, timestamp) + aggregator.add("g", key, value, unit, tags, timestamp) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index eef9edd6dd..93a9b84fb8 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -148,4 +148,35 @@ def test_set(sentry_init, capture_envelopes): "magic": "puff", "release": "fun-release@1.0.0", "environment": "not-fun-env", + } + + +def test_gauge(sentry_init, capture_envelopes): + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + envelopes = capture_envelopes() + + metrics.gauge("my-gauge", 10.0, tags={"x": "y"}) + metrics.gauge("my-gauge", 20.0, tags={"x": "y"}) + metrics.gauge("my-gauge", 30.0, tags={"x": "y"}) + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 1 + assert m[0][1] == "my-gauge@none" + assert m[0][2] == "g" + assert len(m[0][3]) == 5 + assert list(map(float, m[0][3])) == [30.0, 10.0, 30.0, 60.0, 3.0] + assert m[0][4] == { + "x": "y", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", } \ No newline at end of file From 8b494b148289b075c5684a1c677e62b25c7290b3 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 14:49:10 +0200 Subject: [PATCH 07/36] test: Added multi-metric test --- tests/test_metrics.py | 84 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 93a9b84fb8..684c8571c6 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -34,10 +34,11 @@ def test_incr(sentry_init, capture_envelopes): environment="not-fun-env", _experiments={"enable_metrics": True}, ) + ts = time.time() envelopes = capture_envelopes() - metrics.incr("foobar", 1.0, tags={"foo": "bar", "blub": "blah"}) - metrics.incr("foobar", 2.0, tags={"foo": "bar", "blub": "blah"}) + metrics.incr("foobar", 1.0, tags={"foo": "bar", "blub": "blah"}, timestamp=ts) + metrics.incr("foobar", 2.0, tags={"foo": "bar", "blub": "blah"}, timestamp=ts) Hub.current.flush() (envelope,) = envelopes @@ -64,9 +65,10 @@ def test_timing(sentry_init, capture_envelopes): environment="not-fun-env", _experiments={"enable_metrics": True}, ) + ts = time.time() envelopes = capture_envelopes() - with metrics.timing("whatever", tags={"blub": "blah"}): + with metrics.timing("whatever", tags={"blub": "blah"}, timestamp=ts): time.sleep(0.1) Hub.current.flush() @@ -94,12 +96,13 @@ def test_distribution(sentry_init, capture_envelopes): environment="not-fun-env", _experiments={"enable_metrics": True}, ) + ts = time.time() envelopes = capture_envelopes() - metrics.distribution("dist", 1.0, tags={"a": "b"}) - metrics.distribution("dist", 2.0, tags={"a": "b"}) - metrics.distribution("dist", 2.0, tags={"a": "b"}) - metrics.distribution("dist", 3.0, tags={"a": "b"}) + metrics.distribution("dist", 1.0, tags={"a": "b"}, timestamp=ts) + metrics.distribution("dist", 2.0, tags={"a": "b"}, timestamp=ts) + metrics.distribution("dist", 2.0, tags={"a": "b"}, timestamp=ts) + metrics.distribution("dist", 3.0, tags={"a": "b"}, timestamp=ts) Hub.current.flush() (envelope,) = envelopes @@ -126,11 +129,12 @@ def test_set(sentry_init, capture_envelopes): environment="not-fun-env", _experiments={"enable_metrics": True}, ) + ts = time.time() envelopes = capture_envelopes() - metrics.set("my-set", "peter", tags={"magic": "puff"}) - metrics.set("my-set", "paul", tags={"magic": "puff"}) - metrics.set("my-set", "mary", tags={"magic": "puff"}) + metrics.set("my-set", "peter", tags={"magic": "puff"}, timestamp=ts) + metrics.set("my-set", "paul", tags={"magic": "puff"}, timestamp=ts) + metrics.set("my-set", "mary", tags={"magic": "puff"}, timestamp=ts) Hub.current.flush() (envelope,) = envelopes @@ -157,11 +161,12 @@ def test_gauge(sentry_init, capture_envelopes): environment="not-fun-env", _experiments={"enable_metrics": True}, ) + ts = time.time() envelopes = capture_envelopes() - metrics.gauge("my-gauge", 10.0, tags={"x": "y"}) - metrics.gauge("my-gauge", 20.0, tags={"x": "y"}) - metrics.gauge("my-gauge", 30.0, tags={"x": "y"}) + metrics.gauge("my-gauge", 10.0, tags={"x": "y"}, timestamp=ts) + metrics.gauge("my-gauge", 20.0, tags={"x": "y"}, timestamp=ts) + metrics.gauge("my-gauge", 30.0, tags={"x": "y"}, timestamp=ts) Hub.current.flush() (envelope,) = envelopes @@ -179,4 +184,57 @@ def test_gauge(sentry_init, capture_envelopes): "x": "y", "release": "fun-release@1.0.0", "environment": "not-fun-env", + } + + +def test_multiple(sentry_init, capture_envelopes): + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + ts = time.time() + envelopes = capture_envelopes() + + metrics.gauge("my-gauge", 10.0, tags={"x": "y"}, timestamp=ts) + metrics.gauge("my-gauge", 20.0, tags={"x": "y"}, timestamp=ts) + metrics.gauge("my-gauge", 30.0, tags={"x": "y"}, timestamp=ts) + for _ in range(10): + metrics.incr("counter-1", 1.0, timestamp=ts) + metrics.incr("counter-2", 1.0, timestamp=ts) + + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 3 + + assert m[0][1] == "counter-1@none" + assert m[0][2] == "c" + assert list(map(float, m[0][3])) == [10.0] + assert m[0][4] == { + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } + + assert m[1][1] == "counter-2@none" + assert m[1][2] == "c" + assert list(map(float, m[1][3])) == [1.0] + assert m[1][4] == { + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } + + assert m[2][1] == "my-gauge@none" + assert m[2][2] == "g" + assert len(m[2][3]) == 5 + assert list(map(float, m[2][3])) == [30.0, 10.0, 30.0, 60.0, 3.0] + assert m[2][4] == { + "x": "y", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", } \ No newline at end of file From 22705c2b1a8b942b36bbab1649bcd6668c698f7a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 14:50:01 +0200 Subject: [PATCH 08/36] fix: lint --- tests/test_metrics.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 684c8571c6..7fb08bdc4e 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,4 +1,3 @@ -import sentry_sdk import time from sentry_sdk import Hub, metrics @@ -237,4 +236,4 @@ def test_multiple(sentry_init, capture_envelopes): "x": "y", "release": "fun-release@1.0.0", "environment": "not-fun-env", - } \ No newline at end of file + } From feb8c9bc955c2232be7b32ea7b00772b2692061e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 15:02:44 +0200 Subject: [PATCH 09/36] feat: add transaction name to metrics --- sentry_sdk/metrics.py | 25 ++++++++++++++++++++++++- tests/test_metrics.py | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 1889711f12..d88daaef5b 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -11,6 +11,12 @@ from sentry_sdk.hub import Hub from sentry_sdk.utils import now from sentry_sdk.envelope import Envelope, Item +from sentry_sdk.tracing import ( + TRANSACTION_SOURCE_ROUTE, + TRANSACTION_SOURCE_VIEW, + TRANSACTION_SOURCE_COMPONENT, + TRANSACTION_SOURCE_TASK, +) from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: @@ -32,7 +38,16 @@ thread_local = threading.local() -_sanitize_value = partial(re.compile(r"[^a-zA-Z0-9_/.@-]").sub, "") +_sanitize_value = partial(re.compile(r"[^a-zA-Z0-9_/{}<>[].@-]").sub, "") + +GOOD_TRANSACTION_SOURCES = frozenset( + [ + TRANSACTION_SOURCE_ROUTE, + TRANSACTION_SOURCE_VIEW, + TRANSACTION_SOURCE_COMPONENT, + TRANSACTION_SOURCE_TASK, + ] +) def in_metrics(): @@ -448,6 +463,14 @@ def get_aggregator_and_update_tags(tags): updated_tags = dict(tags or ()) updated_tags.setdefault("release", client.options["release"]) updated_tags.setdefault("environment", client.options["environment"]) + + scope = hub.scope + transaction_source = scope._transaction_info.get("source") + if transaction_source in GOOD_TRANSACTION_SOURCES: + transaction = scope._transaction + if transaction: + updated_tags.setdefault("transaction", transaction) + return client.metrics_aggregator, updated_tags diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 7fb08bdc4e..8d0d42647b 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,6 +1,6 @@ import time -from sentry_sdk import Hub, metrics +from sentry_sdk import Hub, metrics, push_scope def parse_metrics(bytes): @@ -237,3 +237,40 @@ def test_multiple(sentry_init, capture_envelopes): "release": "fun-release@1.0.0", "environment": "not-fun-env", } + + +def test_distribution(sentry_init, capture_envelopes): + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + ts = time.time() + envelopes = capture_envelopes() + + with push_scope() as scope: + scope.set_transaction_name("/user/{user_id}", source="route") + metrics.distribution("dist", 1.0, tags={"a": "b"}, timestamp=ts) + metrics.distribution("dist", 2.0, tags={"a": "b"}, timestamp=ts) + metrics.distribution("dist", 2.0, tags={"a": "b"}, timestamp=ts) + metrics.distribution("dist", 3.0, tags={"a": "b"}, timestamp=ts) + + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 1 + assert m[0][1] == "dist@second" + assert m[0][2] == "d" + assert len(m[0][3]) == 4 + assert sorted(map(float, m[0][3])) == [1.0, 2.0, 2.0, 3.0] + assert m[0][4] == { + "a": "b", + "transaction": "/user/{user_id}", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } \ No newline at end of file From cafc7477c04f6de472f92225bfb95e5d6b25e00f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 15:06:49 +0200 Subject: [PATCH 10/36] fix: duplicate test name --- tests/test_metrics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 8d0d42647b..808878af5f 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -239,7 +239,7 @@ def test_multiple(sentry_init, capture_envelopes): } -def test_distribution(sentry_init, capture_envelopes): +def test_transaction_name(sentry_init, capture_envelopes): sentry_init( release="fun-release@1.0.0", environment="not-fun-env", @@ -273,4 +273,4 @@ def test_distribution(sentry_init, capture_envelopes): "transaction": "/user/{user_id}", "release": "fun-release@1.0.0", "environment": "not-fun-env", - } \ No newline at end of file + } From 48abc5d5e8f716526029b857fd684c93f6b210b5 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 15:08:53 +0200 Subject: [PATCH 11/36] fix: typing --- sentry_sdk/_types.py | 1 + sentry_sdk/metrics.py | 8 ++------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 10cea697ea..fdedbad043 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -53,6 +53,7 @@ "session", "internal", "profile", + "statsd", ] SessionStatus = Literal["ok", "exited", "crashed", "abnormal"] EndpointType = Literal["store", "envelope"] diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index d88daaef5b..c088525060 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -32,6 +32,7 @@ from sentry_sdk._types import MetricUnit from sentry_sdk._types import MetricType from sentry_sdk._types import MetricTags + from sentry_sdk._types import MetricTagValue from sentry_sdk._types import MetricTagsInternal from sentry_sdk._types import FlushedMetricValue from sentry_sdk._types import BucketKey @@ -460,7 +461,7 @@ def get_aggregator_and_update_tags(tags): if client is None or client.metrics_aggregator is None: return None, tags - updated_tags = dict(tags or ()) + updated_tags = dict(tags or ()) # type: Dict[str, MetricTagValue] updated_tags.setdefault("release", client.options["release"]) updated_tags.setdefault("environment", client.options["environment"]) @@ -474,11 +475,6 @@ def get_aggregator_and_update_tags(tags): return client.metrics_aggregator, updated_tags -def enhance_tags(tags): - # type: (Optional[MetricTags]) -> MetricTags - return tags - - def incr( key, # type: str value, # type: float From cd2597aed7748a33bd83ea3f95269689fffa6c4c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 15:38:39 +0200 Subject: [PATCH 12/36] Add missing space --- sentry_sdk/metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index c088525060..d4b3867cea 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -461,7 +461,7 @@ def get_aggregator_and_update_tags(tags): if client is None or client.metrics_aggregator is None: return None, tags - updated_tags = dict(tags or ()) # type: Dict[str, MetricTagValue] + updated_tags = dict(tags or ()) # type: Dict[str, MetricTagValue] updated_tags.setdefault("release", client.options["release"]) updated_tags.setdefault("environment", client.options["environment"]) From c67ddf098bde9bceb190bf08e4f59fccd2e3f446 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 15:50:06 +0200 Subject: [PATCH 13/36] feat: return envelope to allow capturing of metrics --- sentry_sdk/metrics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index d4b3867cea..d63c841cc5 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -426,11 +426,12 @@ def _emit( self, flushable_buckets, # type: (Iterable[Tuple[int, Dict[BucketKey, Metric]]]) ): - # type: (...) -> None + # type: (...) -> Envelope encoded_metrics = _encode_metrics(flushable_buckets) metric_item = Item(payload=encoded_metrics, type="statsd") envelope = Envelope(items=[metric_item]) self._capture_func(envelope) + return envelope def _serialize_tags( self, tags # type: Optional[MetricTags] From daf0ee19619592d5ac233adcade12dedcbe48f1b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 17:00:56 +0200 Subject: [PATCH 14/36] feat: Unicode support for tag values --- sentry_sdk/metrics.py | 7 ++++--- tests/test_metrics.py | 42 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index d63c841cc5..e8c2cc730b 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -39,7 +39,8 @@ thread_local = threading.local() -_sanitize_value = partial(re.compile(r"[^a-zA-Z0-9_/{}<>[].@-]").sub, "") +_sanitize_key = partial(re.compile(r"[^a-zA-Z0-9_/.-]+").sub, "_") +_sanitize_value = partial(re.compile(r"[^\w\d_:/@\.{}\[\]$-]+", re.UNICODE).sub, "_") GOOD_TRANSACTION_SOURCES = frozenset( [ @@ -234,7 +235,7 @@ def _encode_metrics(flushable_buckets): for timestamp, buckets in flushable_buckets: for bucket_key, metric in buckets.items(): metric_type, metric_name, metric_unit, metric_tags = bucket_key - metric_name = _sanitize_value(metric_name) or "invalid-metric-name" + metric_name = _sanitize_key(metric_name) _write(metric_name.encode("utf-8")) _write(b"@") _write(metric_unit.encode("utf-8")) @@ -250,7 +251,7 @@ def _encode_metrics(flushable_buckets): _write(b"|#") first = True for tag_key, tag_value in metric_tags: - tag_key = _sanitize_value(tag_key) + tag_key = _sanitize_key(tag_key) if not tag_key: continue if first: diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 808878af5f..37b3841bf6 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -23,7 +23,7 @@ def parse_metrics(bytes): else: raise ValueError("unknown piece %r" % (piece,)) rv.append((ts, name, ty, values, tags)) - rv.sort() + rv.sort(key=lambda x: (x[0], x[1], tuple(sorted(tags.items())))) return rv @@ -274,3 +274,43 @@ def test_transaction_name(sentry_init, capture_envelopes): "release": "fun-release@1.0.0", "environment": "not-fun-env", } + + +def test_tag_normalization(sentry_init, capture_envelopes): + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + ts = time.time() + envelopes = capture_envelopes() + + metrics.distribution("dist", 1.0, tags={"foo-bar": "%$foo"}, timestamp=ts) + metrics.distribution("dist", 1.0, tags={"foo$$$bar": "blah{}"}, timestamp=ts) + metrics.distribution("dist", 1.0, tags={"foö-bar": "snöwmän"}, timestamp=ts) + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 3 + assert m[0][4] == { + "foo-bar": "_$foo", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } + + assert m[1][4] == { + "foo_bar": "blah{}", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } + + assert m[2][4] == { + "fo_-bar": "snöwmän", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } \ No newline at end of file From 7bd88b098a36c05536b35ac4943c1e40dc79fa0e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 18:16:29 +0200 Subject: [PATCH 15/36] Add newline --- tests/test_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 37b3841bf6..8abed12cb3 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -313,4 +313,4 @@ def test_tag_normalization(sentry_init, capture_envelopes): "fo_-bar": "snöwmän", "release": "fun-release@1.0.0", "environment": "not-fun-env", - } \ No newline at end of file + } From a21e683c31cec2db4f8391dadcb729946ad8e6fe Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 18:36:17 +0200 Subject: [PATCH 16/36] feat: Added before_emit_metric --- sentry_sdk/consts.py | 2 ++ sentry_sdk/metrics.py | 19 ++++++++++++------- tests/test_metrics.py | 44 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 651cf6c366..8251bb9935 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -25,6 +25,7 @@ ProfilerMode, TracesSampler, TransactionProcessor, + MetricTags, ) # Experiments are feature flags to enable and disable certain unstable SDK @@ -42,6 +43,7 @@ "otel_powered_performance": Optional[bool], "transport_zlib_compression_level": Optional[int], "enable_metrics": Optional[bool], + "before_emit_metric": Optional[Callable[[str, Dict[MetricTags]], bool]], }, total=False, ) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index e8c2cc730b..fcd66979b6 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -455,8 +455,8 @@ def _serialize_tags( return tuple(sorted(rv)) -def get_aggregator_and_update_tags(tags): - # type: (Optional[MetricTags]) -> Tuple[Optional[MetricsAggregator], Optional[MetricTags]] +def get_aggregator_and_update_tags(key, tags): + # type: (str, Optional[MetricTags]) -> Tuple[Optional[MetricsAggregator], Optional[MetricTags]] """Returns the current metrics aggregator if there is one.""" hub = Hub.current client = hub.client @@ -474,6 +474,11 @@ def get_aggregator_and_update_tags(tags): if transaction: updated_tags.setdefault("transaction", transaction) + callback = client.options.get("_experiments", {}).get("before_emit_metric") + if callback is not None: + if not callback(key, updated_tags): + return None, updated_tags + return client.metrics_aggregator, updated_tags @@ -486,7 +491,7 @@ def incr( ): # type: (...) -> None """Increments a counter.""" - aggregator, tags = get_aggregator_and_update_tags(tags) + aggregator, tags = get_aggregator_and_update_tags(key, tags) if aggregator is not None: aggregator.add("c", key, value, unit, tags, timestamp) @@ -499,7 +504,7 @@ def timing( ): # type: (...) -> Iterator[None] """Emits a distribution with the time it takes to run the given code block.""" - aggregator, tags = get_aggregator_and_update_tags(tags) + aggregator, tags = get_aggregator_and_update_tags(key, tags) if aggregator is not None: then = now() try: @@ -519,7 +524,7 @@ def distribution( timestamp=None, # type: Optional[float] ) -> None: """Emits a distribution.""" - aggregator, tags = get_aggregator_and_update_tags(tags) + aggregator, tags = get_aggregator_and_update_tags(key, tags) if aggregator is not None: aggregator.add("d", key, value, unit, tags, timestamp) @@ -532,7 +537,7 @@ def set( timestamp=None, # type: Optional[float] ) -> None: """Emits a set.""" - aggregator, tags = get_aggregator_and_update_tags(tags) + aggregator, tags = get_aggregator_and_update_tags(key, tags) if aggregator is not None: aggregator.add("s", key, value, unit, tags, timestamp) @@ -545,6 +550,6 @@ def gauge( timestamp=None, # type: Optional[float] ) -> None: """Emits a gauge.""" - aggregator, tags = get_aggregator_and_update_tags(tags) + aggregator, tags = get_aggregator_and_update_tags(key, tags) if aggregator is not None: aggregator.add("g", key, value, unit, tags, timestamp) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 8abed12cb3..afe25bdad8 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -285,9 +285,9 @@ def test_tag_normalization(sentry_init, capture_envelopes): ts = time.time() envelopes = capture_envelopes() - metrics.distribution("dist", 1.0, tags={"foo-bar": "%$foo"}, timestamp=ts) - metrics.distribution("dist", 1.0, tags={"foo$$$bar": "blah{}"}, timestamp=ts) - metrics.distribution("dist", 1.0, tags={"foö-bar": "snöwmän"}, timestamp=ts) + metrics.distribution("a", 1.0, tags={"foo-bar": "%$foo"}, timestamp=ts) + metrics.distribution("b", 1.0, tags={"foo$$$bar": "blah{}"}, timestamp=ts) + metrics.distribution("c", 1.0, tags={"foö-bar": "snöwmän"}, timestamp=ts) Hub.current.flush() (envelope,) = envelopes @@ -314,3 +314,41 @@ def test_tag_normalization(sentry_init, capture_envelopes): "release": "fun-release@1.0.0", "environment": "not-fun-env", } + + +def test_before_emit_metric(sentry_init, capture_envelopes): + def before_emit(key, tags): + if key == "removed-metric": + return False + tags["extra"] = "foo" + del tags["release"] + return True + + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={ + "enable_metrics": True, + "before_emit_metric": before_emit, + }, + ) + ts = time.time() + envelopes = capture_envelopes() + + metrics.incr("removed-metric", 1.0) + metrics.incr("actual-metric", 1.0) + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 1 + assert m[0][1] == "actual-metric@none" + assert m[0][3] == ["1.0"] + assert m[0][4] == { + "extra": "foo", + "environment": "not-fun-env", + } From b1ba79e32703b3d767199d2bf5d6da57ae23048b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 18:50:35 +0200 Subject: [PATCH 17/36] feat: Add timed decorator --- sentry_sdk/metrics.py | 49 +++++++++++++++++++++++++------------------ tests/test_metrics.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index fcd66979b6..bf74643d8d 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -37,8 +37,8 @@ from sentry_sdk._types import FlushedMetricValue from sentry_sdk._types import BucketKey -thread_local = threading.local() +_thread_local = threading.local() _sanitize_key = partial(re.compile(r"[^a-zA-Z0-9_/.-]+").sub, "_") _sanitize_value = partial(re.compile(r"[^\w\d_:/@\.{}\[\]$-]+", re.UNICODE).sub, "_") @@ -52,29 +52,21 @@ ) -def in_metrics(): - # type: (...) -> bool - try: - return thread_local.in_metrics - except AttributeError: - return False - - def metrics_noop(func): # type: (Any) -> Any @wraps(func) def new_func(*args, **kwargs): # type: (*Any, **Any) -> Any try: - in_metrics = thread_local.in_metrics + in_metrics = _thread_local.in_metrics except AttributeError: in_metrics = False - thread_local.in_metrics = True + _thread_local.in_metrics = True try: if not in_metrics: return func(*args, **kwargs) finally: - thread_local.in_metrics = in_metrics + _thread_local.in_metrics = in_metrics return new_func @@ -314,7 +306,7 @@ def _ensure_thread(self): def _flush_loop(self): # type: (...) -> None - thread_local.in_metrics = True + _thread_local.in_metrics = True while self._running or self._force_flush: self._flush() if self._running: @@ -455,7 +447,7 @@ def _serialize_tags( return tuple(sorted(rv)) -def get_aggregator_and_update_tags(key, tags): +def _get_aggregator_and_update_tags(key, tags): # type: (str, Optional[MetricTags]) -> Tuple[Optional[MetricsAggregator], Optional[MetricTags]] """Returns the current metrics aggregator if there is one.""" hub = Hub.current @@ -484,14 +476,14 @@ def get_aggregator_and_update_tags(key, tags): def incr( key, # type: str - value, # type: float + value=1.0, # type: float unit="none", # type: MetricUnit tags=None, # type: Optional[MetricTags] timestamp=None, # type: Optional[float] ): # type: (...) -> None """Increments a counter.""" - aggregator, tags = get_aggregator_and_update_tags(key, tags) + aggregator, tags = _get_aggregator_and_update_tags(key, tags) if aggregator is not None: aggregator.add("c", key, value, unit, tags, timestamp) @@ -504,7 +496,7 @@ def timing( ): # type: (...) -> Iterator[None] """Emits a distribution with the time it takes to run the given code block.""" - aggregator, tags = get_aggregator_and_update_tags(key, tags) + aggregator, tags = _get_aggregator_and_update_tags(key, tags) if aggregator is not None: then = now() try: @@ -516,6 +508,23 @@ def timing( yield +def timed( + key, # type: str + tags=None, # type: Optional[MetricTags] +): + # type: (...) -> Callable[[Any], Any] + """Similar to `timing` but to be used as a decorator.""" + def decorator(f): + # type: (Any) -> Any + @wraps(f) + def timed_func(*args, **kwargs): + # type: (*Any, **Any) -> Any + with timing(key, tags): + return f(*args, **kwargs) + return timed_func + return decorator + + def distribution( key, # type: str value, # type: float @@ -524,7 +533,7 @@ def distribution( timestamp=None, # type: Optional[float] ) -> None: """Emits a distribution.""" - aggregator, tags = get_aggregator_and_update_tags(key, tags) + aggregator, tags = _get_aggregator_and_update_tags(key, tags) if aggregator is not None: aggregator.add("d", key, value, unit, tags, timestamp) @@ -537,7 +546,7 @@ def set( timestamp=None, # type: Optional[float] ) -> None: """Emits a set.""" - aggregator, tags = get_aggregator_and_update_tags(key, tags) + aggregator, tags = _get_aggregator_and_update_tags(key, tags) if aggregator is not None: aggregator.add("s", key, value, unit, tags, timestamp) @@ -550,6 +559,6 @@ def gauge( timestamp=None, # type: Optional[float] ) -> None: """Emits a gauge.""" - aggregator, tags = get_aggregator_and_update_tags(key, tags) + aggregator, tags = _get_aggregator_and_update_tags(key, tags) if aggregator is not None: aggregator.add("g", key, value, unit, tags, timestamp) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index afe25bdad8..6d3721d6e1 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -89,6 +89,40 @@ def test_timing(sentry_init, capture_envelopes): } +def test_timed(sentry_init, capture_envelopes): + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + envelopes = capture_envelopes() + + @metrics.timed("whatever", tags={"x": "y"}) + def amazing(): + time.sleep(0.1) + return 42 + + assert amazing() == 42 + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 1 + assert m[0][1] == "whatever@second" + assert m[0][2] == "d" + assert len(m[0][3]) == 1 + assert float(m[0][3][0]) >= 0.1 + assert m[0][4] == { + "x": "y", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } + + def test_distribution(sentry_init, capture_envelopes): sentry_init( release="fun-release@1.0.0", From 80cef70c27024ba3a0d1cb9bed6df6d689e06d13 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 18:58:33 +0200 Subject: [PATCH 18/36] ref: Unify timed and timing --- sentry_sdk/metrics.py | 68 ++++++++++++++++++++++++------------------- tests/test_metrics.py | 4 +-- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index bf74643d8d..09cdd1a672 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -6,7 +6,6 @@ import zlib from functools import wraps, partial from threading import Event, Lock, Thread -from contextlib import contextmanager from sentry_sdk.hub import Hub from sentry_sdk.utils import now @@ -488,41 +487,50 @@ def incr( aggregator.add("c", key, value, unit, tags, timestamp) -@contextmanager -def timing( - key, # type: str - tags=None, # type: Optional[MetricTags] - timestamp=None, # type: Optional[float] -): - # type: (...) -> Iterator[None] - """Emits a distribution with the time it takes to run the given code block.""" - aggregator, tags = _get_aggregator_and_update_tags(key, tags) - if aggregator is not None: - then = now() - try: - yield - finally: - elapsed = now() - then - aggregator.add("d", key, elapsed, "second", tags, timestamp) - else: - yield - - -def timed( - key, # type: str - tags=None, # type: Optional[MetricTags] -): - # type: (...) -> Callable[[Any], Any] - """Similar to `timing` but to be used as a decorator.""" - def decorator(f): +class _Timing(object): + def __init__( + self, + key, # type: str + tags, # type: Optional[MetricTags] + timestamp, # type: Optional[float] + ): + # type: (...) -> None + self.key = key + self.tags = tags + self.timestamp = timestamp + self.then = None # type: Optional[float] + + def __enter__(self): + # type: (...) -> _Timing + self.then = now() + return self + + def __exit__(self, exc_type, exc_value, tb): + # type: (Any, Any, Any) -> None + aggregator, tags = _get_aggregator_and_update_tags(self.key, self.tags) + if aggregator is not None: + elapsed = now() - self.then # type: ignore + aggregator.add("d", self.key, elapsed, "second", tags, self.timestamp) + + def __call__(self, f): # type: (Any) -> Any @wraps(f) def timed_func(*args, **kwargs): # type: (*Any, **Any) -> Any - with timing(key, tags): + with timing(self.key, self.tags, self.timestamp): return f(*args, **kwargs) + return timed_func - return decorator + + +def timing( + key, # type: str + tags=None, # type: Optional[MetricTags] + timestamp=None, # type: Optional[float] +): + # type: (...) -> _Timing + """Emits a distribution with the time it takes to run the given code block.""" + return _Timing(key, tags, timestamp) def distribution( diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 6d3721d6e1..bf9a53ee10 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -89,7 +89,7 @@ def test_timing(sentry_init, capture_envelopes): } -def test_timed(sentry_init, capture_envelopes): +def test_timing_decorator(sentry_init, capture_envelopes): sentry_init( release="fun-release@1.0.0", environment="not-fun-env", @@ -97,7 +97,7 @@ def test_timed(sentry_init, capture_envelopes): ) envelopes = capture_envelopes() - @metrics.timed("whatever", tags={"x": "y"}) + @metrics.timing("whatever", tags={"x": "y"}) def amazing(): time.sleep(0.1) return 42 From 54a49ffac7be7bbaffbd299cfa04c77c7e2cee2a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 19:00:42 +0200 Subject: [PATCH 19/36] fix: Remove unused ts --- tests/test_metrics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index bf9a53ee10..b8cca59cbe 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -366,7 +366,6 @@ def before_emit(key, tags): "before_emit_metric": before_emit, }, ) - ts = time.time() envelopes = capture_envelopes() metrics.incr("removed-metric", 1.0) From 799a436e7647d6187ef411079cefc8fc9d06daad Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 19:04:12 +0200 Subject: [PATCH 20/36] Fix lints --- sentry_sdk/metrics.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 09cdd1a672..1e283c7870 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -25,7 +25,6 @@ from typing import Callable from typing import Optional from typing import Tuple - from typing import Iterator from sentry_sdk._types import MetricValue from sentry_sdk._types import MetricUnit @@ -490,7 +489,7 @@ def incr( class _Timing(object): def __init__( self, - key, # type: str + key, # type: str tags, # type: Optional[MetricTags] timestamp, # type: Optional[float] ): From 0417e7b426a7433840f605e57ee31021aef0e592 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 19:10:03 +0200 Subject: [PATCH 21/36] Fix typing error --- sentry_sdk/consts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 8251bb9935..d15cf3f569 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -43,7 +43,7 @@ "otel_powered_performance": Optional[bool], "transport_zlib_compression_level": Optional[int], "enable_metrics": Optional[bool], - "before_emit_metric": Optional[Callable[[str, Dict[MetricTags]], bool]], + "before_emit_metric": Optional[Callable[[str, MetricTags], bool]], }, total=False, ) From e9d2eee44354b476a1789ab7c16301384b930455 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 19:16:11 +0200 Subject: [PATCH 22/36] Make 2.7 happy --- tests/test_metrics.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index b8cca59cbe..81f31fcadf 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,3 +1,5 @@ +# coding: utf-8 + import time from sentry_sdk import Hub, metrics, push_scope From ed866fcbc13a993f75889ed485c9419b7ff07c2f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 20:24:00 +0200 Subject: [PATCH 23/36] fix: newstyle type syntax --- sentry_sdk/metrics.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 1e283c7870..eb0c7fe01d 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -538,7 +538,8 @@ def distribution( unit="second", # type: MetricUnit tags=None, # type: Optional[MetricTags] timestamp=None, # type: Optional[float] -) -> None: +): + # type: (...) -> None """Emits a distribution.""" aggregator, tags = _get_aggregator_and_update_tags(key, tags) if aggregator is not None: @@ -551,7 +552,8 @@ def set( unit="none", # type: MetricUnit tags=None, # type: Optional[MetricTags] timestamp=None, # type: Optional[float] -) -> None: +): + # type: (...) -> None """Emits a set.""" aggregator, tags = _get_aggregator_and_update_tags(key, tags) if aggregator is not None: @@ -564,7 +566,8 @@ def gauge( unit="none", # type: MetricValue tags=None, # type: Optional[MetricTags] timestamp=None, # type: Optional[float] -) -> None: +): + # type: (...) -> None """Emits a gauge.""" aggregator, tags = _get_aggregator_and_update_tags(key, tags) if aggregator is not None: From 2273176222a69f8a39b61ed59244eb2578a476af Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 20:34:42 +0200 Subject: [PATCH 24/36] Fix unicode literal --- tests/test_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 81f31fcadf..e800b1caf6 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -323,7 +323,7 @@ def test_tag_normalization(sentry_init, capture_envelopes): metrics.distribution("a", 1.0, tags={"foo-bar": "%$foo"}, timestamp=ts) metrics.distribution("b", 1.0, tags={"foo$$$bar": "blah{}"}, timestamp=ts) - metrics.distribution("c", 1.0, tags={"foö-bar": "snöwmän"}, timestamp=ts) + metrics.distribution("c", 1.0, tags={"foö-bar": u"snöwmän"}, timestamp=ts) Hub.current.flush() (envelope,) = envelopes From de9049abc7717a8d255ebf97c6375e27b13ef036 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 22:02:31 +0200 Subject: [PATCH 25/36] Reformat --- tests/test_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index e800b1caf6..81f31fcadf 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -323,7 +323,7 @@ def test_tag_normalization(sentry_init, capture_envelopes): metrics.distribution("a", 1.0, tags={"foo-bar": "%$foo"}, timestamp=ts) metrics.distribution("b", 1.0, tags={"foo$$$bar": "blah{}"}, timestamp=ts) - metrics.distribution("c", 1.0, tags={"foö-bar": u"snöwmän"}, timestamp=ts) + metrics.distribution("c", 1.0, tags={"foö-bar": "snöwmän"}, timestamp=ts) Hub.current.flush() (envelope,) = envelopes From c37ed6fb0bafdae7ab5de4f179896ec70e01c7e4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 22:10:19 +0200 Subject: [PATCH 26/36] Mark string as unicode --- tests/test_metrics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 81f31fcadf..4a2d5fab00 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -323,7 +323,7 @@ def test_tag_normalization(sentry_init, capture_envelopes): metrics.distribution("a", 1.0, tags={"foo-bar": "%$foo"}, timestamp=ts) metrics.distribution("b", 1.0, tags={"foo$$$bar": "blah{}"}, timestamp=ts) - metrics.distribution("c", 1.0, tags={"foö-bar": "snöwmän"}, timestamp=ts) + metrics.distribution("c", 1.0, tags={"foö-bar": u"snöwmän"}, timestamp=ts) Hub.current.flush() (envelope,) = envelopes @@ -346,7 +346,7 @@ def test_tag_normalization(sentry_init, capture_envelopes): } assert m[2][4] == { - "fo_-bar": "snöwmän", + "fo_-bar": u"snöwmän", "release": "fun-release@1.0.0", "environment": "not-fun-env", } From b16bee43f805b200cc141e95656afb408cb565dd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 22:18:03 +0200 Subject: [PATCH 27/36] Make black not strip u prefixes on some tests --- tests/test_metrics.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 4a2d5fab00..7979860289 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,5 +1,3 @@ -# coding: utf-8 - import time from sentry_sdk import Hub, metrics, push_scope @@ -321,9 +319,11 @@ def test_tag_normalization(sentry_init, capture_envelopes): ts = time.time() envelopes = capture_envelopes() + # fmt: off metrics.distribution("a", 1.0, tags={"foo-bar": "%$foo"}, timestamp=ts) metrics.distribution("b", 1.0, tags={"foo$$$bar": "blah{}"}, timestamp=ts) - metrics.distribution("c", 1.0, tags={"foö-bar": u"snöwmän"}, timestamp=ts) + metrics.distribution("c", 1.0, tags={u"foö-bar": u"snöwmän"}, timestamp=ts) + # fmt: on Hub.current.flush() (envelope,) = envelopes @@ -345,11 +345,13 @@ def test_tag_normalization(sentry_init, capture_envelopes): "environment": "not-fun-env", } + # fmt: off assert m[2][4] == { "fo_-bar": u"snöwmän", "release": "fun-release@1.0.0", "environment": "not-fun-env", } + # fmt: on def test_before_emit_metric(sentry_init, capture_envelopes): From be7756b4743df8f7efa4546b53574b7f4c40de77 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Sep 2023 22:22:08 +0200 Subject: [PATCH 28/36] Add back removed utf-8 coding marker --- tests/test_metrics.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 7979860289..2be00b2539 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,3 +1,5 @@ +# coding: utf-8 + import time from sentry_sdk import Hub, metrics, push_scope From 1f18562ced2b46ac8999797619756ddaf5a5807d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 21 Sep 2023 09:04:25 +0200 Subject: [PATCH 29/36] feat: Allow non integer tag values --- sentry_sdk/_types.py | 9 ++++++- sentry_sdk/metrics.py | 7 ++--- tests/test_metrics.py | 63 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index fdedbad043..1249c01939 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -129,7 +129,14 @@ MetricTagsInternal = Tuple[Tuple[str, str], ...] # External representation of tags as a dictionary. - MetricTagValue = Union[str, List[str], Tuple[str, ...]] + MetricTagValue = Union[ + str, + int, + float, + None, + List[Union[int, str, float, None]], + Tuple[Union[int, str, float, None], ...], + ] MetricTags = Mapping[str, MetricTagValue] # Value inside the generator for the metric value. diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index eb0c7fe01d..b2397da699 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -436,9 +436,10 @@ def _serialize_tags( # If the value is a collection, we want to flatten it. if isinstance(value, (list, tuple)): for inner_value in value: - rv.append((key, inner_value)) - else: - rv.append((key, value)) + if inner_value is not None: + rv.append((key, str(inner_value))) + elif value is not None: + rv.append((key, str(value))) # It's very important to sort the tags in order to obtain the # same bucket key. diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 2be00b2539..a8457039e0 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -19,7 +19,14 @@ def parse_metrics(bytes): if piece[0] == "#": for pair in piece[1:].split(","): k, v = pair.split(":", 1) - tags[k] = v + old = tags.get(k) + if old is not None: + if isinstance(old, list): + old.append(v) + else: + tags[k] = [old, v] + else: + tags[k] = v elif piece[0] == "T": ts = int(piece[1:]) else: @@ -391,3 +398,57 @@ def before_emit(key, tags): "extra": "foo", "environment": "not-fun-env", } + + +def test_aggregator_flush(sentry_init, capture_envelopes): + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={ + "enable_metrics": True, + }, + ) + envelopes = capture_envelopes() + + metrics.incr("a-metric", 1.0) + Hub.current.flush() + + assert len(envelopes) == 1 + assert Hub.current.client.metrics_aggregator.buckets == {} + + +def test_tag_serialization(sentry_init, capture_envelopes): + sentry_init( + release="fun-release", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + envelopes = capture_envelopes() + + metrics.incr( + "counter", + tags={ + "no-value": None, + "an-int": 42, + "a-float": 23.0, + "a-string": "blah", + "more-than-one": [1, "zwei", "3.0", None], + }, + ) + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 1 + assert m[0][4] == { + "an-int": "42", + "a-float": "23.0", + "a-string": "blah", + "more-than-one": ["1", "3.0", "zwei"], + "release": "fun-release", + "environment": "not-fun-env", + } From 79379a443d300cb0fccac1d30b7a6d73c3b42bc8 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 21 Sep 2023 10:21:41 +0200 Subject: [PATCH 30/36] Fix python 2.7 again --- sentry_sdk/metrics.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index b2397da699..32c789e6ea 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -7,6 +7,7 @@ from functools import wraps, partial from threading import Event, Lock, Thread +from sentry_sdk._compat import text_type from sentry_sdk.hub import Hub from sentry_sdk.utils import now from sentry_sdk.envelope import Envelope, Item @@ -437,9 +438,9 @@ def _serialize_tags( if isinstance(value, (list, tuple)): for inner_value in value: if inner_value is not None: - rv.append((key, str(inner_value))) + rv.append((key, text_type(inner_value))) elif value is not None: - rv.append((key, str(value))) + rv.append((key, text_type(value))) # It's very important to sort the tags in order to obtain the # same bucket key. From c0bb251d1098b1af6642c0054f3024acc82f382e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 21 Sep 2023 10:28:34 +0200 Subject: [PATCH 31/36] Added comment on sanitizing --- sentry_sdk/metrics.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 32c789e6ea..17aee46248 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -223,6 +223,11 @@ def _encode_metrics(flushable_buckets): out = io.BytesIO() _write = out.write + # Note on sanetization: we intentionally sanetize in emission (serialization) + # and not during aggregation for performance reasons. This means that the + # envelope can in fact have duplicate buckets stored. This is acceptable for + # relay side emission and should not happen commonly. + for timestamp, buckets in flushable_buckets: for bucket_key, metric in buckets.items(): metric_type, metric_name, metric_unit, metric_tags = bucket_key From fe71bc6d30ba9f98181b33459801bf2793d178f0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 21 Sep 2023 13:29:34 +0200 Subject: [PATCH 32/36] Split out timing and distribution --- sentry_sdk/_types.py | 29 +---------------- sentry_sdk/metrics.py | 74 ++++++++++++++++++++++++++++++++----------- tests/test_metrics.py | 59 +++++++++++++++++++++++++++++++--- 3 files changed, 111 insertions(+), 51 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 1249c01939..e88d07b420 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -91,33 +91,6 @@ ProfilerMode = Literal["sleep", "thread", "gevent", "unknown"] - # Unit of the metrics. - MetricUnit = Literal[ - "none", - "nanosecond", - "microsecond", - "millisecond", - "second", - "minute", - "hour", - "day", - "week", - "bit", - "byte", - "kilobyte", - "kibibyte", - "mebibyte", - "gigabyte", - "terabyte", - "tebibyte", - "petabyte", - "pebibyte", - "exabyte", - "exbibyte", - "ratio", - "percent", - ] - # Type of the metric. MetricType = Literal["d", "s", "g", "c"] @@ -142,4 +115,4 @@ # Value inside the generator for the metric value. FlushedMetricValue = Union[int, float] - BucketKey = Tuple[MetricType, str, MetricUnit, MetricTagsInternal] + BucketKey = Tuple[MetricType, str, MeasurementUnit, MetricTagsInternal] diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 17aee46248..9d0d39a307 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -9,7 +9,7 @@ from sentry_sdk._compat import text_type from sentry_sdk.hub import Hub -from sentry_sdk.utils import now +from sentry_sdk.utils import now, nanosecond_time from sentry_sdk.envelope import Envelope, Item from sentry_sdk.tracing import ( TRANSACTION_SOURCE_ROUTE, @@ -27,14 +27,15 @@ from typing import Optional from typing import Tuple - from sentry_sdk._types import MetricValue - from sentry_sdk._types import MetricUnit - from sentry_sdk._types import MetricType - from sentry_sdk._types import MetricTags + from sentry_sdk._types import BucketKey + from sentry_sdk._types import DurationUnit + from sentry_sdk._types import FlushedMetricValue + from sentry_sdk._types import MeasurementUnit from sentry_sdk._types import MetricTagValue + from sentry_sdk._types import MetricTags from sentry_sdk._types import MetricTagsInternal - from sentry_sdk._types import FlushedMetricValue - from sentry_sdk._types import BucketKey + from sentry_sdk._types import MetricType + from sentry_sdk._types import MetricValue _thread_local = threading.local() @@ -272,6 +273,18 @@ def _encode_metrics(flushable_buckets): "s": SetMetric, } +# some of these are dumb +TIMING_FUNCTIONS = { + "nanosecond": nanosecond_time, + "microsecond": lambda: nanosecond_time() / 1000.0, + "millisecond": lambda: nanosecond_time() / 1000000.0, + "second": now, + "minute": lambda: now() / 60.0, + "hour": lambda: now() / 3600.0, + "day": lambda: now() / 3600.0 / 24.0, + "week": lambda: now() / 3600.0 / 24.0 / 7.0, +} + class MetricsAggregator(object): ROLLUP_IN_SECONDS = 10.0 @@ -358,7 +371,7 @@ def add( ty, # type: MetricType key, # type: str value, # type: MetricValue - unit, # type: MetricUnit + unit, # type: MeasurementUnit tags, # type: Optional[MetricTags] timestamp=None, # type: Optional[float] ): @@ -482,7 +495,7 @@ def _get_aggregator_and_update_tags(key, tags): def incr( key, # type: str value=1.0, # type: float - unit="none", # type: MetricUnit + unit="none", # type: MeasurementUnit tags=None, # type: Optional[MetricTags] timestamp=None, # type: Optional[float] ): @@ -499,31 +512,43 @@ def __init__( key, # type: str tags, # type: Optional[MetricTags] timestamp, # type: Optional[float] + value, # type: Optional[float] + unit, # type: DurationUnit ): # type: (...) -> None self.key = key self.tags = tags self.timestamp = timestamp - self.then = None # type: Optional[float] + self.value = value + self.unit = unit + self.entered = None # type: Optional[float] + + def _validate_invocation(self, context): + # type: (str) -> None + if self.value is not None: + raise TypeError('cannot use timing as %s when a value is provided' % context) def __enter__(self): # type: (...) -> _Timing - self.then = now() + self.entered = TIMING_FUNCTIONS[self.unit]() + self._validate_invocation("context-manager") return self def __exit__(self, exc_type, exc_value, tb): # type: (Any, Any, Any) -> None aggregator, tags = _get_aggregator_and_update_tags(self.key, self.tags) if aggregator is not None: - elapsed = now() - self.then # type: ignore - aggregator.add("d", self.key, elapsed, "second", tags, self.timestamp) + elapsed = TIMING_FUNCTIONS[self.unit]() - self.entered # type: ignore + aggregator.add("d", self.key, elapsed, self.unit, tags, self.timestamp) def __call__(self, f): # type: (Any) -> Any + self._validate_invocation("decorator") + @wraps(f) def timed_func(*args, **kwargs): # type: (*Any, **Any) -> Any - with timing(self.key, self.tags, self.timestamp): + with timing(key=self.key, tags=self.tags, timestamp=self.timestamp, unit=self.unit): return f(*args, **kwargs) return timed_func @@ -531,18 +556,31 @@ def timed_func(*args, **kwargs): def timing( key, # type: str + value=None, # type: Optional[float] + unit="second", # type: DurationUnit tags=None, # type: Optional[MetricTags] timestamp=None, # type: Optional[float] ): # type: (...) -> _Timing - """Emits a distribution with the time it takes to run the given code block.""" - return _Timing(key, tags, timestamp) + """Emits a distribution with the time it takes to run the given code block. + + This method supports three forms of invocation: + + - when a `value` is provided, it functions similar to `distribution` but with + - it can be used as a context manager + - it can be used as a decorator + """ + if value is not None: + aggregator, tags = _get_aggregator_and_update_tags(key, tags) + if aggregator is not None: + aggregator.add("d", key, value, unit, tags, timestamp) + return _Timing(key, tags, timestamp, value, unit) def distribution( key, # type: str value, # type: float - unit="second", # type: MetricUnit + unit="none", # type: MeasurementUnit tags=None, # type: Optional[MetricTags] timestamp=None, # type: Optional[float] ): @@ -556,7 +594,7 @@ def distribution( def set( key, # type: str value, # type: MetricValue - unit="none", # type: MetricUnit + unit="none", # type: MeasurementUnit tags=None, # type: Optional[MetricTags] timestamp=None, # type: Optional[float] ): diff --git a/tests/test_metrics.py b/tests/test_metrics.py index a8457039e0..145a1e94cc 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -106,12 +106,18 @@ def test_timing_decorator(sentry_init, capture_envelopes): ) envelopes = capture_envelopes() - @metrics.timing("whatever", tags={"x": "y"}) + @metrics.timing("whatever-1", tags={"x": "y"}) def amazing(): time.sleep(0.1) return 42 + @metrics.timing("whatever-2", tags={"x": "y"}, unit="nanosecond") + def amazing_nano(): + time.sleep(0.01) + return 23 + assert amazing() == 42 + assert amazing_nano() == 23 Hub.current.flush() (envelope,) = envelopes @@ -120,8 +126,8 @@ def amazing(): assert envelope.items[0].headers["type"] == "statsd" m = parse_metrics(envelope.items[0].payload.get_bytes()) - assert len(m) == 1 - assert m[0][1] == "whatever@second" + assert len(m) == 2 + assert m[0][1] == "whatever-1@second" assert m[0][2] == "d" assert len(m[0][3]) == 1 assert float(m[0][3][0]) >= 0.1 @@ -131,6 +137,49 @@ def amazing(): "environment": "not-fun-env", } + assert m[1][1] == "whatever-2@nanosecond" + assert m[1][2] == "d" + assert len(m[1][3]) == 1 + assert float(m[1][3][0]) >= 10000000.0 + assert m[1][4] == { + "x": "y", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } + + +def test_timing_basic(sentry_init, capture_envelopes): + sentry_init( + release="fun-release@1.0.0", + environment="not-fun-env", + _experiments={"enable_metrics": True}, + ) + ts = time.time() + envelopes = capture_envelopes() + + metrics.timing("timing", 1.0, tags={"a": "b"}, timestamp=ts) + metrics.timing("timing", 2.0, tags={"a": "b"}, timestamp=ts) + metrics.timing("timing", 2.0, tags={"a": "b"}, timestamp=ts) + metrics.timing("timing", 3.0, tags={"a": "b"}, timestamp=ts) + Hub.current.flush() + + (envelope,) = envelopes + + assert len(envelope.items) == 1 + assert envelope.items[0].headers["type"] == "statsd" + m = parse_metrics(envelope.items[0].payload.get_bytes()) + + assert len(m) == 1 + assert m[0][1] == "timing@second" + assert m[0][2] == "d" + assert len(m[0][3]) == 4 + assert sorted(map(float, m[0][3])) == [1.0, 2.0, 2.0, 3.0] + assert m[0][4] == { + "a": "b", + "release": "fun-release@1.0.0", + "environment": "not-fun-env", + } + def test_distribution(sentry_init, capture_envelopes): sentry_init( @@ -154,7 +203,7 @@ def test_distribution(sentry_init, capture_envelopes): m = parse_metrics(envelope.items[0].payload.get_bytes()) assert len(m) == 1 - assert m[0][1] == "dist@second" + assert m[0][1] == "dist@none" assert m[0][2] == "d" assert len(m[0][3]) == 4 assert sorted(map(float, m[0][3])) == [1.0, 2.0, 2.0, 3.0] @@ -307,7 +356,7 @@ def test_transaction_name(sentry_init, capture_envelopes): m = parse_metrics(envelope.items[0].payload.get_bytes()) assert len(m) == 1 - assert m[0][1] == "dist@second" + assert m[0][1] == "dist@none" assert m[0][2] == "d" assert len(m[0][3]) == 4 assert sorted(map(float, m[0][3])) == [1.0, 2.0, 2.0, 3.0] From 16f20a8fe3df67f7ddcdccc716619c6e44412a50 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 21 Sep 2023 13:32:51 +0200 Subject: [PATCH 33/36] fix: lint --- sentry_sdk/metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 9d0d39a307..6ce6b35df9 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -563,7 +563,7 @@ def timing( ): # type: (...) -> _Timing """Emits a distribution with the time it takes to run the given code block. - + This method supports three forms of invocation: - when a `value` is provided, it functions similar to `distribution` but with From 8d8e015adc7e7adf0f782a82449de3d9fc4b918d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 21 Sep 2023 13:38:40 +0200 Subject: [PATCH 34/36] black --- sentry_sdk/metrics.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 6ce6b35df9..018c680750 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -526,7 +526,9 @@ def __init__( def _validate_invocation(self, context): # type: (str) -> None if self.value is not None: - raise TypeError('cannot use timing as %s when a value is provided' % context) + raise TypeError( + "cannot use timing as %s when a value is provided" % context + ) def __enter__(self): # type: (...) -> _Timing @@ -548,7 +550,9 @@ def __call__(self, f): @wraps(f) def timed_func(*args, **kwargs): # type: (*Any, **Any) -> Any - with timing(key=self.key, tags=self.tags, timestamp=self.timestamp, unit=self.unit): + with timing( + key=self.key, tags=self.tags, timestamp=self.timestamp, unit=self.unit + ): return f(*args, **kwargs) return timed_func From 7cf653bd978f079f1a762245c20ef8bf781d20e4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 21 Sep 2023 13:47:51 +0200 Subject: [PATCH 35/36] Make nanosecond_time always work --- sentry_sdk/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 480c55c647..41ab0b598f 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -1579,7 +1579,7 @@ def nanosecond_time(): def nanosecond_time(): # type: () -> int - raise AttributeError + raise int(time.time() * 1e9) if PY2: From 079fb346c62eeafe973ed198e9b15914521fcb9e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 21 Sep 2023 13:53:12 +0200 Subject: [PATCH 36/36] raise -> return --- sentry_sdk/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 41ab0b598f..c811d2d2fe 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -1579,7 +1579,7 @@ def nanosecond_time(): def nanosecond_time(): # type: () -> int - raise int(time.time() * 1e9) + return int(time.time() * 1e9) if PY2: