From b156cff6274818ed0ec53c32de5a188be95f2207 Mon Sep 17 00:00:00 2001 From: Kev Date: Wed, 8 Oct 2025 18:12:47 -0400 Subject: [PATCH 01/14] feat(metrics): Add experimental trace metrics behind an experiments flag Similar to https://github.com/getsentry/sentry-javascript/pull/17883, this allows the py sdk to send in new trace metric protocol items, although this code is experimental since the schema may still change. Most of this code has been copied from logs (eg. log batcher -> metrics batcher) in order to dogfood, once we're more sure of our approach we can refactor. --- sentry_sdk/__init__.py | 2 + sentry_sdk/_trace_metrics_batcher.py | 157 +++++++++++++++++++++ sentry_sdk/_types.py | 27 ++++ sentry_sdk/client.py | 67 +++++++++ sentry_sdk/envelope.py | 2 + sentry_sdk/trace_metrics.py | 91 ++++++++++++ sentry_sdk/types.py | 3 + sentry_sdk/utils.py | 18 +++ tests/test_trace_metrics.py | 198 +++++++++++++++++++++++++++ 9 files changed, 565 insertions(+) create mode 100644 sentry_sdk/_trace_metrics_batcher.py create mode 100644 sentry_sdk/trace_metrics.py create mode 100644 tests/test_trace_metrics.py diff --git a/sentry_sdk/__init__.py b/sentry_sdk/__init__.py index 1939be0510..476f8a5b86 100644 --- a/sentry_sdk/__init__.py +++ b/sentry_sdk/__init__.py @@ -1,4 +1,5 @@ from sentry_sdk import profiler +from sentry_sdk import trace_metrics from sentry_sdk.scope import Scope from sentry_sdk.transport import Transport, HttpTransport from sentry_sdk.client import Client @@ -49,6 +50,7 @@ "monitor", "logger", "profiler", + "trace_metrics", "start_session", "end_session", "set_transaction_name", diff --git a/sentry_sdk/_trace_metrics_batcher.py b/sentry_sdk/_trace_metrics_batcher.py new file mode 100644 index 0000000000..240db890f7 --- /dev/null +++ b/sentry_sdk/_trace_metrics_batcher.py @@ -0,0 +1,157 @@ +import os +import random +import threading +from datetime import datetime, timezone +from typing import Optional, List, Callable, TYPE_CHECKING, Any + +from sentry_sdk.utils import format_timestamp, safe_repr +from sentry_sdk.envelope import Envelope, Item, PayloadRef + +if TYPE_CHECKING: + from sentry_sdk._types import TraceMetric + + +class TraceMetricsBatcher: + MAX_METRICS_BEFORE_FLUSH = 100 + FLUSH_WAIT_TIME = 5.0 + + def __init__( + self, + capture_func, # type: Callable[[Envelope], None] + ): + # type: (...) -> None + self._metric_buffer = [] # type: List[TraceMetric] + self._capture_func = capture_func + self._running = True + self._lock = threading.Lock() + + self._flush_event = threading.Event() # type: threading.Event + + self._flusher = None # type: Optional[threading.Thread] + self._flusher_pid = None # type: Optional[int] + + def _ensure_thread(self): + # type: (...) -> bool + if not self._running: + return False + + pid = os.getpid() + if self._flusher_pid == pid: + return True + + with self._lock: + if self._flusher_pid == pid: + return True + + self._flusher_pid = pid + + self._flusher = threading.Thread(target=self._flush_loop) + self._flusher.daemon = True + + try: + self._flusher.start() + except RuntimeError: + self._running = False + return False + + return True + + def _flush_loop(self): + # type: (...) -> None + while self._running: + self._flush_event.wait(self.FLUSH_WAIT_TIME + random.random()) + self._flush_event.clear() + self._flush() + + def add( + self, + metric, # type: TraceMetric + ): + # type: (...) -> None + if not self._ensure_thread() or self._flusher is None: + return None + + with self._lock: + self._metric_buffer.append(metric) + if len(self._metric_buffer) >= self.MAX_METRICS_BEFORE_FLUSH: + self._flush_event.set() + + def kill(self): + # type: (...) -> None + if self._flusher is None: + return + + self._running = False + self._flush_event.set() + self._flusher = None + + def flush(self): + # type: (...) -> None + self._flush() + + @staticmethod + def _metric_to_transport_format(metric): + # type: (TraceMetric) -> Any + def format_attribute(val): + # type: (int | float | str | bool) -> Any + if isinstance(val, bool): + return {"value": val, "type": "boolean"} + if isinstance(val, int): + return {"value": val, "type": "integer"} + if isinstance(val, float): + return {"value": val, "type": "double"} + if isinstance(val, str): + return {"value": val, "type": "string"} + return {"value": safe_repr(val), "type": "string"} + + res = { + "timestamp": metric["timestamp"], + "trace_id": metric["trace_id"], + "name": metric["name"], + "type": metric["type"], + "value": metric["value"], + "attributes": { + k: format_attribute(v) for (k, v) in metric["attributes"].items() + }, + } + + if metric.get("span_id") is not None: + res["span_id"] = metric["span_id"] + + if metric.get("unit") is not None: + res["unit"] = metric["unit"] + + return res + + def _flush(self): + # type: (...) -> Optional[Envelope] + + envelope = Envelope( + headers={"sent_at": format_timestamp(datetime.now(timezone.utc))} + ) + with self._lock: + if len(self._metric_buffer) == 0: + return None + + envelope.add_item( + Item( + type="trace_metric", + content_type="application/vnd.sentry.items.trace-metric+json", + headers={ + "item_count": len(self._metric_buffer), + }, + payload=PayloadRef( + json={ + "items": [ + self._metric_to_transport_format(metric) + for metric in self._metric_buffer + ] + } + ), + ) + ) + self._metric_buffer.clear() + + self._capture_func(envelope) + return envelope + diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index b28c7260ce..522c0dac1a 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -235,6 +235,32 @@ class SDKInfo(TypedDict): }, ) + TraceMetricType = Literal["counter", "gauge", "distribution"] + + TraceMetricAttributeValue = TypedDict( + "TraceMetricAttributeValue", + { + "value": Union[str, bool, float, int], + "type": Literal["string", "boolean", "double", "integer"], + }, + ) + + TraceMetric = TypedDict( + "TraceMetric", + { + "timestamp": float, + "trace_id": str, + "span_id": Optional[str], + "name": str, + "type": TraceMetricType, + "value": float, + "unit": Optional[str], + "attributes": dict[str, TraceMetricAttributeValue], + }, + ) + + TraceMetricProcessor = Callable[[TraceMetric, Hint], Optional[TraceMetric]] + # TODO: Make a proper type definition for this (PRs welcome!) Breadcrumb = Dict[str, Any] @@ -270,6 +296,7 @@ class SDKInfo(TypedDict): "monitor", "span", "log_item", + "trace_metric", ] SessionStatus = Literal["ok", "exited", "crashed", "abnormal"] diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index c06043ebe2..11454bf9fe 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -25,6 +25,7 @@ logger, get_before_send_log, has_logs_enabled, + has_trace_metrics_enabled, ) from sentry_sdk.serializer import serialize from sentry_sdk.tracing import trace @@ -184,6 +185,7 @@ def __init__(self, options=None): self.monitor = None # type: Optional[Monitor] self.metrics_aggregator = None # type: Optional[MetricsAggregator] self.log_batcher = None # type: Optional[LogBatcher] + self.trace_metrics_batcher = None # type: Optional[TraceMetricsBatcher] def __getstate__(self, *args, **kwargs): # type: (*Any, **Any) -> Any @@ -219,6 +221,10 @@ def _capture_experimental_log(self, log): # type: (Log) -> None pass + def _capture_trace_metric(self, metric): + # type: (TraceMetric) -> None + pass + def capture_session(self, *args, **kwargs): # type: (*Any, **Any) -> None return None @@ -388,6 +394,13 @@ def _capture_envelope(envelope): self.log_batcher = LogBatcher(capture_func=_capture_envelope) + self.trace_metrics_batcher = None + + if has_trace_metrics_enabled(self.options): + from sentry_sdk._trace_metrics_batcher import TraceMetricsBatcher + + self.trace_metrics_batcher = TraceMetricsBatcher(capture_func=_capture_envelope) + max_request_body_size = ("always", "never", "small", "medium") if self.options["max_request_body_size"] not in max_request_body_size: raise ValueError( @@ -967,6 +980,56 @@ def _capture_experimental_log(self, log): if self.log_batcher: self.log_batcher.add(log) + def _capture_trace_metric(self, metric): + # type: (Optional[TraceMetric]) -> None + if not has_trace_metrics_enabled(self.options) or metric is None: + return + + current_scope = sentry_sdk.get_current_scope() + isolation_scope = sentry_sdk.get_isolation_scope() + + metric["attributes"]["sentry.sdk.name"] = SDK_INFO["name"] + metric["attributes"]["sentry.sdk.version"] = SDK_INFO["version"] + + environment = self.options.get("environment") + if environment is not None and "sentry.environment" not in metric["attributes"]: + metric["attributes"]["sentry.environment"] = environment + + release = self.options.get("release") + if release is not None and "sentry.release" not in metric["attributes"]: + metric["attributes"]["sentry.release"] = release + + if isolation_scope._user is not None: + for metric_attribute, user_attribute in ( + ("user.id", "id"), + ("user.name", "username"), + ("user.email", "email"), + ): + if ( + user_attribute in isolation_scope._user + and metric_attribute not in metric["attributes"] + ): + metric["attributes"][metric_attribute] = isolation_scope._user[ + user_attribute + ] + + debug = self.options.get("debug", False) + if debug: + logger.debug( + f"[Sentry Trace Metrics] [{metric.get('type')}] {metric.get('name')}: {metric.get('value')}" + ) + + from sentry_sdk.utils import get_before_send_trace_metric + before_send_trace_metric = get_before_send_trace_metric(self.options) + if before_send_trace_metric is not None: + metric = before_send_trace_metric(metric, {}) + + if metric is None: + return + + if self.trace_metrics_batcher: + self.trace_metrics_batcher.add(metric) + def capture_session( self, session, # type: Session @@ -1023,6 +1086,8 @@ def close( self.metrics_aggregator.kill() if self.log_batcher is not None: self.log_batcher.kill() + if self.trace_metrics_batcher is not None: + self.trace_metrics_batcher.kill() if self.monitor: self.monitor.kill() self.transport.kill() @@ -1049,6 +1114,8 @@ def flush( self.metrics_aggregator.flush() if self.log_batcher is not None: self.log_batcher.flush() + if self.trace_metrics_batcher is not None: + self.trace_metrics_batcher.flush() self.transport.flush(timeout=timeout, callback=callback) def __enter__(self): diff --git a/sentry_sdk/envelope.py b/sentry_sdk/envelope.py index d9b2c1629a..c8e3f1ee08 100644 --- a/sentry_sdk/envelope.py +++ b/sentry_sdk/envelope.py @@ -285,6 +285,8 @@ def data_category(self): return "error" elif ty == "log": return "log_item" + elif ty == "trace_metric": + return "trace_metric" elif ty == "client_report": return "internal" elif ty == "profile": diff --git a/sentry_sdk/trace_metrics.py b/sentry_sdk/trace_metrics.py new file mode 100644 index 0000000000..2a7ac77cc8 --- /dev/null +++ b/sentry_sdk/trace_metrics.py @@ -0,0 +1,91 @@ +import time +from typing import Any, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from sentry_sdk._types import TraceMetric + + +def _capture_trace_metric( + name, # type: str + metric_type, # type: str + value, # type: float + unit=None, # type: Optional[str] + attributes=None, # type: Optional[dict[str, Any]] +): + # type: (...) -> None + from sentry_sdk.api import get_client, get_current_scope, get_current_span + from sentry_sdk.utils import safe_repr + + client = get_client() + + attrs = {} # type: dict[str, str | bool | float | int] + if attributes: + for k, v in attributes.items(): + attrs[k] = ( + v + if ( + isinstance(v, str) + or isinstance(v, int) + or isinstance(v, bool) + or isinstance(v, float) + ) + else safe_repr(v) + ) + + span = get_current_span() + trace_id = "00000000-0000-0000-0000-000000000000" + span_id = None + + if span: + trace_context = span.get_trace_context() + trace_id = trace_context.get("trace_id", trace_id) + span_id = trace_context.get("span_id") + else: + scope = get_current_scope() + if scope: + propagation_context = scope._propagation_context + if propagation_context: + trace_id = propagation_context.get("trace_id", trace_id) + + metric = { + "timestamp": time.time(), + "trace_id": trace_id, + "span_id": span_id, + "name": name, + "type": metric_type, + "value": float(value), + "unit": unit, + "attributes": attrs, + } # type: TraceMetric + + client._capture_trace_metric(metric) + + +def count( + name, # type: str + value, # type: float + unit=None, # type: Optional[str] + attributes=None, # type: Optional[dict[str, Any]] +): + # type: (...) -> None + _capture_trace_metric(name, "counter", value, unit, attributes) + + +def gauge( + name, # type: str + value, # type: float + unit=None, # type: Optional[str] + attributes=None, # type: Optional[dict[str, Any]] +): + # type: (...) -> None + _capture_trace_metric(name, "gauge", value, unit, attributes) + + +def distribution( + name, # type: str + value, # type: float + unit=None, # type: Optional[str] + attributes=None, # type: Optional[dict[str, Any]] +): + # type: (...) -> None + _capture_trace_metric(name, "distribution", value, unit, attributes) diff --git a/sentry_sdk/types.py b/sentry_sdk/types.py index 1a65247584..32ce32376f 100644 --- a/sentry_sdk/types.py +++ b/sentry_sdk/types.py @@ -21,6 +21,7 @@ Log, MonitorConfig, SamplingContext, + TraceMetric, ) else: from typing import Any @@ -35,6 +36,7 @@ Log = Any MonitorConfig = Any SamplingContext = Any + TraceMetric = Any __all__ = ( @@ -46,4 +48,5 @@ "Log", "MonitorConfig", "SamplingContext", + "TraceMetric", ) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 2083fd296c..c9e2e61a33 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2013,3 +2013,21 @@ def get_before_send_log(options): return options.get("before_send_log") or options["_experiments"].get( "before_send_log" ) + + +def has_trace_metrics_enabled(options): + # type: (Optional[dict[str, Any]]) -> bool + if options is None: + return False + + return bool(options["_experiments"].get("enableTraceMetrics", False)) + + +def get_before_send_trace_metric(options): + # type: (Optional[dict[str, Any]]) -> Optional[Callable[[TraceMetric, Hint], Optional[TraceMetric]]] + if options is None: + return None + + return options.get("before_send_trace_metric") or options["_experiments"].get( + "before_send_trace_metric" + ) diff --git a/tests/test_trace_metrics.py b/tests/test_trace_metrics.py new file mode 100644 index 0000000000..49ac219714 --- /dev/null +++ b/tests/test_trace_metrics.py @@ -0,0 +1,198 @@ +import json +import sys +from typing import List, Any, Mapping +import pytest + +import sentry_sdk +import sentry_sdk.trace_metrics +from sentry_sdk import get_client +from sentry_sdk.envelope import Envelope +from sentry_sdk.types import TraceMetric + +minimum_python_37 = pytest.mark.skipif( + sys.version_info < (3, 7), reason="Asyncio tests need Python >= 3.7" +) + + +def envelopes_to_trace_metrics(envelopes): + # type: (List[Envelope]) -> List[TraceMetric] + res = [] # type: List[TraceMetric] + for envelope in envelopes: + for item in envelope.items: + if item.type == "trace_metric": + for metric_json in item.payload.json["items"]: + metric = { + "timestamp": metric_json["timestamp"], + "trace_id": metric_json["trace_id"], + "span_id": metric_json.get("span_id"), + "name": metric_json["name"], + "type": metric_json["type"], + "value": metric_json["value"], + "unit": metric_json.get("unit"), + "attributes": { + k: v["value"] for (k, v) in metric_json["attributes"].items() + }, + } # type: TraceMetric + res.append(metric) + return res + + +@minimum_python_37 +def test_trace_metrics_disabled_by_default(sentry_init, capture_envelopes): + sentry_init() + + envelopes = capture_envelopes() + + sentry_sdk.trace_metrics.count("test.counter", 1) + sentry_sdk.trace_metrics.gauge("test.gauge", 42) + sentry_sdk.trace_metrics.distribution("test.distribution", 200) + + assert len(envelopes) == 0 + + +@minimum_python_37 +def test_trace_metrics_basics(sentry_init, capture_envelopes): + sentry_init(_experiments={"enableTraceMetrics": True}) + envelopes = capture_envelopes() + + sentry_sdk.trace_metrics.count("test.counter", 1) + sentry_sdk.trace_metrics.gauge("test.gauge", 42, unit="millisecond") + sentry_sdk.trace_metrics.distribution("test.distribution", 200, unit="second") + + get_client().flush() + metrics = envelopes_to_trace_metrics(envelopes) + + assert len(metrics) == 3 + + assert metrics[0]["name"] == "test.counter" + assert metrics[0]["type"] == "counter" + assert metrics[0]["value"] == 1.0 + assert metrics[0]["unit"] is None + assert "sentry.sdk.name" in metrics[0]["attributes"] + assert "sentry.sdk.version" in metrics[0]["attributes"] + + assert metrics[1]["name"] == "test.gauge" + assert metrics[1]["type"] == "gauge" + assert metrics[1]["value"] == 42.0 + assert metrics[1]["unit"] == "millisecond" + + assert metrics[2]["name"] == "test.distribution" + assert metrics[2]["type"] == "distribution" + assert metrics[2]["value"] == 200.0 + assert metrics[2]["unit"] == "second" + + +@minimum_python_37 +def test_trace_metrics_experimental_option(sentry_init, capture_envelopes): + sentry_init(_experiments={"enableTraceMetrics": True}) + envelopes = capture_envelopes() + + sentry_sdk.trace_metrics.count("test.counter", 5) + + get_client().flush() + + metrics = envelopes_to_trace_metrics(envelopes) + assert len(metrics) == 1 + + assert metrics[0]["name"] == "test.counter" + assert metrics[0]["type"] == "counter" + assert metrics[0]["value"] == 5.0 + + +@minimum_python_37 +def test_trace_metrics_with_attributes(sentry_init, capture_envelopes): + sentry_init(_experiments={"enableTraceMetrics": True}, release="1.0.0", environment="test") + envelopes = capture_envelopes() + + sentry_sdk.trace_metrics.count( + "test.counter", + 1, + attributes={"endpoint": "/api/test", "status": "success"} + ) + + get_client().flush() + + metrics = envelopes_to_trace_metrics(envelopes) + assert len(metrics) == 1 + + assert metrics[0]["attributes"]["endpoint"] == "/api/test" + assert metrics[0]["attributes"]["status"] == "success" + assert metrics[0]["attributes"]["sentry.release"] == "1.0.0" + assert metrics[0]["attributes"]["sentry.environment"] == "test" + + +@minimum_python_37 +def test_trace_metrics_with_user(sentry_init, capture_envelopes): + sentry_init(_experiments={"enableTraceMetrics": True}) + envelopes = capture_envelopes() + + sentry_sdk.set_user({"id": "user-123", "email": "test@example.com", "username": "testuser"}) + sentry_sdk.trace_metrics.count("test.user.counter", 1) + + get_client().flush() + + metrics = envelopes_to_trace_metrics(envelopes) + assert len(metrics) == 1 + + assert metrics[0]["attributes"]["user.id"] == "user-123" + assert metrics[0]["attributes"]["user.email"] == "test@example.com" + assert metrics[0]["attributes"]["user.name"] == "testuser" + + +@minimum_python_37 +def test_trace_metrics_with_span(sentry_init, capture_envelopes): + sentry_init(_experiments={"enableTraceMetrics": True}, traces_sample_rate=1.0) + envelopes = capture_envelopes() + + with sentry_sdk.start_span(op="test", name="test-span") as span: + sentry_sdk.trace_metrics.count("test.span.counter", 1) + + get_client().flush() + + metrics = envelopes_to_trace_metrics(envelopes) + assert len(metrics) == 1 + + assert metrics[0]["trace_id"] is not None + assert metrics[0]["trace_id"] != "00000000-0000-0000-0000-000000000000" + assert metrics[0]["span_id"] is not None + + +@minimum_python_37 +def test_trace_metrics_before_send(sentry_init, capture_envelopes): + before_metric_called = False + + def _before_metric(record, hint): + nonlocal before_metric_called + + assert set(record.keys()) == { + "timestamp", + "trace_id", + "span_id", + "name", + "type", + "value", + "unit", + "attributes", + } + + if record["name"] == "test.skip": + return None + + before_metric_called = True + return record + + sentry_init( + _experiments={"enableTraceMetrics": True, "before_send_trace_metric": _before_metric}, + ) + envelopes = capture_envelopes() + + sentry_sdk.trace_metrics.count("test.skip", 1) + sentry_sdk.trace_metrics.count("test.keep", 1) + + get_client().flush() + + metrics = envelopes_to_trace_metrics(envelopes) + assert len(metrics) == 1 + assert metrics[0]["name"] == "test.keep" + assert before_metric_called + From 225affd7a1282cc9749fa7392ff65f3a011bae8e Mon Sep 17 00:00:00 2001 From: Kev Date: Thu, 9 Oct 2025 02:25:37 -0400 Subject: [PATCH 02/14] Cleanup exposing 'tracemetric' as users should only see 'metrics' since trace metric is an internal dataset detail --- sentry_sdk/client.py | 10 +++++----- sentry_sdk/utils.py | 8 +++----- tests/test_trace_metrics.py | 26 +++++++++++++++----------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 11454bf9fe..5af04b0405 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -24,6 +24,7 @@ is_gevent, logger, get_before_send_log, + get_before_send_metric, has_logs_enabled, has_trace_metrics_enabled, ) @@ -1016,13 +1017,12 @@ def _capture_trace_metric(self, metric): debug = self.options.get("debug", False) if debug: logger.debug( - f"[Sentry Trace Metrics] [{metric.get('type')}] {metric.get('name')}: {metric.get('value')}" + f"[Sentry Metrics] [{metric.get('type')}] {metric.get('name')}: {metric.get('value')}" ) - from sentry_sdk.utils import get_before_send_trace_metric - before_send_trace_metric = get_before_send_trace_metric(self.options) - if before_send_trace_metric is not None: - metric = before_send_trace_metric(metric, {}) + before_send_metric = get_before_send_metric(self.options) + if before_send_metric is not None: + metric = before_send_metric(metric, {}) if metric is None: return diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index c9e2e61a33..99376e9761 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2020,14 +2020,12 @@ def has_trace_metrics_enabled(options): if options is None: return False - return bool(options["_experiments"].get("enableTraceMetrics", False)) + return bool(options["_experiments"].get("enableMetrics", False)) -def get_before_send_trace_metric(options): +def get_before_send_metric(options): # type: (Optional[dict[str, Any]]) -> Optional[Callable[[TraceMetric, Hint], Optional[TraceMetric]]] if options is None: return None - return options.get("before_send_trace_metric") or options["_experiments"].get( - "before_send_trace_metric" - ) + return options["_experiments"].get("before_send_metric") diff --git a/tests/test_trace_metrics.py b/tests/test_trace_metrics.py index 49ac219714..eca99947de 100644 --- a/tests/test_trace_metrics.py +++ b/tests/test_trace_metrics.py @@ -52,7 +52,7 @@ def test_trace_metrics_disabled_by_default(sentry_init, capture_envelopes): @minimum_python_37 def test_trace_metrics_basics(sentry_init, capture_envelopes): - sentry_init(_experiments={"enableTraceMetrics": True}) + sentry_init(_experiments={"enableMetrics": True}) envelopes = capture_envelopes() sentry_sdk.trace_metrics.count("test.counter", 1) @@ -84,7 +84,7 @@ def test_trace_metrics_basics(sentry_init, capture_envelopes): @minimum_python_37 def test_trace_metrics_experimental_option(sentry_init, capture_envelopes): - sentry_init(_experiments={"enableTraceMetrics": True}) + sentry_init(_experiments={"enableMetrics": True}) envelopes = capture_envelopes() sentry_sdk.trace_metrics.count("test.counter", 5) @@ -101,13 +101,13 @@ def test_trace_metrics_experimental_option(sentry_init, capture_envelopes): @minimum_python_37 def test_trace_metrics_with_attributes(sentry_init, capture_envelopes): - sentry_init(_experiments={"enableTraceMetrics": True}, release="1.0.0", environment="test") + sentry_init( + _experiments={"enableMetrics": True}, release="1.0.0", environment="test" + ) envelopes = capture_envelopes() sentry_sdk.trace_metrics.count( - "test.counter", - 1, - attributes={"endpoint": "/api/test", "status": "success"} + "test.counter", 1, attributes={"endpoint": "/api/test", "status": "success"} ) get_client().flush() @@ -123,10 +123,12 @@ def test_trace_metrics_with_attributes(sentry_init, capture_envelopes): @minimum_python_37 def test_trace_metrics_with_user(sentry_init, capture_envelopes): - sentry_init(_experiments={"enableTraceMetrics": True}) + sentry_init(_experiments={"enableMetrics": True}) envelopes = capture_envelopes() - sentry_sdk.set_user({"id": "user-123", "email": "test@example.com", "username": "testuser"}) + sentry_sdk.set_user( + {"id": "user-123", "email": "test@example.com", "username": "testuser"} + ) sentry_sdk.trace_metrics.count("test.user.counter", 1) get_client().flush() @@ -141,7 +143,7 @@ def test_trace_metrics_with_user(sentry_init, capture_envelopes): @minimum_python_37 def test_trace_metrics_with_span(sentry_init, capture_envelopes): - sentry_init(_experiments={"enableTraceMetrics": True}, traces_sample_rate=1.0) + sentry_init(_experiments={"enableMetrics": True}, traces_sample_rate=1.0) envelopes = capture_envelopes() with sentry_sdk.start_span(op="test", name="test-span") as span: @@ -182,7 +184,10 @@ def _before_metric(record, hint): return record sentry_init( - _experiments={"enableTraceMetrics": True, "before_send_trace_metric": _before_metric}, + _experiments={ + "enableMetrics": True, + "before_send_metric": _before_metric, + }, ) envelopes = capture_envelopes() @@ -195,4 +200,3 @@ def _before_metric(record, hint): assert len(metrics) == 1 assert metrics[0]["name"] == "test.keep" assert before_metric_called - From c25f7cb605b573af6ce101d2800cf2a9a18ddddb Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 14:43:18 +0200 Subject: [PATCH 03/14] renaming trace metrics -> metrics --- sentry_sdk/__init__.py | 2 - sentry_sdk/{trace_metrics.py => _metrics.py} | 16 ++-- ...metrics_batcher.py => _metrics_batcher.py} | 13 ++- sentry_sdk/_types.py | 16 ++-- sentry_sdk/client.py | 37 +++++---- sentry_sdk/consts.py | 3 + sentry_sdk/types.py | 6 +- sentry_sdk/utils.py | 6 +- ...{test_trace_metrics.py => test_metrics.py} | 80 ++++++++----------- 9 files changed, 84 insertions(+), 95 deletions(-) rename sentry_sdk/{trace_metrics.py => _metrics.py} (85%) rename sentry_sdk/{_trace_metrics_batcher.py => _metrics_batcher.py} (94%) rename tests/{test_trace_metrics.py => test_metrics.py} (66%) diff --git a/sentry_sdk/__init__.py b/sentry_sdk/__init__.py index 476f8a5b86..1939be0510 100644 --- a/sentry_sdk/__init__.py +++ b/sentry_sdk/__init__.py @@ -1,5 +1,4 @@ from sentry_sdk import profiler -from sentry_sdk import trace_metrics from sentry_sdk.scope import Scope from sentry_sdk.transport import Transport, HttpTransport from sentry_sdk.client import Client @@ -50,7 +49,6 @@ "monitor", "logger", "profiler", - "trace_metrics", "start_session", "end_session", "set_transaction_name", diff --git a/sentry_sdk/trace_metrics.py b/sentry_sdk/_metrics.py similarity index 85% rename from sentry_sdk/trace_metrics.py rename to sentry_sdk/_metrics.py index 2a7ac77cc8..18d58aeaa7 100644 --- a/sentry_sdk/trace_metrics.py +++ b/sentry_sdk/_metrics.py @@ -2,10 +2,10 @@ from typing import Any, Optional, TYPE_CHECKING if TYPE_CHECKING: - from sentry_sdk._types import TraceMetric + from sentry_sdk._types import Metric -def _capture_trace_metric( +def _capture_metric( name, # type: str metric_type, # type: str value, # type: float @@ -35,7 +35,7 @@ def _capture_trace_metric( span = get_current_span() trace_id = "00000000-0000-0000-0000-000000000000" span_id = None - + if span: trace_context = span.get_trace_context() trace_id = trace_context.get("trace_id", trace_id) @@ -56,9 +56,9 @@ def _capture_trace_metric( "value": float(value), "unit": unit, "attributes": attrs, - } # type: TraceMetric + } # type: Metric - client._capture_trace_metric(metric) + client._capture_metric(metric) def count( @@ -68,7 +68,7 @@ def count( attributes=None, # type: Optional[dict[str, Any]] ): # type: (...) -> None - _capture_trace_metric(name, "counter", value, unit, attributes) + _capture_metric(name, "counter", value, unit, attributes) def gauge( @@ -78,7 +78,7 @@ def gauge( attributes=None, # type: Optional[dict[str, Any]] ): # type: (...) -> None - _capture_trace_metric(name, "gauge", value, unit, attributes) + _capture_metric(name, "gauge", value, unit, attributes) def distribution( @@ -88,4 +88,4 @@ def distribution( attributes=None, # type: Optional[dict[str, Any]] ): # type: (...) -> None - _capture_trace_metric(name, "distribution", value, unit, attributes) + _capture_metric(name, "distribution", value, unit, attributes) diff --git a/sentry_sdk/_trace_metrics_batcher.py b/sentry_sdk/_metrics_batcher.py similarity index 94% rename from sentry_sdk/_trace_metrics_batcher.py rename to sentry_sdk/_metrics_batcher.py index 240db890f7..8ca8a9db08 100644 --- a/sentry_sdk/_trace_metrics_batcher.py +++ b/sentry_sdk/_metrics_batcher.py @@ -8,10 +8,10 @@ from sentry_sdk.envelope import Envelope, Item, PayloadRef if TYPE_CHECKING: - from sentry_sdk._types import TraceMetric + from sentry_sdk._types import Metric -class TraceMetricsBatcher: +class MetricsBatcher: MAX_METRICS_BEFORE_FLUSH = 100 FLUSH_WAIT_TIME = 5.0 @@ -20,7 +20,7 @@ def __init__( capture_func, # type: Callable[[Envelope], None] ): # type: (...) -> None - self._metric_buffer = [] # type: List[TraceMetric] + self._metric_buffer = [] # type: List[Metric] self._capture_func = capture_func self._running = True self._lock = threading.Lock() @@ -65,7 +65,7 @@ def _flush_loop(self): def add( self, - metric, # type: TraceMetric + metric, # type: Metric ): # type: (...) -> None if not self._ensure_thread() or self._flusher is None: @@ -91,9 +91,9 @@ def flush(self): @staticmethod def _metric_to_transport_format(metric): - # type: (TraceMetric) -> Any + # type: (Metric) -> Any def format_attribute(val): - # type: (int | float | str | bool) -> Any + # type: (Union[int, float, str, bool]) -> Any if isinstance(val, bool): return {"value": val, "type": "boolean"} if isinstance(val, int): @@ -154,4 +154,3 @@ def _flush(self): self._capture_func(envelope) return envelope - diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 6560ad1924..1dd7da6cd7 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -234,31 +234,31 @@ class SDKInfo(TypedDict): }, ) - TraceMetricType = Literal["counter", "gauge", "distribution"] + MetricType = Literal["counter", "gauge", "distribution"] - TraceMetricAttributeValue = TypedDict( - "TraceMetricAttributeValue", + MetricAttributeValue = TypedDict( + "MetricAttributeValue", { "value": Union[str, bool, float, int], "type": Literal["string", "boolean", "double", "integer"], }, ) - TraceMetric = TypedDict( - "TraceMetric", + Metric = TypedDict( + "Metric", { "timestamp": float, "trace_id": str, "span_id": Optional[str], "name": str, - "type": TraceMetricType, + "type": MetricType, "value": float, "unit": Optional[str], - "attributes": dict[str, TraceMetricAttributeValue], + "attributes": dict[str, MetricAttributeValue], }, ) - TraceMetricProcessor = Callable[[TraceMetric, Hint], Optional[TraceMetric]] + MetricProcessor = Callable[[Metric, Hint], Optional[Metric]] # TODO: Make a proper type definition for this (PRs welcome!) Breadcrumb = Dict[str, Any] diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 1b11039d13..d4ac19650c 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -26,7 +26,7 @@ get_before_send_log, get_before_send_metric, has_logs_enabled, - has_trace_metrics_enabled, + has_metrics_enabled, ) from sentry_sdk.serializer import serialize from sentry_sdk.tracing import trace @@ -61,7 +61,7 @@ from typing import Union from typing import TypeVar - from sentry_sdk._types import Event, Hint, SDKInfo, Log + from sentry_sdk._types import Event, Hint, SDKInfo, Log, Metric from sentry_sdk.integrations import Integration from sentry_sdk.scope import Scope from sentry_sdk.session import Session @@ -184,7 +184,7 @@ def __init__(self, options=None): self.transport = None # type: Optional[Transport] self.monitor = None # type: Optional[Monitor] self.log_batcher = None # type: Optional[LogBatcher] - self.trace_metrics_batcher = None # type: Optional[TraceMetricsBatcher] + self.metrics_batcher = None # type: Optional[MetricsBatcher] def __getstate__(self, *args, **kwargs): # type: (*Any, **Any) -> Any @@ -220,8 +220,8 @@ def _capture_log(self, log): # type: (Log) -> None pass - def _capture_trace_metric(self, metric): - # type: (TraceMetric) -> None + def _capture_metric(self, metric): + # type: (Metric) -> None pass def capture_session(self, *args, **kwargs): @@ -373,12 +373,12 @@ def _capture_envelope(envelope): self.log_batcher = LogBatcher(capture_func=_capture_envelope) - self.trace_metrics_batcher = None + self.metrics_batcher = None - if has_trace_metrics_enabled(self.options): - from sentry_sdk._trace_metrics_batcher import TraceMetricsBatcher + if has_metrics_enabled(self.options): + from sentry_sdk._metrics_batcher import MetricsBatcher - self.trace_metrics_batcher = TraceMetricsBatcher(capture_func=_capture_envelope) + self.metrics_batcher = MetricsBatcher(capture_func=_capture_envelope) max_request_body_size = ("always", "never", "small", "medium") if self.options["max_request_body_size"] not in max_request_body_size: @@ -958,12 +958,11 @@ def _capture_log(self, log): if self.log_batcher: self.log_batcher.add(log) - def _capture_trace_metric(self, metric): - # type: (Optional[TraceMetric]) -> None - if not has_trace_metrics_enabled(self.options) or metric is None: + def _capture_metric(self, metric): + # type: (Optional[Metric]) -> None + if not has_metrics_enabled(self.options) or metric is None: return - current_scope = sentry_sdk.get_current_scope() isolation_scope = sentry_sdk.get_isolation_scope() metric["attributes"]["sentry.sdk.name"] = SDK_INFO["name"] @@ -1004,8 +1003,8 @@ def _capture_trace_metric(self, metric): if metric is None: return - if self.trace_metrics_batcher: - self.trace_metrics_batcher.add(metric) + if self.metrics_batcher: + self.metrics_batcher.add(metric) def capture_session( self, @@ -1061,8 +1060,8 @@ def close( self.session_flusher.kill() if self.log_batcher is not None: self.log_batcher.kill() - if self.trace_metrics_batcher is not None: - self.trace_metrics_batcher.kill() + if self.metrics_batcher is not None: + self.metrics_batcher.kill() if self.monitor: self.monitor.kill() self.transport.kill() @@ -1087,8 +1086,8 @@ def flush( self.session_flusher.flush() if self.log_batcher is not None: self.log_batcher.flush() - if self.trace_metrics_batcher is not None: - self.trace_metrics_batcher.flush() + if self.metrics_batcher is not None: + self.metrics_batcher.flush() self.transport.flush(timeout=timeout, callback=callback) def __enter__(self): diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 0f71a0d460..12654cc76d 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -52,6 +52,7 @@ class CompressionAlgo(Enum): Hint, Log, MeasurementUnit, + Metric, ProfilerMode, TracesSampler, TransactionProcessor, @@ -77,6 +78,8 @@ class CompressionAlgo(Enum): "transport_http2": Optional[bool], "enable_logs": Optional[bool], "before_send_log": Optional[Callable[[Log, Hint], Optional[Log]]], + "enable_metrics": Optional[bool], + "before_send_metric": Optional[Callable[[Metric, Hint], Optional[Metric]]], }, total=False, ) diff --git a/sentry_sdk/types.py b/sentry_sdk/types.py index 32ce32376f..8b28166462 100644 --- a/sentry_sdk/types.py +++ b/sentry_sdk/types.py @@ -21,7 +21,7 @@ Log, MonitorConfig, SamplingContext, - TraceMetric, + Metric, ) else: from typing import Any @@ -36,7 +36,7 @@ Log = Any MonitorConfig = Any SamplingContext = Any - TraceMetric = Any + Metric = Any __all__ = ( @@ -48,5 +48,5 @@ "Log", "MonitorConfig", "SamplingContext", - "TraceMetric", + "Metric", ) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 99376e9761..7495a85899 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2015,16 +2015,16 @@ def get_before_send_log(options): ) -def has_trace_metrics_enabled(options): +def has_metrics_enabled(options): # type: (Optional[dict[str, Any]]) -> bool if options is None: return False - return bool(options["_experiments"].get("enableMetrics", False)) + return bool(options["_experiments"].get("enable_metrics", False)) def get_before_send_metric(options): - # type: (Optional[dict[str, Any]]) -> Optional[Callable[[TraceMetric, Hint], Optional[TraceMetric]]] + # type: (Optional[dict[str, Any]]) -> Optional[Callable[[Metric, Hint], Optional[Metric]]] if options is None: return None diff --git a/tests/test_trace_metrics.py b/tests/test_metrics.py similarity index 66% rename from tests/test_trace_metrics.py rename to tests/test_metrics.py index eca99947de..f56f782ae4 100644 --- a/tests/test_trace_metrics.py +++ b/tests/test_metrics.py @@ -4,19 +4,15 @@ import pytest import sentry_sdk -import sentry_sdk.trace_metrics +from sentry_sdk import _metrics from sentry_sdk import get_client from sentry_sdk.envelope import Envelope -from sentry_sdk.types import TraceMetric +from sentry_sdk.types import Metric -minimum_python_37 = pytest.mark.skipif( - sys.version_info < (3, 7), reason="Asyncio tests need Python >= 3.7" -) - -def envelopes_to_trace_metrics(envelopes): - # type: (List[Envelope]) -> List[TraceMetric] - res = [] # type: List[TraceMetric] +def envelopes_to_metrics(envelopes): + # type: (List[Envelope]) -> List[Metric] + res = [] # type: List[Metric] for envelope in envelopes: for item in envelope.items: if item.type == "trace_metric": @@ -30,38 +26,37 @@ def envelopes_to_trace_metrics(envelopes): "value": metric_json["value"], "unit": metric_json.get("unit"), "attributes": { - k: v["value"] for (k, v) in metric_json["attributes"].items() + k: v["value"] + for (k, v) in metric_json["attributes"].items() }, - } # type: TraceMetric + } # type: Metric res.append(metric) return res -@minimum_python_37 -def test_trace_metrics_disabled_by_default(sentry_init, capture_envelopes): +def test_metrics_disabled_by_default(sentry_init, capture_envelopes): sentry_init() envelopes = capture_envelopes() - sentry_sdk.trace_metrics.count("test.counter", 1) - sentry_sdk.trace_metrics.gauge("test.gauge", 42) - sentry_sdk.trace_metrics.distribution("test.distribution", 200) + _metrics.count("test.counter", 1) + _metrics.gauge("test.gauge", 42) + _metrics.distribution("test.distribution", 200) assert len(envelopes) == 0 -@minimum_python_37 -def test_trace_metrics_basics(sentry_init, capture_envelopes): +def test_metrics_basics(sentry_init, capture_envelopes): sentry_init(_experiments={"enableMetrics": True}) envelopes = capture_envelopes() - sentry_sdk.trace_metrics.count("test.counter", 1) - sentry_sdk.trace_metrics.gauge("test.gauge", 42, unit="millisecond") - sentry_sdk.trace_metrics.distribution("test.distribution", 200, unit="second") + _metrics.count("test.counter", 1) + _metrics.gauge("test.gauge", 42, unit="millisecond") + _metrics.distribution("test.distribution", 200, unit="second") get_client().flush() - metrics = envelopes_to_trace_metrics(envelopes) - + metrics = envelopes_to_metrics(envelopes) + assert len(metrics) == 3 assert metrics[0]["name"] == "test.counter" @@ -82,16 +77,15 @@ def test_trace_metrics_basics(sentry_init, capture_envelopes): assert metrics[2]["unit"] == "second" -@minimum_python_37 -def test_trace_metrics_experimental_option(sentry_init, capture_envelopes): +def test_metrics_experimental_option(sentry_init, capture_envelopes): sentry_init(_experiments={"enableMetrics": True}) envelopes = capture_envelopes() - sentry_sdk.trace_metrics.count("test.counter", 5) + _metrics.count("test.counter", 5) get_client().flush() - metrics = envelopes_to_trace_metrics(envelopes) + metrics = envelopes_to_metrics(envelopes) assert len(metrics) == 1 assert metrics[0]["name"] == "test.counter" @@ -99,20 +93,19 @@ def test_trace_metrics_experimental_option(sentry_init, capture_envelopes): assert metrics[0]["value"] == 5.0 -@minimum_python_37 -def test_trace_metrics_with_attributes(sentry_init, capture_envelopes): +def test_metrics_with_attributes(sentry_init, capture_envelopes): sentry_init( _experiments={"enableMetrics": True}, release="1.0.0", environment="test" ) envelopes = capture_envelopes() - sentry_sdk.trace_metrics.count( + _metrics.count( "test.counter", 1, attributes={"endpoint": "/api/test", "status": "success"} ) get_client().flush() - metrics = envelopes_to_trace_metrics(envelopes) + metrics = envelopes_to_metrics(envelopes) assert len(metrics) == 1 assert metrics[0]["attributes"]["endpoint"] == "/api/test" @@ -121,19 +114,18 @@ def test_trace_metrics_with_attributes(sentry_init, capture_envelopes): assert metrics[0]["attributes"]["sentry.environment"] == "test" -@minimum_python_37 -def test_trace_metrics_with_user(sentry_init, capture_envelopes): +def test_metrics_with_user(sentry_init, capture_envelopes): sentry_init(_experiments={"enableMetrics": True}) envelopes = capture_envelopes() sentry_sdk.set_user( {"id": "user-123", "email": "test@example.com", "username": "testuser"} ) - sentry_sdk.trace_metrics.count("test.user.counter", 1) + _metrics.count("test.user.counter", 1) get_client().flush() - metrics = envelopes_to_trace_metrics(envelopes) + metrics = envelopes_to_metrics(envelopes) assert len(metrics) == 1 assert metrics[0]["attributes"]["user.id"] == "user-123" @@ -141,17 +133,16 @@ def test_trace_metrics_with_user(sentry_init, capture_envelopes): assert metrics[0]["attributes"]["user.name"] == "testuser" -@minimum_python_37 -def test_trace_metrics_with_span(sentry_init, capture_envelopes): +def test_metrics_with_span(sentry_init, capture_envelopes): sentry_init(_experiments={"enableMetrics": True}, traces_sample_rate=1.0) envelopes = capture_envelopes() - with sentry_sdk.start_span(op="test", name="test-span") as span: - sentry_sdk.trace_metrics.count("test.span.counter", 1) + with sentry_sdk.start_span(op="test", name="test-span"): + _metrics.count("test.span.counter", 1) get_client().flush() - metrics = envelopes_to_trace_metrics(envelopes) + metrics = envelopes_to_metrics(envelopes) assert len(metrics) == 1 assert metrics[0]["trace_id"] is not None @@ -159,8 +150,7 @@ def test_trace_metrics_with_span(sentry_init, capture_envelopes): assert metrics[0]["span_id"] is not None -@minimum_python_37 -def test_trace_metrics_before_send(sentry_init, capture_envelopes): +def test_metrics_before_send(sentry_init, capture_envelopes): before_metric_called = False def _before_metric(record, hint): @@ -191,12 +181,12 @@ def _before_metric(record, hint): ) envelopes = capture_envelopes() - sentry_sdk.trace_metrics.count("test.skip", 1) - sentry_sdk.trace_metrics.count("test.keep", 1) + _metrics.count("test.skip", 1) + _metrics.count("test.keep", 1) get_client().flush() - metrics = envelopes_to_trace_metrics(envelopes) + metrics = envelopes_to_metrics(envelopes) assert len(metrics) == 1 assert metrics[0]["name"] == "test.keep" assert before_metric_called From f339a60fef12b0eb2ab60a00b365e3eeba6c8d38 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 14:44:34 +0200 Subject: [PATCH 04/14] Apply suggestion from @sentrivana --- 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 18d58aeaa7..15b528fcc8 100644 --- a/sentry_sdk/_metrics.py +++ b/sentry_sdk/_metrics.py @@ -18,7 +18,7 @@ def _capture_metric( client = get_client() - attrs = {} # type: dict[str, str | bool | float | int] + attrs = {} # type: dict[str, Union[str, bool, float, int] if attributes: for k, v in attributes.items(): attrs[k] = ( From f6bed30cb4700501d69acf8b37fb68a922709d85 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 14:48:32 +0200 Subject: [PATCH 05/14] add notice --- sentry_sdk/_metrics.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sentry_sdk/_metrics.py b/sentry_sdk/_metrics.py index 15b528fcc8..3bbbbad8b1 100644 --- a/sentry_sdk/_metrics.py +++ b/sentry_sdk/_metrics.py @@ -1,3 +1,8 @@ +""" +NOTE: This file contains experimental code that may be changed or removed at any +time without prior notice. +""" + import time from typing import Any, Optional, TYPE_CHECKING From d8dd0ffa4427792a729d0885d082d37a3a574340 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 14:49:47 +0200 Subject: [PATCH 06/14] imports --- sentry_sdk/_metrics.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/_metrics.py b/sentry_sdk/_metrics.py index 3bbbbad8b1..84ffb85272 100644 --- a/sentry_sdk/_metrics.py +++ b/sentry_sdk/_metrics.py @@ -6,6 +6,9 @@ import time from typing import Any, Optional, TYPE_CHECKING +import sentry_sdk +from sentry_sdk.utils import safe_repr + if TYPE_CHECKING: from sentry_sdk._types import Metric @@ -18,10 +21,8 @@ def _capture_metric( attributes=None, # type: Optional[dict[str, Any]] ): # type: (...) -> None - from sentry_sdk.api import get_client, get_current_scope, get_current_span - from sentry_sdk.utils import safe_repr - client = get_client() + client = sentry_sdk.get_client() attrs = {} # type: dict[str, Union[str, bool, float, int] if attributes: @@ -37,7 +38,7 @@ def _capture_metric( else safe_repr(v) ) - span = get_current_span() + span = sentry_sdk.get_current_span() trace_id = "00000000-0000-0000-0000-000000000000" span_id = None @@ -46,11 +47,11 @@ def _capture_metric( trace_id = trace_context.get("trace_id", trace_id) span_id = trace_context.get("span_id") else: - scope = get_current_scope() + scope = sentry_sdk.get_current_scope() if scope: propagation_context = scope._propagation_context if propagation_context: - trace_id = propagation_context.get("trace_id", trace_id) + trace_id = propagation_context.trace_id or trace_id metric = { "timestamp": time.time(), From d7f64c4d1438220a85ef390448e6dc3a162090c8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 14:50:57 +0200 Subject: [PATCH 07/14] fix flag name in tests --- tests/test_metrics.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index f56f782ae4..ffa4799802 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -47,7 +47,7 @@ def test_metrics_disabled_by_default(sentry_init, capture_envelopes): def test_metrics_basics(sentry_init, capture_envelopes): - sentry_init(_experiments={"enableMetrics": True}) + sentry_init(_experiments={"enable_metrics": True}) envelopes = capture_envelopes() _metrics.count("test.counter", 1) @@ -78,7 +78,7 @@ def test_metrics_basics(sentry_init, capture_envelopes): def test_metrics_experimental_option(sentry_init, capture_envelopes): - sentry_init(_experiments={"enableMetrics": True}) + sentry_init(_experiments={"enable_metrics": True}) envelopes = capture_envelopes() _metrics.count("test.counter", 5) @@ -95,7 +95,7 @@ def test_metrics_experimental_option(sentry_init, capture_envelopes): def test_metrics_with_attributes(sentry_init, capture_envelopes): sentry_init( - _experiments={"enableMetrics": True}, release="1.0.0", environment="test" + _experiments={"enable_metrics": True}, release="1.0.0", environment="test" ) envelopes = capture_envelopes() @@ -115,7 +115,7 @@ def test_metrics_with_attributes(sentry_init, capture_envelopes): def test_metrics_with_user(sentry_init, capture_envelopes): - sentry_init(_experiments={"enableMetrics": True}) + sentry_init(_experiments={"enable_metrics": True}) envelopes = capture_envelopes() sentry_sdk.set_user( @@ -134,7 +134,7 @@ def test_metrics_with_user(sentry_init, capture_envelopes): def test_metrics_with_span(sentry_init, capture_envelopes): - sentry_init(_experiments={"enableMetrics": True}, traces_sample_rate=1.0) + sentry_init(_experiments={"enable_metrics": True}, traces_sample_rate=1.0) envelopes = capture_envelopes() with sentry_sdk.start_span(op="test", name="test-span"): @@ -175,7 +175,7 @@ def _before_metric(record, hint): sentry_init( _experiments={ - "enableMetrics": True, + "enable_metrics": True, "before_send_metric": _before_metric, }, ) From e36c40b2ecf26314c045ef3360e924e6a23da554 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 14:53:56 +0200 Subject: [PATCH 08/14] fix type hint --- 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 84ffb85272..ee5743fb99 100644 --- a/sentry_sdk/_metrics.py +++ b/sentry_sdk/_metrics.py @@ -24,7 +24,7 @@ def _capture_metric( client = sentry_sdk.get_client() - attrs = {} # type: dict[str, Union[str, bool, float, int] + attrs = {} # type: dict[str, Union[str, bool, float, int]] if attributes: for k, v in attributes.items(): attrs[k] = ( From 63df412eb7a8ec5a7911cd07ad57b02c186d84c7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 15:04:15 +0200 Subject: [PATCH 09/14] move more stuff to client --- sentry_sdk/_metrics.py | 20 ++------------------ sentry_sdk/_types.py | 2 +- sentry_sdk/client.py | 11 +++++++++++ 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/sentry_sdk/_metrics.py b/sentry_sdk/_metrics.py index ee5743fb99..3e0815f703 100644 --- a/sentry_sdk/_metrics.py +++ b/sentry_sdk/_metrics.py @@ -21,7 +21,6 @@ def _capture_metric( attributes=None, # type: Optional[dict[str, Any]] ): # type: (...) -> None - client = sentry_sdk.get_client() attrs = {} # type: dict[str, Union[str, bool, float, int]] @@ -38,25 +37,10 @@ def _capture_metric( else safe_repr(v) ) - span = sentry_sdk.get_current_span() - trace_id = "00000000-0000-0000-0000-000000000000" - span_id = None - - if span: - trace_context = span.get_trace_context() - trace_id = trace_context.get("trace_id", trace_id) - span_id = trace_context.get("span_id") - else: - scope = sentry_sdk.get_current_scope() - if scope: - propagation_context = scope._propagation_context - if propagation_context: - trace_id = propagation_context.trace_id or trace_id - metric = { "timestamp": time.time(), - "trace_id": trace_id, - "span_id": span_id, + "trace_id": None, + "span_id": None, "name": name, "type": metric_type, "value": float(value), diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 1dd7da6cd7..dfa92214ff 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -248,7 +248,7 @@ class SDKInfo(TypedDict): "Metric", { "timestamp": float, - "trace_id": str, + "trace_id": Optional[str], "span_id": Optional[str], "name": str, "type": MetricType, diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index d4ac19650c..d7272d0c87 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -976,6 +976,17 @@ def _capture_metric(self, metric): if release is not None and "sentry.release" not in metric["attributes"]: metric["attributes"]["sentry.release"] = release + span = sentry_sdk.get_current_span() + trace_id = "00000000-0000-0000-0000-000000000000" + + if span: + metric["trace_id"] = span.trace_id or trace_id + metric["span_id"] = span.span_id or None + else: + propagation_context = isolation_scope.get_active_propagation_context() + if propagation_context: + metric["trace_id"] = propagation_context.trace_id or trace_id + if isolation_scope._user is not None: for metric_attribute, user_attribute in ( ("user.id", "id"), From 92c8d418b79de90dc80ae3a5556db6aac2a09ce3 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 15:08:23 +0200 Subject: [PATCH 10/14] fix setting trace_id --- sentry_sdk/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index d7272d0c87..b196a19710 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -977,15 +977,15 @@ def _capture_metric(self, metric): metric["attributes"]["sentry.release"] = release span = sentry_sdk.get_current_span() - trace_id = "00000000-0000-0000-0000-000000000000" + metric["trace_id"] = "00000000-0000-0000-0000-000000000000" if span: - metric["trace_id"] = span.trace_id or trace_id - metric["span_id"] = span.span_id or None + metric["trace_id"] = span.trace_id + metric["span_id"] = span.span_id else: propagation_context = isolation_scope.get_active_propagation_context() if propagation_context: - metric["trace_id"] = propagation_context.trace_id or trace_id + metric["trace_id"] = propagation_context.trace_id if isolation_scope._user is not None: for metric_attribute, user_attribute in ( From 46f4691aeeadb2b7b8967e6e9301775745616789 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 15:11:34 +0200 Subject: [PATCH 11/14] propagation context test --- tests/test_metrics.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index ffa4799802..e344857737 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -137,7 +137,7 @@ def test_metrics_with_span(sentry_init, capture_envelopes): sentry_init(_experiments={"enable_metrics": True}, traces_sample_rate=1.0) envelopes = capture_envelopes() - with sentry_sdk.start_span(op="test", name="test-span"): + with sentry_sdk.start_transaction(op="test", name="test-span"): _metrics.count("test.span.counter", 1) get_client().flush() @@ -150,6 +150,22 @@ def test_metrics_with_span(sentry_init, capture_envelopes): assert metrics[0]["span_id"] is not None +def test_metrics_tracing_without_performance(sentry_init, capture_envelopes): + sentry_init(_experiments={"enable_metrics": True}) + envelopes = capture_envelopes() + + _metrics.count("test.span.counter", 1) + + get_client().flush() + + metrics = envelopes_to_metrics(envelopes) + assert len(metrics) == 1 + + assert metrics[0]["trace_id"] is not None + assert metrics[0]["trace_id"] != "00000000-0000-0000-0000-000000000000" + assert metrics[0]["span_id"] is not None + + def test_metrics_before_send(sentry_init, capture_envelopes): before_metric_called = False From d87ae308ad787acc17f2ee1a338965962c06bd22 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 15:12:51 +0200 Subject: [PATCH 12/14] . --- 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 e344857737..5e774227fd 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -163,7 +163,7 @@ def test_metrics_tracing_without_performance(sentry_init, capture_envelopes): assert metrics[0]["trace_id"] is not None assert metrics[0]["trace_id"] != "00000000-0000-0000-0000-000000000000" - assert metrics[0]["span_id"] is not None + assert metrics[0]["span_id"] is None def test_metrics_before_send(sentry_init, capture_envelopes): From 330101123a1de77ddd910d7d7242d7fdb7179d56 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 15:38:06 +0200 Subject: [PATCH 13/14] mypy --- sentry_sdk/_metrics.py | 6 +++--- sentry_sdk/_metrics_batcher.py | 2 +- sentry_sdk/_types.py | 2 +- sentry_sdk/client.py | 1 + sentry_sdk/utils.py | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/_metrics.py b/sentry_sdk/_metrics.py index 3e0815f703..03bde137bd 100644 --- a/sentry_sdk/_metrics.py +++ b/sentry_sdk/_metrics.py @@ -4,18 +4,18 @@ """ import time -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING, Union import sentry_sdk from sentry_sdk.utils import safe_repr if TYPE_CHECKING: - from sentry_sdk._types import Metric + from sentry_sdk._types import Metric, MetricType def _capture_metric( name, # type: str - metric_type, # type: str + metric_type, # type: MetricType value, # type: float unit=None, # type: Optional[str] attributes=None, # type: Optional[dict[str, Any]] diff --git a/sentry_sdk/_metrics_batcher.py b/sentry_sdk/_metrics_batcher.py index 8ca8a9db08..fd9a5d732b 100644 --- a/sentry_sdk/_metrics_batcher.py +++ b/sentry_sdk/_metrics_batcher.py @@ -2,7 +2,7 @@ import random import threading from datetime import datetime, timezone -from typing import Optional, List, Callable, TYPE_CHECKING, Any +from typing import Optional, List, Callable, TYPE_CHECKING, Any, Union from sentry_sdk.utils import format_timestamp, safe_repr from sentry_sdk.envelope import Envelope, Item, PayloadRef diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index dfa92214ff..66ed7df4f7 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -254,7 +254,7 @@ class SDKInfo(TypedDict): "type": MetricType, "value": float, "unit": Optional[str], - "attributes": dict[str, MetricAttributeValue], + "attributes": dict[str, str | bool | float | int], }, ) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index b196a19710..7d77a5bbb4 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -68,6 +68,7 @@ from sentry_sdk.spotlight import SpotlightClient from sentry_sdk.transport import Transport from sentry_sdk._log_batcher import LogBatcher + from sentry_sdk._metrics_batcher import MetricsBatcher I = TypeVar("I", bound=Integration) # noqa: E741 diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 7495a85899..cd825b29e2 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -59,7 +59,7 @@ from gevent.hub import Hub - from sentry_sdk._types import Event, ExcInfo, Log, Hint + from sentry_sdk._types import Event, ExcInfo, Log, Hint, Metric P = ParamSpec("P") R = TypeVar("R") From 16a60585f6f847de57f3af73f20c1010f393b059 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Oct 2025 15:42:27 +0200 Subject: [PATCH 14/14] better trace id check --- sentry_sdk/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 7d77a5bbb4..d17f922642 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -985,7 +985,7 @@ def _capture_metric(self, metric): metric["span_id"] = span.span_id else: propagation_context = isolation_scope.get_active_propagation_context() - if propagation_context: + if propagation_context and propagation_context.trace_id: metric["trace_id"] = propagation_context.trace_id if isolation_scope._user is not None: