From 270be5985f049d444fc161dd1fdfea674aaf647a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 10 Dec 2025 16:42:27 +0100 Subject: [PATCH 01/33] ref: Make logs, metrics go via scope --- sentry_sdk/_types.py | 25 +++-- sentry_sdk/client.py | 150 +++++------------------------ sentry_sdk/integrations/logging.py | 2 +- sentry_sdk/integrations/loguru.py | 2 +- sentry_sdk/logger.py | 6 +- sentry_sdk/metrics.py | 4 +- sentry_sdk/scope.py | 118 ++++++++++++++++++++++- 7 files changed, 163 insertions(+), 144 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 0426bf7a93..7c2f17a06a 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -222,13 +222,26 @@ class SDKInfo(TypedDict): # TODO: Make a proper type definition for this (PRs welcome!) Hint = Dict[str, Any] + AttributeValue = ( + str | bool | float | int | list[str] | list[bool] | list[float] | list[int] + ) + Attributes = dict[str, AttributeValue] + + SerializedAttributeValue = TypedDict( + "SerializedAttributeValue", + { + "type": Literal["string", "boolean", "double", "integer"], + "value": AttributeValue, + }, + ) + Log = TypedDict( "Log", { "severity_text": str, "severity_number": int, "body": str, - "attributes": dict[str, str | bool | float | int], + "attributes": Attributes, "time_unix_nano": int, "trace_id": Optional[str], }, @@ -236,14 +249,6 @@ class SDKInfo(TypedDict): MetricType = Literal["counter", "gauge", "distribution"] - MetricAttributeValue = TypedDict( - "MetricAttributeValue", - { - "value": Union[str, bool, float, int], - "type": Literal["string", "boolean", "double", "integer"], - }, - ) - Metric = TypedDict( "Metric", { @@ -254,7 +259,7 @@ class SDKInfo(TypedDict): "type": MetricType, "value": float, "unit": Optional[str], - "attributes": dict[str, str | bool | float | int], + "attributes": Attributes, }, ) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index ad682b1979..209d5cdd0d 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -932,137 +932,39 @@ def capture_event( return return_value - def _capture_log(self, log): - # type: (Optional[Log]) -> None - if not has_logs_enabled(self.options) or log is None: + def _capture_telemetry(self, telemetry, type_, scope): + # type: (Telemetry, str, Scope) -> None + # Capture attributes-based telemetry (logs, metrics, spansV2) + before_send_getter = { + "log": lambda: get_before_send_log(self.options), + "metric": lambda: get_before_send_metric(self.options), + }.get(type_) + + if before_send_getter is not None: + before_send = before_send_getter() + if before_send is not None: + telemetry = before_send(telemetry, {}) + + if telemetry is None: return - current_scope = sentry_sdk.get_current_scope() - isolation_scope = sentry_sdk.get_isolation_scope() - - log["attributes"]["sentry.sdk.name"] = SDK_INFO["name"] - log["attributes"]["sentry.sdk.version"] = SDK_INFO["version"] - - server_name = self.options.get("server_name") - if server_name is not None and SPANDATA.SERVER_ADDRESS not in log["attributes"]: - log["attributes"][SPANDATA.SERVER_ADDRESS] = server_name - - environment = self.options.get("environment") - if environment is not None and "sentry.environment" not in log["attributes"]: - log["attributes"]["sentry.environment"] = environment - - release = self.options.get("release") - if release is not None and "sentry.release" not in log["attributes"]: - log["attributes"]["sentry.release"] = release - - trace_context = current_scope.get_trace_context() - trace_id = trace_context.get("trace_id") - span_id = trace_context.get("span_id") - - if trace_id is not None and log.get("trace_id") is None: - log["trace_id"] = trace_id - - if ( - span_id is not None - and "sentry.trace.parent_span_id" not in log["attributes"] - ): - log["attributes"]["sentry.trace.parent_span_id"] = span_id - - # The user, if present, is always set on the isolation scope. - if isolation_scope._user is not None: - for log_attribute, user_attribute in ( - ("user.id", "id"), - ("user.name", "username"), - ("user.email", "email"), - ): - if ( - user_attribute in isolation_scope._user - and log_attribute not in log["attributes"] - ): - log["attributes"][log_attribute] = isolation_scope._user[ - user_attribute - ] - - # If debug is enabled, log the log to the console - debug = self.options.get("debug", False) - if debug: - logger.debug( - f"[Sentry Logs] [{log.get('severity_text')}] {log.get('body')}" - ) + scope.apply_to_telemetry(telemetry) - before_send_log = get_before_send_log(self.options) - if before_send_log is not None: - log = before_send_log(log, {}) + batcher = { + "log": self.log_batcher, + "metric": self.metrics_batcher, + }.get(type_) # type: Optional[LogBatcher, MetricsBatcher] - if log is None: - return + if batcher: + batcher.add(telemetry) - if self.log_batcher: - self.log_batcher.add(log) + def _capture_log(self, log, scope): + # type: (Optional[Log], Scope) -> None + self._capture_telemetry(log, "log", scope) - def _capture_metric(self, metric): + def _capture_metric(self, metric, scope): # 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"] - metric["attributes"]["sentry.sdk.version"] = SDK_INFO["version"] - - server_name = self.options.get("server_name") - if ( - server_name is not None - and SPANDATA.SERVER_ADDRESS not in metric["attributes"] - ): - metric["attributes"][SPANDATA.SERVER_ADDRESS] = server_name - - 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 - - trace_context = current_scope.get_trace_context() - trace_id = trace_context.get("trace_id") - span_id = trace_context.get("span_id") - - metric["trace_id"] = trace_id or "00000000-0000-0000-0000-000000000000" - if span_id is not None: - metric["span_id"] = span_id - - 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 Metrics] [{metric.get('type')}] {metric.get('name')}: {metric.get('value')}" - ) - - 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 - - if self.metrics_batcher: - self.metrics_batcher.add(metric) + self._capture_telemetry(metric, "metric", scope) def capture_session( self, diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index 9c68596be8..e40086c065 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -409,7 +409,7 @@ def _capture_log_from_record(self, client, record): attrs["logger.name"] = record.name # noinspection PyProtectedMember - client._capture_log( + sentry_sdk.get_current_scope()._capture_log( { "severity_text": otel_severity_text, "severity_number": otel_severity_number, diff --git a/sentry_sdk/integrations/loguru.py b/sentry_sdk/integrations/loguru.py index 96d2b6a7ae..193bc82e4a 100644 --- a/sentry_sdk/integrations/loguru.py +++ b/sentry_sdk/integrations/loguru.py @@ -201,7 +201,7 @@ def loguru_sentry_logs_handler(message): else: attrs[f"sentry.message.parameter.{key}"] = safe_repr(value) - client._capture_log( + sentry_sdk.get_current_scope()._capture_log( { "severity_text": otel_severity_text, "severity_number": otel_severity_number, diff --git a/sentry_sdk/logger.py b/sentry_sdk/logger.py index b90ac034bb..4903bf5a35 100644 --- a/sentry_sdk/logger.py +++ b/sentry_sdk/logger.py @@ -3,7 +3,7 @@ import time from typing import Any -from sentry_sdk import get_client +import sentry_sdk from sentry_sdk.utils import safe_repr, capture_internal_exceptions OTEL_RANGES = [ @@ -28,8 +28,6 @@ def __missing__(self, key): def _capture_log(severity_text, severity_number, template, **kwargs): # type: (str, int, str, **Any) -> None - client = get_client() - body = template attrs = {} # type: dict[str, str | bool | float | int] if "attributes" in kwargs: @@ -58,7 +56,7 @@ def _capture_log(severity_text, severity_number, template, **kwargs): } # noinspection PyProtectedMember - client._capture_log( + sentry_sdk.get_current_scope()._capture_log( { "severity_text": severity_text, "severity_number": severity_number, diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 03bde137bd..63c681264f 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -21,8 +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]] if attributes: for k, v in attributes.items(): @@ -48,7 +46,7 @@ def _capture_metric( "attributes": attrs, } # type: Metric - client._capture_metric(metric) + sentry_sdk.get_current_scope()._capture_metric(metric) def count( diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 466e1b5b12..a8cb1af790 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -11,7 +11,12 @@ from sentry_sdk._types import AnnotatedValue from sentry_sdk.attachments import Attachment -from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, FALSE_VALUES, INSTRUMENTER +from sentry_sdk.consts import ( + DEFAULT_MAX_BREADCRUMBS, + FALSE_VALUES, + INSTRUMENTER, + SPANDATA, +) from sentry_sdk.feature_flags import FlagBuffer, DEFAULT_FLAG_CAPACITY from sentry_sdk.profiler.continuous_profiler import ( get_profiler_id, @@ -42,6 +47,8 @@ event_from_exception, exc_info_from_error, logger, + has_logs_enabled, + has_metrics_enabled, ) import typing @@ -1239,6 +1246,57 @@ def capture_event(self, event, hint=None, scope=None, **scope_kwargs): return event_id + def _capture_log(self, log, scope=None, **scope_kwargs): + # type: (Optional[Log], Optional[Scope], Any) -> None + if log is None: + return + + client = self.get_client() + if not has_logs_enabled(client.options) or log is None: + return + + scope = self._merge_scopes(scope, scope_kwargs) + + trace_context = scope.get_trace_context() + trace_id = trace_context.get("trace_id") + if trace_id is not None and log.get("trace_id") is None: + log["trace_id"] = trace_id + + # If debug is enabled, log the log to the console + debug = client.options.get("debug", False) + if debug: + logger.debug( + f"[Sentry Logs] [{log.get('severity_text')}] {log.get('body')}" + ) + + client._capture_log(log, scope=scope) + + def _capture_metric(self, metric, scope=None, **scope_kwargs): + # type: (Optional[Metric], Optional[Scope], Any) -> None + if metric is None: + return + + client = self.get_client() + if not has_metrics_enabled(client.options): + return + + scope = self._merge_scopes(scope, scope_kwargs) + + trace_context = scope.get_trace_context() + trace_id = trace_context.get("trace_id") + span_id = trace_context.get("span_id") + metric["trace_id"] = trace_id or "00000000-0000-0000-0000-000000000000" + if span_id is not None: + metric["span_id"] = span_id + + debug = client.options.get("debug", False) + if debug: + logger.debug( + f"[Sentry Metrics] [{metric.get('type')}] {metric.get('name')}: {metric.get('value')}" + ) + + client._capture_metric(metric, scope=scope) + def capture_message(self, message, level=None, scope=None, **scope_kwargs): # type: (str, Optional[LogLevelStr], Optional[Scope], Any) -> Optional[str] """ @@ -1470,6 +1528,54 @@ def _apply_flags_to_event(self, event, hint, options): {"values": flags} ) + def _apply_global_attributes_to_telemetry(self, telemetry, options): + # TODO: Global stuff like this should just be retrieved at init time and + # put onto the global scope's attributes + # TODO: These attrs will actually be saved on and retrieved from + # the global scope directly in a later step instead of constructing + # them anew + from sentry_sdk.client import SDK_INFO + + attributes = telemetry["attributes"] + + attributes["sentry.sdk.name"] = SDK_INFO["name"] + attributes["sentry.sdk.version"] = SDK_INFO["version"] + + server_name = options.get("server_name") + if server_name is not None: + attributes[SPANDATA.SERVER_ADDRESS] = server_name + + environment = options.get("environment") + if environment is not None: + attributes["sentry.environment"] = environment + + release = options.get("release") + if release is not None: + attributes["sentry.release"] = release + + def _apply_tracing_attributes_to_telemetry(self, telemetry): + attributes = telemetry["attributes"] + + trace_context = self.get_trace_context() + span_id = trace_context.get("span_id") + + if span_id is not None and "sentry.trace_parent_span_id" not in attributes: + attributes["sentry.trace.parent_span_id"] = span_id + + def _apply_user_attributes_to_telemetry(self, telemetry): + attributes = telemetry["attributes"] + + if self._user is None: + return + + for attribute_name, user_attribute in ( + ("user.id", "id"), + ("user.name", "username"), + ("user.email", "email"), + ): + if user_attribute in self._user and attribute_name not in attributes: + attributes[attribute_name] = self._user[user_attribute] + def _drop(self, cause, ty): # type: (Any, str) -> Optional[Any] logger.info("%s (%s) dropped event", ty, cause) @@ -1580,6 +1686,16 @@ def apply_to_event( return event + @_disable_capture + def apply_to_telemetry(self, telemetry): + # Attributes-based events and telemetry go through here (logs, metrics, + # spansV2) + options = self.get_client().options + + self._apply_global_attributes_to_telemetry(telemetry, options) + self._apply_tracing_attributes_to_telemetry(telemetry) + self._apply_user_attributes_to_telemetry(telemetry) + def update_from_scope(self, scope): # type: (Scope) -> None """Update the scope with another scope's data.""" From 329ea2c1f61946684722df155c3182c860a6004c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 16 Dec 2025 14:51:01 +0100 Subject: [PATCH 02/33] typing fixes --- sentry_sdk/_types.py | 3 ++- sentry_sdk/client.py | 12 +++++++----- sentry_sdk/metrics.py | 2 +- sentry_sdk/scope.py | 15 ++++++++++----- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 323db1fbcc..31a57e04f6 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -216,7 +216,8 @@ class SDKInfo(TypedDict): Hint = Dict[str, Any] AttributeValue = ( - str | bool | float | int | list[str] | list[bool] | list[float] | list[int] + str | bool | float | int + # TODO: relay support coming soon for: list[str] | list[bool] | list[float] | list[int] ) Attributes = dict[str, AttributeValue] diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index bf6b4cb2ce..471a5b8059 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -68,7 +68,6 @@ SDKInfo, Log, Metric, - Telemetry, EventDataCategory, ) from sentry_sdk.integrations import Integration @@ -225,10 +224,10 @@ def is_active(self) -> bool: def capture_event(self, *args: "Any", **kwargs: "Any") -> "Optional[str]": return None - def _capture_log(self, log: "Log") -> None: + def _capture_log(self, log: "Log", scope: "Scope") -> None: pass - def _capture_metric(self, metric: "Metric") -> None: + def _capture_metric(self, metric: "Metric", scope: "Scope") -> None: pass def capture_session(self, *args: "Any", **kwargs: "Any") -> None: @@ -907,9 +906,12 @@ def capture_event( return return_value def _capture_telemetry( - self, telemetry: "Telemetry", ty: str, scope: "Scope" + self, telemetry: "Optional[Union[Log, Metric]]", ty: str, scope: "Scope" ) -> None: # Capture attributes-based telemetry (logs, metrics, spansV2) + if telemetry is None: + return + before_send_getter = { "log": lambda: get_before_send_log(self.options), "metric": lambda: get_before_send_metric(self.options), @@ -925,7 +927,7 @@ def _capture_telemetry( scope.apply_to_telemetry(telemetry) - batcher: "Optional[LogBatcher, MetricsBatcher]" = { + batcher: "Optional[Union[LogBatcher, MetricsBatcher]]" = { "log": self.log_batcher, "metric": self.metrics_batcher, }.get(ty) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 24841c1dbd..e7f7a3bea7 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -18,7 +18,7 @@ def _capture_metric( metric_type: "MetricType", value: float, unit: "Optional[str]" = None, - attributes: "Attributes" = None, + attributes: "Optional[Attributes]" = None, ) -> None: attrs: "Attributes" = {} diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 0e9053a72b..194dc69943 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -84,7 +84,6 @@ LogLevelStr, Metric, SamplingContext, - Telemetry, Type, ) @@ -1487,7 +1486,9 @@ def _apply_flags_to_event( {"values": flags} ) - def _apply_global_attributes_to_telemetry(self, telemetry, options): + def _apply_global_attributes_to_telemetry( + self, telemetry: "Union[Log, Metric]", options: "dict[str, Any]" + ) -> None: # TODO: Global stuff like this should just be retrieved at init time and # put onto the global scope's attributes and then applied to events # from there @@ -1510,7 +1511,9 @@ def _apply_global_attributes_to_telemetry(self, telemetry, options): if release is not None: attributes["sentry.release"] = release - def _apply_tracing_attributes_to_telemetry(self, telemetry): + def _apply_tracing_attributes_to_telemetry( + self, telemetry: "Union[Log, Metric]" + ) -> None: attributes = telemetry["attributes"] trace_context = self.get_trace_context() @@ -1519,7 +1522,9 @@ def _apply_tracing_attributes_to_telemetry(self, telemetry): if span_id is not None and "sentry.trace_parent_span_id" not in attributes: attributes["sentry.trace.parent_span_id"] = span_id - def _apply_user_attributes_to_telemetry(self, telemetry): + def _apply_user_attributes_to_telemetry( + self, telemetry: "Union[Log, Metric]" + ) -> None: attributes = telemetry["attributes"] if not should_send_default_pii() or self._user is None: @@ -1640,7 +1645,7 @@ def apply_to_event( return event @_disable_capture - def apply_to_telemetry(self, telemetry: "Telemetry") -> None: + def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None: # Attributes-based events and telemetry go through here (logs, metrics, # spansV2) options = self.get_client().options From 6c1897a99a912d2dc97ca9952a289d77db7c74f0 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 16 Dec 2025 14:57:28 +0100 Subject: [PATCH 03/33] giving up on typing dispatches --- sentry_sdk/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 471a5b8059..ef8a21fbe8 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -920,7 +920,7 @@ def _capture_telemetry( if before_send_getter is not None: before_send = before_send_getter() if before_send is not None: - telemetry = before_send(telemetry, {}) + telemetry = before_send(telemetry, {}) # type: ignore[arg-type] if telemetry is None: return @@ -933,7 +933,7 @@ def _capture_telemetry( }.get(ty) if batcher: - batcher.add(telemetry) + batcher.add(telemetry) # type: ignore[arg-type] def _capture_log(self, log: "Optional[Log]", scope: "Scope") -> None: self._capture_telemetry(log, "log", scope) From 8ffc78ad805134d9cc63caa37b12db6ef244e3d7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 16 Dec 2025 15:01:19 +0100 Subject: [PATCH 04/33] span_id is not an attribute anymore --- sentry_sdk/scope.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 194dc69943..6620afccd1 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1511,17 +1511,6 @@ def _apply_global_attributes_to_telemetry( if release is not None: attributes["sentry.release"] = release - def _apply_tracing_attributes_to_telemetry( - self, telemetry: "Union[Log, Metric]" - ) -> None: - attributes = telemetry["attributes"] - - trace_context = self.get_trace_context() - span_id = trace_context.get("span_id") - - if span_id is not None and "sentry.trace_parent_span_id" not in attributes: - attributes["sentry.trace.parent_span_id"] = span_id - def _apply_user_attributes_to_telemetry( self, telemetry: "Union[Log, Metric]" ) -> None: @@ -1650,8 +1639,15 @@ def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None: # spansV2) options = self.get_client().options + trace_context = self.get_trace_context() + trace_id = trace_context.get("span_id") + span_id = trace_context.get("span_id") + if telemetry.get("trace_id") is None: + telemetry["trace_id"] = trace_id + if telemetry.get("span_id") is None: + telemetry["span_id"] = span_id + self._apply_global_attributes_to_telemetry(telemetry, options) - self._apply_tracing_attributes_to_telemetry(telemetry) self._apply_user_attributes_to_telemetry(telemetry) def update_from_scope(self, scope: "Scope") -> None: From 2405282583f8539adc4054cfb74e40443fb7c84d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 16 Dec 2025 15:04:15 +0100 Subject: [PATCH 05/33] move format_attributes to utils --- sentry_sdk/_log_batcher.py | 13 +------------ sentry_sdk/_metrics_batcher.py | 13 +------------ sentry_sdk/utils.py | 22 +++++++++++++++++++++- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/sentry_sdk/_log_batcher.py b/sentry_sdk/_log_batcher.py index aee9b1db6f..d8e1590523 100644 --- a/sentry_sdk/_log_batcher.py +++ b/sentry_sdk/_log_batcher.py @@ -4,7 +4,7 @@ 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.utils import format_attribute, format_timestamp, safe_repr from sentry_sdk.envelope import Envelope, Item, PayloadRef if TYPE_CHECKING: @@ -115,17 +115,6 @@ def flush(self) -> None: @staticmethod def _log_to_transport_format(log: "Log") -> "Any": - def format_attribute(val: "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"} - if "sentry.severity_number" not in log["attributes"]: log["attributes"]["sentry.severity_number"] = log["severity_number"] if "sentry.severity_text" not in log["attributes"]: diff --git a/sentry_sdk/_metrics_batcher.py b/sentry_sdk/_metrics_batcher.py index 3bac514ed2..36526e8aa9 100644 --- a/sentry_sdk/_metrics_batcher.py +++ b/sentry_sdk/_metrics_batcher.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from typing import Optional, List, Callable, TYPE_CHECKING, Any, Union -from sentry_sdk.utils import format_timestamp, safe_repr +from sentry_sdk.utils import format_attribute, format_timestamp, safe_repr from sentry_sdk.envelope import Envelope, Item, PayloadRef if TYPE_CHECKING: @@ -96,17 +96,6 @@ def flush(self) -> None: @staticmethod def _metric_to_transport_format(metric: "Metric") -> "Any": - def format_attribute(val: "Union[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"], diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 551504fa8a..8f2d01bdce 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -59,7 +59,15 @@ from gevent.hub import Hub - from sentry_sdk._types import Event, ExcInfo, Hint, Log, Metric + from sentry_sdk._types import ( + AttributeValue, + SerializedAttributeValue, + Event, + ExcInfo, + Hint, + Log, + Metric, + ) P = ParamSpec("P") R = TypeVar("R") @@ -2036,3 +2044,15 @@ def get_before_send_metric( return options.get("before_send_metric") or options["_experiments"].get( "before_send_metric" ) + + +def format_attribute(val: "AttributeValue") -> "SerializedAttributeValue": + 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"} From a0a603bae572404e4acd4389b6808b1bb2a34229 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 16 Dec 2025 15:19:37 +0100 Subject: [PATCH 06/33] attr list values --- sentry_sdk/_types.py | 14 +++++++++++--- sentry_sdk/utils.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 31a57e04f6..4115ee2723 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -216,15 +216,23 @@ class SDKInfo(TypedDict): Hint = Dict[str, Any] AttributeValue = ( - str | bool | float | int - # TODO: relay support coming soon for: list[str] | list[bool] | list[float] | list[int] + str | bool | float | int | list[str] | list[bool] | list[float] | list[int] ) Attributes = dict[str, AttributeValue] SerializedAttributeValue = TypedDict( "SerializedAttributeValue", { - "type": Literal["string", "boolean", "double", "integer"], + "type": Literal[ + "string", + "boolean", + "double", + "integer", + "string[]", + "boolean[]", + "double[]", + "integer[]", + ], "value": AttributeValue, }, ) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 8f2d01bdce..8635f607fb 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2055,4 +2055,21 @@ def format_attribute(val: "AttributeValue") -> "SerializedAttributeValue": return {"value": val, "type": "double"} if isinstance(val, str): return {"value": val, "type": "string"} + + if isinstance(val, list): + if not val: + return {"value": val, "type": str} + + # Only lists of elements of a single type are supported + list_types = { + str: "string[]", + int: "integer[]", + float: "double[]", + bool: "boolean[]", + } + + ty = type(val[0]) + if ty in list_types and all(isinstance(v, ty) for v in val): + return {"value": val, "type": list_types[ty]} + return {"value": safe_repr(val), "type": "string"} From a2fee7c4f9cf61e2301ce7450cfacb7255a70977 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 16 Dec 2025 15:27:30 +0100 Subject: [PATCH 07/33] . --- sentry_sdk/logger.py | 26 ++++++++------------------ sentry_sdk/utils.py | 6 ++++-- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/sentry_sdk/logger.py b/sentry_sdk/logger.py index 4bf7d5c4bd..5187283ce9 100644 --- a/sentry_sdk/logger.py +++ b/sentry_sdk/logger.py @@ -1,11 +1,15 @@ # NOTE: this is the logger sentry exposes to users, not some generic logger. import functools import time -from typing import Any +from typing import Any, TYPE_CHECKING import sentry_sdk from sentry_sdk.utils import safe_repr, capture_internal_exceptions +if TYPE_CHECKING: + from sentry_sdk._types import Attributes, Log + + OTEL_RANGES = [ # ((severity level range), severity text) # https://opentelemetry.io/docs/specs/otel/logs/data-model @@ -29,7 +33,8 @@ def _capture_log( severity_text: str, severity_number: int, template: str, **kwargs: "Any" ) -> None: body = template - attrs: "dict[str, str | bool | float | int]" = {} + + attrs: "Attributes" = {} if "attributes" in kwargs: attrs.update(kwargs.pop("attributes")) for k, v in kwargs.items(): @@ -41,21 +46,6 @@ def _capture_log( with capture_internal_exceptions(): body = template.format_map(_dict_default_key(kwargs)) - attrs = { - k: ( - v - if ( - isinstance(v, str) - or isinstance(v, int) - or isinstance(v, bool) - or isinstance(v, float) - ) - else safe_repr(v) - ) - for (k, v) in attrs.items() - } - - # noinspection PyProtectedMember sentry_sdk.get_current_scope()._capture_log( { "severity_text": severity_text, @@ -65,7 +55,7 @@ def _capture_log( "time_unix_nano": time.time_ns(), "trace_id": None, "span_id": None, - }, + } ) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 8635f607fb..0840a04356 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -45,6 +45,7 @@ Dict, Iterator, List, + Literal, NoReturn, Optional, ParamSpec, @@ -2058,10 +2059,10 @@ def format_attribute(val: "AttributeValue") -> "SerializedAttributeValue": if isinstance(val, list): if not val: - return {"value": val, "type": str} + return {"value": val, "type": "string[]"} # Only lists of elements of a single type are supported - list_types = { + list_types: 'dict[type, Literal["string[]", "integer[]", "double[]", "boolean[]"]]' = { str: "string[]", int: "integer[]", float: "double[]", @@ -2072,4 +2073,5 @@ def format_attribute(val: "AttributeValue") -> "SerializedAttributeValue": if ty in list_types and all(isinstance(v, ty) for v in val): return {"value": val, "type": list_types[ty]} + # Coerce to string if we don't know what to do with the value return {"value": safe_repr(val), "type": "string"} From 87537f05e48abdc5530b4f933e0b0baa8a596126 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 16 Dec 2025 16:49:08 +0100 Subject: [PATCH 08/33] add link --- sentry_sdk/_types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 4115ee2723..daeb87a6ab 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -221,6 +221,7 @@ class SDKInfo(TypedDict): Attributes = dict[str, AttributeValue] SerializedAttributeValue = TypedDict( + # https://develop.sentry.dev/sdk/telemetry/attributes/#supported-types "SerializedAttributeValue", { "type": Literal[ From 8c3283316b6b8eed495c4377a1348ab0525fca79 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 16 Dec 2025 16:53:33 +0100 Subject: [PATCH 09/33] remove custom trace_id, span_id setting --- sentry_sdk/scope.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 6620afccd1..cd7db829ce 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1196,14 +1196,6 @@ def _capture_log( scope = self._merge_scopes(scope, scope_kwargs) - trace_context = scope.get_trace_context() - trace_id = trace_context.get("trace_id") - span_id = trace_context.get("span_id") - if trace_id is not None and log.get("trace_id") is None: - log["trace_id"] = trace_id - if span_id is not None and log.get("span_id") is None: - log["span_id"] = span_id - # If debug is enabled, log the log to the console debug = client.options.get("debug", False) if debug: @@ -1228,13 +1220,6 @@ def _capture_metric( scope = self._merge_scopes(scope, scope_kwargs) - trace_context = scope.get_trace_context() - trace_id = trace_context.get("trace_id") - span_id = trace_context.get("span_id") - metric["trace_id"] = trace_id or "00000000-0000-0000-0000-000000000000" - if span_id is not None: - metric["span_id"] = span_id - debug = client.options.get("debug", False) if debug: logger.debug( From 3747c5f24e34aec7be6df8bd15fa49c280cf93cb Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 17 Dec 2025 09:14:53 +0100 Subject: [PATCH 10/33] rename, fix --- sentry_sdk/_log_batcher.py | 4 ++-- sentry_sdk/_metrics_batcher.py | 4 ++-- sentry_sdk/scope.py | 8 ++++---- sentry_sdk/utils.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/_log_batcher.py b/sentry_sdk/_log_batcher.py index d8e1590523..51886f48f9 100644 --- a/sentry_sdk/_log_batcher.py +++ b/sentry_sdk/_log_batcher.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from typing import Optional, List, Callable, TYPE_CHECKING, Any -from sentry_sdk.utils import format_attribute, format_timestamp, safe_repr +from sentry_sdk.utils import format_timestamp, safe_repr, serialize_attribute from sentry_sdk.envelope import Envelope, Item, PayloadRef if TYPE_CHECKING: @@ -127,7 +127,7 @@ def _log_to_transport_format(log: "Log") -> "Any": "level": str(log["severity_text"]), "body": str(log["body"]), "attributes": { - k: format_attribute(v) for (k, v) in log["attributes"].items() + k: serialize_attribute(v) for (k, v) in log["attributes"].items() }, } diff --git a/sentry_sdk/_metrics_batcher.py b/sentry_sdk/_metrics_batcher.py index 36526e8aa9..6cbac0cbce 100644 --- a/sentry_sdk/_metrics_batcher.py +++ b/sentry_sdk/_metrics_batcher.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from typing import Optional, List, Callable, TYPE_CHECKING, Any, Union -from sentry_sdk.utils import format_attribute, format_timestamp, safe_repr +from sentry_sdk.utils import format_timestamp, safe_repr, serialize_attribute from sentry_sdk.envelope import Envelope, Item, PayloadRef if TYPE_CHECKING: @@ -103,7 +103,7 @@ def _metric_to_transport_format(metric: "Metric") -> "Any": "type": metric["type"], "value": metric["value"], "attributes": { - k: format_attribute(v) for (k, v) in metric["attributes"].items() + k: serialize_attribute(v) for (k, v) in metric["attributes"].items() }, } diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index cd7db829ce..fcaae9a7f3 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1625,11 +1625,11 @@ def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None: options = self.get_client().options trace_context = self.get_trace_context() - trace_id = trace_context.get("span_id") - span_id = trace_context.get("span_id") + trace_id = trace_context.get("trace_id") if telemetry.get("trace_id") is None: - telemetry["trace_id"] = trace_id - if telemetry.get("span_id") is None: + telemetry["trace_id"] = trace_id or "00000000-0000-0000-0000-000000000000" + span_id = trace_context.get("span_id") + if telemetry.get("span_id") is None and span_id: telemetry["span_id"] = span_id self._apply_global_attributes_to_telemetry(telemetry, options) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 0840a04356..78da303b29 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2047,7 +2047,7 @@ def get_before_send_metric( ) -def format_attribute(val: "AttributeValue") -> "SerializedAttributeValue": +def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue": if isinstance(val, bool): return {"value": val, "type": "boolean"} if isinstance(val, int): From 4dc5dd876c0741adf639340c4dbabd76fa918a75 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 17 Dec 2025 09:26:05 +0100 Subject: [PATCH 11/33] . --- sentry_sdk/client.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index ef8a21fbe8..08161eac6b 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -62,14 +62,7 @@ from typing import Union from typing import TypeVar - from sentry_sdk._types import ( - Event, - Hint, - SDKInfo, - Log, - Metric, - EventDataCategory, - ) + from sentry_sdk._types import Event, Hint, SDKInfo, Log, Metric, EventDataCategory from sentry_sdk.integrations import Integration from sentry_sdk.scope import Scope from sentry_sdk.session import Session From 6acb51007a496cbad17ec37b01773a45d4ee58ab Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 17 Dec 2025 09:29:21 +0100 Subject: [PATCH 12/33] simplify --- sentry_sdk/scope.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index fcaae9a7f3..c6747745c6 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1181,12 +1181,7 @@ def capture_event( return event_id - def _capture_log( - self, - log: "Optional[Log]", - scope: "Optional[Scope]" = None, - **scope_kwargs: "Any", - ) -> None: + def _capture_log(self, log: "Optional[Log]") -> None: if log is None: return @@ -1194,7 +1189,7 @@ def _capture_log( if not has_logs_enabled(client.options) or log is None: return - scope = self._merge_scopes(scope, scope_kwargs) + scope = self._merge_scopes() # If debug is enabled, log the log to the console debug = client.options.get("debug", False) @@ -1205,12 +1200,7 @@ def _capture_log( client._capture_log(log, scope=scope) - def _capture_metric( - self, - metric: "Optional[Metric]", - scope: "Optional[Scope]" = None, - **scope_kwargs: "Any", - ) -> None: + def _capture_metric(self, metric: "Optional[Metric]") -> None: if metric is None: return @@ -1218,7 +1208,7 @@ def _capture_metric( if not has_metrics_enabled(client.options): return - scope = self._merge_scopes(scope, scope_kwargs) + scope = self._merge_scopes() debug = client.options.get("debug", False) if debug: From 2bf0f3a27d0f3c5faa8b1c26697e3f41bb6563c1 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 17 Dec 2025 09:32:40 +0100 Subject: [PATCH 13/33] . --- sentry_sdk/scope.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index c6747745c6..0eb3b9505e 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1186,19 +1186,18 @@ def _capture_log(self, log: "Optional[Log]") -> None: return client = self.get_client() - if not has_logs_enabled(client.options) or log is None: + if not has_logs_enabled(client.options): return - scope = self._merge_scopes() + merged_scope = self._merge_scopes() - # If debug is enabled, log the log to the console debug = client.options.get("debug", False) if debug: logger.debug( f"[Sentry Logs] [{log.get('severity_text')}] {log.get('body')}" ) - client._capture_log(log, scope=scope) + client._capture_log(log, scope=merged_scope) def _capture_metric(self, metric: "Optional[Metric]") -> None: if metric is None: @@ -1208,7 +1207,7 @@ def _capture_metric(self, metric: "Optional[Metric]") -> None: if not has_metrics_enabled(client.options): return - scope = self._merge_scopes() + merged_scope = self._merge_scopes() debug = client.options.get("debug", False) if debug: @@ -1216,7 +1215,7 @@ def _capture_metric(self, metric: "Optional[Metric]") -> None: f"[Sentry Metrics] [{metric.get('type')}] {metric.get('name')}: {metric.get('value')}" ) - client._capture_metric(metric, scope=scope) + client._capture_metric(metric, scope=merged_scope) def capture_message( self, From c83d76c64c3963c6f7c6954da30ef1789142eb73 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 17 Dec 2025 09:59:31 +0100 Subject: [PATCH 14/33] . --- sentry_sdk/scope.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 0eb3b9505e..d4bb99fb57 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1474,15 +1474,15 @@ def _apply_global_attributes_to_telemetry( attributes["sentry.sdk.version"] = SDK_INFO["version"] server_name = options.get("server_name") - if server_name is not None: + if server_name is not None and SPANDATA.SERVER_ADDRESS not in attributes: attributes[SPANDATA.SERVER_ADDRESS] = server_name environment = options.get("environment") - if environment is not None: + if environment is not None and "sentry.environment" not in attributes: attributes["sentry.environment"] = environment release = options.get("release") - if release is not None: + if release is not None and "sentry.release" not in attributes: attributes["sentry.release"] = release def _apply_user_attributes_to_telemetry( From a62d90b897a3683803750b03c396af9fe642107d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 17 Dec 2025 10:42:24 +0100 Subject: [PATCH 15/33] first attrs, then before_send --- sentry_sdk/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 08161eac6b..bb9dd0b117 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -905,6 +905,8 @@ def _capture_telemetry( if telemetry is None: return + scope.apply_to_telemetry(telemetry) + before_send_getter = { "log": lambda: get_before_send_log(self.options), "metric": lambda: get_before_send_metric(self.options), @@ -918,8 +920,6 @@ def _capture_telemetry( if telemetry is None: return - scope.apply_to_telemetry(telemetry) - batcher: "Optional[Union[LogBatcher, MetricsBatcher]]" = { "log": self.log_batcher, "metric": self.metrics_batcher, From 649c3add024996018c211671ec06c32862c03c01 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 17 Dec 2025 10:47:06 +0100 Subject: [PATCH 16/33] dont pass opts around --- sentry_sdk/scope.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index d4bb99fb57..a933159919 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1461,7 +1461,7 @@ def _apply_flags_to_event( ) def _apply_global_attributes_to_telemetry( - self, telemetry: "Union[Log, Metric]", options: "dict[str, Any]" + self, telemetry: "Union[Log, Metric]" ) -> None: # TODO: Global stuff like this should just be retrieved at init time and # put onto the global scope's attributes and then applied to events @@ -1473,6 +1473,8 @@ def _apply_global_attributes_to_telemetry( attributes["sentry.sdk.name"] = SDK_INFO["name"] attributes["sentry.sdk.version"] = SDK_INFO["version"] + options = self.get_client().options + server_name = options.get("server_name") if server_name is not None and SPANDATA.SERVER_ADDRESS not in attributes: attributes[SPANDATA.SERVER_ADDRESS] = server_name @@ -1611,8 +1613,6 @@ def apply_to_event( def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None: # Attributes-based events and telemetry go through here (logs, metrics, # spansV2) - options = self.get_client().options - trace_context = self.get_trace_context() trace_id = trace_context.get("trace_id") if telemetry.get("trace_id") is None: @@ -1621,7 +1621,7 @@ def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None: if telemetry.get("span_id") is None and span_id: telemetry["span_id"] = span_id - self._apply_global_attributes_to_telemetry(telemetry, options) + self._apply_global_attributes_to_telemetry(telemetry) self._apply_user_attributes_to_telemetry(telemetry) def update_from_scope(self, scope: "Scope") -> None: From 3fffa7fc502f805a526f6bcee6e3a312e5422f0e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 08:20:31 +0100 Subject: [PATCH 17/33] simplify dispatcher --- sentry_sdk/client.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index bb9dd0b117..259196d1c6 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -907,26 +907,26 @@ def _capture_telemetry( scope.apply_to_telemetry(telemetry) - before_send_getter = { - "log": lambda: get_before_send_log(self.options), - "metric": lambda: get_before_send_metric(self.options), - }.get(ty) + before_send = None + if ty == "log": + before_send = get_before_send_log(self.options) + elif ty == "metric": + before_send = get_before_send_metric(self.options) # type: ignore - if before_send_getter is not None: - before_send = before_send_getter() - if before_send is not None: - telemetry = before_send(telemetry, {}) # type: ignore[arg-type] + if before_send is not None: + telemetry = before_send(telemetry, {}) # type: ignore if telemetry is None: return - batcher: "Optional[Union[LogBatcher, MetricsBatcher]]" = { - "log": self.log_batcher, - "metric": self.metrics_batcher, - }.get(ty) + batcher = None + if ty == "log": + batcher = self.log_batcher + elif ty == "metric": + batcher = self.metrics_batcher # type: ignore - if batcher: - batcher.add(telemetry) # type: ignore[arg-type] + if batcher is not None: + batcher.add(telemetry) # type: ignore def _capture_log(self, log: "Optional[Log]", scope: "Scope") -> None: self._capture_telemetry(log, "log", scope) From 7ccbd5a89a01539e1603c19f5cdb1886647c5954 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 09:43:09 +0100 Subject: [PATCH 18/33] no support for array attributes yet --- sentry_sdk/_types.py | 13 ++++++++----- sentry_sdk/utils.py | 16 ---------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index daeb87a6ab..5a8cb936ac 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -216,7 +216,9 @@ class SDKInfo(TypedDict): Hint = Dict[str, Any] AttributeValue = ( - str | bool | float | int | list[str] | list[bool] | list[float] | list[int] + str | bool | float | int + # TODO: relay support coming soon for + # | list[str] | list[bool] | list[float] | list[int] ) Attributes = dict[str, AttributeValue] @@ -229,10 +231,11 @@ class SDKInfo(TypedDict): "boolean", "double", "integer", - "string[]", - "boolean[]", - "double[]", - "integer[]", + # TODO: relay support coming soon for: + # "string[]", + # "boolean[]", + # "double[]", + # "integer[]", ], "value": AttributeValue, }, diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 78da303b29..4965a13c0a 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2057,21 +2057,5 @@ def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue": if isinstance(val, str): return {"value": val, "type": "string"} - if isinstance(val, list): - if not val: - return {"value": val, "type": "string[]"} - - # Only lists of elements of a single type are supported - list_types: 'dict[type, Literal["string[]", "integer[]", "double[]", "boolean[]"]]' = { - str: "string[]", - int: "integer[]", - float: "double[]", - bool: "boolean[]", - } - - ty = type(val[0]) - if ty in list_types and all(isinstance(v, ty) for v in val): - return {"value": val, "type": list_types[ty]} - # Coerce to string if we don't know what to do with the value return {"value": safe_repr(val), "type": "string"} From 3814f3be4514e28589e5bed77197a0ebbcd9a18d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 10:57:23 +0100 Subject: [PATCH 19/33] feat: Add scope.set_attribute --- sentry_sdk/scope.py | 31 ++++++++++++++++++++++++++++ sentry_sdk/utils.py | 21 ++++++++++++++++++- tests/test_logs.py | 47 +++++++++++++++++++++++++++++++++++++++++++ tests/test_metrics.py | 45 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index a933159919..9bf08aa1a1 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -46,6 +46,7 @@ disable_capture_event, event_from_exception, exc_info_from_error, + format_attribute, logger, has_logs_enabled, has_metrics_enabled, @@ -73,6 +74,8 @@ from typing_extensions import Unpack from sentry_sdk._types import ( + Attributes, + AttributeValue, Breadcrumb, BreadcrumbHint, ErrorProcessor, @@ -230,6 +233,7 @@ class Scope: "_type", "_last_event_id", "_flags", + "_attributes", ) def __init__( @@ -296,6 +300,8 @@ def __copy__(self) -> "Scope": rv._flags = deepcopy(self._flags) + rv._attributes = self._attributes.copy() + return rv @classmethod @@ -684,6 +690,8 @@ def clear(self) -> None: self._last_event_id: "Optional[str]" = None self._flags: "Optional[FlagBuffer]" = None + self._attributes: "Attributes" = {} + @_attr_setter def level(self, value: "LogLevelStr") -> None: """ @@ -1487,6 +1495,14 @@ def _apply_global_attributes_to_telemetry( if release is not None and "sentry.release" not in attributes: attributes["sentry.release"] = release + def _apply_scope_attributes_to_telemetry( + self, telemetry: "Union[Log, Metric]" + ) -> None: + attributes = telemetry["attributes"] + + for attribute, value in self._attributes.items(): + attributes[attribute] = value + def _apply_user_attributes_to_telemetry( self, telemetry: "Union[Log, Metric]" ) -> None: @@ -1622,6 +1638,7 @@ def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None: telemetry["span_id"] = span_id self._apply_global_attributes_to_telemetry(telemetry) + self._apply_scope_attributes_to_telemetry(telemetry) self._apply_user_attributes_to_telemetry(telemetry) def update_from_scope(self, scope: "Scope") -> None: @@ -1668,6 +1685,8 @@ def update_from_scope(self, scope: "Scope") -> None: else: for flag in scope._flags.get(): self._flags.set(flag["flag"], flag["result"]) + if scope._attributes: + self._attributes.update(scope._attributes) def update_from_kwargs( self, @@ -1677,6 +1696,7 @@ def update_from_kwargs( contexts: "Optional[Dict[str, Dict[str, Any]]]" = None, tags: "Optional[Dict[str, str]]" = None, fingerprint: "Optional[List[str]]" = None, + attributes: "Optional[Attributes]" = None, ) -> None: """Update the scope's attributes.""" if level is not None: @@ -1691,6 +1711,8 @@ def update_from_kwargs( self._tags.update(tags) if fingerprint is not None: self._fingerprint = fingerprint + if attributes is not None: + self._attributes.update(attributes) def __repr__(self) -> str: return "<%s id=%s name=%s type=%s>" % ( @@ -1710,6 +1732,15 @@ def flags(self) -> "FlagBuffer": self._flags = FlagBuffer(capacity=max_flags) return self._flags + def set_attribute(self, attribute: str, value: "AttributeValue") -> None: + self._attributes[attribute] = format_attribute(value) + + def remove_attribute(self, attribute: str) -> None: + try: + del self._attributes[attribute] + except KeyError: + pass + @contextmanager def new_scope() -> "Generator[Scope, None, None]": diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 4965a13c0a..96341d6363 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2047,7 +2047,25 @@ def get_before_send_metric( ) +def format_attribute(val: "Any") -> "AttributeValue": + """ + Turn unsupported attribute value types into an AttributeValue. + + We do this as soon as a user-provided attribute is set, to prevent spans, + logs, ßmetrics and similar from having live references to various ßobjects. + + Note: This is not the final attribute value format. Before they're sent, + they're serialized further into the actual format the protocol expects: + https://develop.sentry.dev/sdk/telemetry/attributes/ + """ + if isinstance(val, (bool, int, float, str)): + return val + + return safe_repr(val) + + def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue": + """Serialize attribute value to the transport format.""" if isinstance(val, bool): return {"value": val, "type": "boolean"} if isinstance(val, int): @@ -2057,5 +2075,6 @@ def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue": if isinstance(val, str): return {"value": val, "type": "string"} - # Coerce to string if we don't know what to do with the value + # Coerce to string if we don't know what to do with the value. This should + # never happen as we pre-format early in format_attribute, but let's be safe. return {"value": safe_repr(val), "type": "string"} diff --git a/tests/test_logs.py b/tests/test_logs.py index 7bdf80365f..45bb90716b 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -548,3 +548,50 @@ def record_lost_event(reason, data_category=None, item=None, *, quantity=1): } ] } + + +def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes): + sentry_init(enable_logs=True) + + envelopes = capture_envelopes() + + global_scope = sentry_sdk.get_global_scope() + global_scope.set_attribute("global.attribute", "value") + + with sentry_sdk.new_scope() as scope: + scope.set_attribute("current.attribute", "value") + python_logger = logging.Logger("test-logger") + python_logger.warning("Hello, world!") + + python_logger.warning("Hello, world!") + + get_client().flush() + + logs = envelopes_to_logs(envelopes) + (log1, log2) = logs + + assert log1["attributes"]["global.attribute"] == "value" + assert log1["attributes"]["current.attribute"] == "value" + + assert log2["attributes"]["temp.attribute"] == "value" + assert "current.attribute" not in log2["attributes"] + + +def test_log_attributes_override_scope_attributes(sentry_init, capture_envelopes): + sentry_init(enable_logs=True) + + envelopes = capture_envelopes() + + with sentry_sdk.new_scope() as scope: + scope.set_attribute("durable.attribute", "value1") + scope.set_attribute("temp.attribute", "value1") + python_logger = logging.Logger("test-logger") + python_logger.warning("Hello, world!", attributes={"temp.attribute": "value2"}) + + get_client().flush() + + logs = envelopes_to_logs(envelopes) + (log,) = logs + + assert log["attributes"]["durable.attribute"] == "value1" + assert log["attributes"]["temp.attribute"] == "value2" diff --git a/tests/test_metrics.py b/tests/test_metrics.py index ee37ee467c..90ad5dc045 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -290,3 +290,48 @@ def record_lost_event(reason, data_category, quantity): assert len(lost_event_calls) == 5 for lost_event_call in lost_event_calls: assert lost_event_call == ("queue_overflow", "trace_metric", 1) + + +def test_metric_gets_attributes_from_scopes(sentry_init, capture_envelopes): + sentry_init() + + envelopes = capture_envelopes() + + global_scope = sentry_sdk.get_global_scope() + global_scope.set_attribute("global.attribute", "value") + + with sentry_sdk.new_scope() as scope: + scope.set_attribute("current.attribute", "value") + sentry_sdk.metrics.count("test", 1) + + sentry_sdk.metrics.count("test", 1) + + get_client().flush() + + metrics = envelopes_to_metrics(envelopes) + (metric1, metric2) = metrics + + assert metric1["attributes"]["global.attribute"] == "value" + assert metric1["attributes"]["current.attribute"] == "value" + + assert metric2["attributes"]["temp.attribute"] == "value" + assert "current.attribute" not in metric2["attributes"] + + +def test_metric_attributes_override_scope_attributes(sentry_init, capture_envelopes): + sentry_init() + + envelopes = capture_envelopes() + + with sentry_sdk.new_scope() as scope: + scope.set_attribute("durable.attribute", "value1") + scope.set_attribute("temp.attribute", "value1") + sentry_sdk.metrics.count("test", 1) + + get_client().flush() + + metrics = envelopes_to_metrics(envelopes) + (metric,) = metrics + + assert metric["attributes"]["durable.attribute"] == "value1" + assert metric["attributes"]["temp.attribute"] == "value2" From 9d8b3d2f938f67dcb22ce16396293d0ccfe8e7f1 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 11:05:28 +0100 Subject: [PATCH 20/33] put preserialization back --- sentry_sdk/logger.py | 7 +++++-- sentry_sdk/metrics.py | 11 +---------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/sentry_sdk/logger.py b/sentry_sdk/logger.py index 5187283ce9..bc5f39224f 100644 --- a/sentry_sdk/logger.py +++ b/sentry_sdk/logger.py @@ -35,8 +35,11 @@ def _capture_log( body = template attrs: "Attributes" = {} - if "attributes" in kwargs: - attrs.update(kwargs.pop("attributes")) + + if kwargs.get("attributes"): + for k, v in kwargs.pop("attributes").items(): + attrs[k] = v if isinstance(v, (str, int, bool, float)) else safe_repr(v) + for k, v in kwargs.items(): attrs[f"sentry.message.parameter.{k}"] = v if kwargs: diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index e7f7a3bea7..de40136590 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -24,16 +24,7 @@ def _capture_metric( 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) - ) + attrs[k] = v if isinstance(v, (str, int, bool, float)) else safe_repr(v) metric: "Metric" = { "timestamp": time.time(), From e92c9d3ae7b2938d2022cbe8b04227ca3c072d2c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 11:12:47 +0100 Subject: [PATCH 21/33] dont override --- sentry_sdk/scope.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 9bf08aa1a1..f10540e365 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1501,7 +1501,8 @@ def _apply_scope_attributes_to_telemetry( attributes = telemetry["attributes"] for attribute, value in self._attributes.items(): - attributes[attribute] = value + if attribute not in self._attributes: + attributes[attribute] = value def _apply_user_attributes_to_telemetry( self, telemetry: "Union[Log, Metric]" From 0b25f4654ffb1e55e3059ed871b698b16599232e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 11:15:53 +0100 Subject: [PATCH 22/33] preserialize after template, parameters --- sentry_sdk/logger.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/logger.py b/sentry_sdk/logger.py index bc5f39224f..82178ecf89 100644 --- a/sentry_sdk/logger.py +++ b/sentry_sdk/logger.py @@ -37,11 +37,11 @@ def _capture_log( attrs: "Attributes" = {} if kwargs.get("attributes"): - for k, v in kwargs.pop("attributes").items(): - attrs[k] = v if isinstance(v, (str, int, bool, float)) else safe_repr(v) + attrs.update(kwargs.pop("attributes")) for k, v in kwargs.items(): attrs[f"sentry.message.parameter.{k}"] = v + if kwargs: # only attach template if there are parameters attrs["sentry.message.template"] = template @@ -49,6 +49,9 @@ def _capture_log( with capture_internal_exceptions(): body = template.format_map(_dict_default_key(kwargs)) + for k, v in attrs.items(): + attrs[k] = v if isinstance(v, (str, int, bool, float)) else safe_repr(v) + sentry_sdk.get_current_scope()._capture_log( { "severity_text": severity_text, From ef5f9fb59dc6ce75310ec21b56af206265727525 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 11:17:37 +0100 Subject: [PATCH 23/33] fix --- sentry_sdk/logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/logger.py b/sentry_sdk/logger.py index 82178ecf89..b446ec7893 100644 --- a/sentry_sdk/logger.py +++ b/sentry_sdk/logger.py @@ -36,7 +36,7 @@ def _capture_log( attrs: "Attributes" = {} - if kwargs.get("attributes"): + if "attributes" in kwargs: attrs.update(kwargs.pop("attributes")) for k, v in kwargs.items(): From 4ac20db7e33e24eee9058811de0173c829bc7e16 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 11:20:55 +0100 Subject: [PATCH 24/33] use format_attr in other places too --- sentry_sdk/logger.py | 16 +++++++--------- sentry_sdk/metrics.py | 4 ++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/sentry_sdk/logger.py b/sentry_sdk/logger.py index b446ec7893..db4ca46715 100644 --- a/sentry_sdk/logger.py +++ b/sentry_sdk/logger.py @@ -4,7 +4,7 @@ from typing import Any, TYPE_CHECKING import sentry_sdk -from sentry_sdk.utils import safe_repr, capture_internal_exceptions +from sentry_sdk.utils import format_attribute, safe_repr, capture_internal_exceptions if TYPE_CHECKING: from sentry_sdk._types import Attributes, Log @@ -34,29 +34,27 @@ def _capture_log( ) -> None: body = template - attrs: "Attributes" = {} + attributes: "Attributes" = {} if "attributes" in kwargs: - attrs.update(kwargs.pop("attributes")) + for attribute, value in kwargs["attributes"] or {}: + attributes[attribute] = format_attribute(value) for k, v in kwargs.items(): - attrs[f"sentry.message.parameter.{k}"] = v + attributes[f"sentry.message.parameter.{k}"] = format_attribute(v) if kwargs: # only attach template if there are parameters - attrs["sentry.message.template"] = template + attributes["sentry.message.template"] = format_attribute(template) with capture_internal_exceptions(): body = template.format_map(_dict_default_key(kwargs)) - for k, v in attrs.items(): - attrs[k] = v if isinstance(v, (str, int, bool, float)) else safe_repr(v) - sentry_sdk.get_current_scope()._capture_log( { "severity_text": severity_text, "severity_number": severity_number, - "attributes": attrs, + "attributes": attributes, "body": body, "time_unix_nano": time.time_ns(), "trace_id": None, diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index de40136590..30f8191126 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -7,7 +7,7 @@ from typing import Any, Optional, TYPE_CHECKING, Union import sentry_sdk -from sentry_sdk.utils import safe_repr +from sentry_sdk.utils import format_attribute, safe_repr if TYPE_CHECKING: from sentry_sdk._types import Attributes, Metric, MetricType @@ -24,7 +24,7 @@ def _capture_metric( if attributes: for k, v in attributes.items(): - attrs[k] = v if isinstance(v, (str, int, bool, float)) else safe_repr(v) + attrs[k] = format_attribute(v) metric: "Metric" = { "timestamp": time.time(), From 4b8047a436e4cf3771545ed9408ef5c3a99e172d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 11:42:16 +0100 Subject: [PATCH 25/33] . --- tests/test_attributes.py | 119 +++++++++++++++++++++++++++++++++++++++ tests/test_logs.py | 2 +- 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 tests/test_attributes.py diff --git a/tests/test_attributes.py b/tests/test_attributes.py new file mode 100644 index 0000000000..1f838aa04e --- /dev/null +++ b/tests/test_attributes.py @@ -0,0 +1,119 @@ +import sentry_sdk + +from tests.test_metrics import envelopes_to_metrics + + +def test_scope_precedence(sentry_init, capture_envelopes): + # Order of precedence, from most important to least: + # 1. telemetry attributes (directly supplying attributes on creation or using set_attribute) + # 2. current scope attributes + # 3. isolation scope attributes + # 4. global scope attributes + sentry_init() + + envelopes = capture_envelopes() + + global_scope = sentry_sdk.get_global_scope() + global_scope.set_attribute("global.attribute", "global") + global_scope.set_attribute("overwritten.attribute", "global") + + isolation_scope = sentry_sdk.get_isolation_scope() + isolation_scope.set_attribute("isolation.attribute", "isolation") + isolation_scope.set_attribute("overwritten.attribute", "isolation") + + current_scope = sentry_sdk.get_current_scope() + current_scope.set_attribute("current.attribute", "current") + current_scope.set_attribute("overwritten.attribute", "current") + + sentry_sdk.metrics.count("test", 1) + sentry_sdk.get_client().flush() + + metrics = envelopes_to_metrics(envelopes) + (metric,) = metrics + + assert metric["attributes"]["global.attribute"] == "global" + assert metric["attributes"]["isolation.attribute"] == "isolation" + assert metric["attributes"]["current.attribute"] == "current" + + assert metric["attributes"]["overwritten.attribute"] == "current" + + +def test_telemetry_precedence(sentry_init, capture_envelopes): + # Order of precedence, from most important to least: + # 1. telemetry attributes (directly supplying attributes on creation or using set_attribute) + # 2. current scope attributes + # 3. isolation scope attributes + # 4. global scope attributes + sentry_init() + + envelopes = capture_envelopes() + + global_scope = sentry_sdk.get_global_scope() + global_scope.set_attribute("global.attribute", "global") + global_scope.set_attribute("overwritten.attribute", "global") + + isolation_scope = sentry_sdk.get_isolation_scope() + isolation_scope.set_attribute("isolation.attribute", "isolation") + isolation_scope.set_attribute("overwritten.attribute", "isolation") + + current_scope = sentry_sdk.get_current_scope() + current_scope.set_attribute("current_scope.attribute", "current_scope") + current_scope.set_attribute("overwritten.attribute", "current_scope") + + sentry_sdk.metrics.count( + "test", + 1, + attributes={ + "telemetry.attribute": "telemetry", + "overwritten.attribute": "telemetry", + }, + ) + + sentry_sdk.get_client().flush() + + metrics = envelopes_to_metrics(envelopes) + (metric,) = metrics + + assert metric["attributes"]["global.attribute"] == "global" + assert metric["attributes"]["isolation.attribute"] == "isolation" + assert metric["attributes"]["current.attribute"] == "current" + assert metric["attributes"]["telemetry.attribute"] == "telemetry" + + assert metric["attributes"]["overwritten.attribute"] == "telemetry" + + +def test_attribute_out_of_scope(sentry_init, capture_envelopes): + sentry_init() + + envelopes = capture_envelopes() + + with sentry_sdk.new_scope() as scope: + scope.set_attribute("outofscope.attribute", "out of scope") + + sentry_sdk.metrics.count("test", 1) + + sentry_sdk.get_client().flush() + + metrics = envelopes_to_metrics(envelopes) + (metric,) = metrics + + assert "outofscope.attribute" not in metric["attributes"] + + +def test_remove_attribute(sentry_init, capture_envelopes): + sentry_init() + + envelopes = capture_envelopes() + + with sentry_sdk.new_scope() as scope: + scope.set_attribute("some.attribute", 123) + scope.remove_attribute("some.attribute") + + sentry_sdk.metrics.count("test", 1) + + sentry_sdk.get_client().flush() + + metrics = envelopes_to_metrics(envelopes) + (metric,) = metrics + + assert "some.attribute" not in metric["attributes"] diff --git a/tests/test_logs.py b/tests/test_logs.py index 45bb90716b..2e56ab5e74 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -563,7 +563,7 @@ def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes): python_logger = logging.Logger("test-logger") python_logger.warning("Hello, world!") - python_logger.warning("Hello, world!") + python_logger.warning("Hello again!") get_client().flush() From 71541967c6629d57b9f22254302d4126f21441e8 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 11:45:58 +0100 Subject: [PATCH 26/33] keyboard --- 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 96341d6363..c99b81a2f5 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -2052,7 +2052,7 @@ def format_attribute(val: "Any") -> "AttributeValue": Turn unsupported attribute value types into an AttributeValue. We do this as soon as a user-provided attribute is set, to prevent spans, - logs, ßmetrics and similar from having live references to various ßobjects. + logs, metrics and similar from having live references to various objects. Note: This is not the final attribute value format. Before they're sent, they're serialized further into the actual format the protocol expects: From cea7d21fa595969de77385a914dd89fa4fde7d3d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 13:16:03 +0100 Subject: [PATCH 27/33] i am very smart --- sentry_sdk/scope.py | 6 ++---- tests/test_attributes.py | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index f10540e365..9bb415350b 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1498,11 +1498,9 @@ def _apply_global_attributes_to_telemetry( def _apply_scope_attributes_to_telemetry( self, telemetry: "Union[Log, Metric]" ) -> None: - attributes = telemetry["attributes"] - for attribute, value in self._attributes.items(): - if attribute not in self._attributes: - attributes[attribute] = value + if attribute not in telemetry["attributes"]: + telemetry["attributes"][attribute] = value def _apply_user_attributes_to_telemetry( self, telemetry: "Union[Log, Metric]" diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 1f838aa04e..b4ba2d77a7 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -57,8 +57,8 @@ def test_telemetry_precedence(sentry_init, capture_envelopes): isolation_scope.set_attribute("overwritten.attribute", "isolation") current_scope = sentry_sdk.get_current_scope() - current_scope.set_attribute("current_scope.attribute", "current_scope") - current_scope.set_attribute("overwritten.attribute", "current_scope") + current_scope.set_attribute("current.attribute", "current") + current_scope.set_attribute("overwritten.attribute", "current") sentry_sdk.metrics.count( "test", From 5aec76cc981f457e50086b00bdbe08f14875c7f3 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 13:23:17 +0100 Subject: [PATCH 28/33] merge botched --- sentry_sdk/logger.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sentry_sdk/logger.py b/sentry_sdk/logger.py index eb8981c668..db4ca46715 100644 --- a/sentry_sdk/logger.py +++ b/sentry_sdk/logger.py @@ -10,10 +10,6 @@ from sentry_sdk._types import Attributes, Log -if TYPE_CHECKING: - from sentry_sdk._types import Attributes, Log - - OTEL_RANGES = [ # ((severity level range), severity text) # https://opentelemetry.io/docs/specs/otel/logs/data-model From 641214cd40c0f284f6b3ad4f3c9b285d48809362 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 13:30:43 +0100 Subject: [PATCH 29/33] issues --- sentry_sdk/logger.py | 3 ++- tests/test_logs.py | 12 ++++++------ tests/test_metrics.py | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/logger.py b/sentry_sdk/logger.py index db4ca46715..4a90fef70b 100644 --- a/sentry_sdk/logger.py +++ b/sentry_sdk/logger.py @@ -37,7 +37,8 @@ def _capture_log( attributes: "Attributes" = {} if "attributes" in kwargs: - for attribute, value in kwargs["attributes"] or {}: + provided_attributes = kwargs.pop("attributes") or {} + for attribute, value in provided_attributes.items(): attributes[attribute] = format_attribute(value) for k, v in kwargs.items(): diff --git a/tests/test_logs.py b/tests/test_logs.py index 2e56ab5e74..f12b42b5b9 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -560,10 +560,9 @@ def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes): with sentry_sdk.new_scope() as scope: scope.set_attribute("current.attribute", "value") - python_logger = logging.Logger("test-logger") - python_logger.warning("Hello, world!") + sentry_sdk.logger.warning("Hello, world!") - python_logger.warning("Hello again!") + sentry_sdk.logger.warning("Hello again!") get_client().flush() @@ -573,7 +572,7 @@ def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes): assert log1["attributes"]["global.attribute"] == "value" assert log1["attributes"]["current.attribute"] == "value" - assert log2["attributes"]["temp.attribute"] == "value" + assert log2["attributes"]["global.attribute"] == "value" assert "current.attribute" not in log2["attributes"] @@ -585,8 +584,9 @@ def test_log_attributes_override_scope_attributes(sentry_init, capture_envelopes with sentry_sdk.new_scope() as scope: scope.set_attribute("durable.attribute", "value1") scope.set_attribute("temp.attribute", "value1") - python_logger = logging.Logger("test-logger") - python_logger.warning("Hello, world!", attributes={"temp.attribute": "value2"}) + sentry_sdk.logger.warning( + "Hello, world!", attributes={"temp.attribute": "value2"} + ) get_client().flush() diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 90ad5dc045..240ed18a37 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -314,7 +314,7 @@ def test_metric_gets_attributes_from_scopes(sentry_init, capture_envelopes): assert metric1["attributes"]["global.attribute"] == "value" assert metric1["attributes"]["current.attribute"] == "value" - assert metric2["attributes"]["temp.attribute"] == "value" + assert metric2["attributes"]["global.attribute"] == "value" assert "current.attribute" not in metric2["attributes"] @@ -326,7 +326,7 @@ def test_metric_attributes_override_scope_attributes(sentry_init, capture_envelo with sentry_sdk.new_scope() as scope: scope.set_attribute("durable.attribute", "value1") scope.set_attribute("temp.attribute", "value1") - sentry_sdk.metrics.count("test", 1) + sentry_sdk.metrics.count("test", 1, attributes={"temp.attribute": "value2"}) get_client().flush() From a3714b0094a18632acff24da1c51a28f7b611538 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 13:36:36 +0100 Subject: [PATCH 30/33] add missing 3.7 decorators in logs tests --- tests/test_logs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_logs.py b/tests/test_logs.py index f12b42b5b9..3b60651631 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -550,6 +550,7 @@ def record_lost_event(reason, data_category=None, item=None, *, quantity=1): } +@minimum_python_37 def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes): sentry_init(enable_logs=True) @@ -576,6 +577,7 @@ def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes): assert "current.attribute" not in log2["attributes"] +@minimum_python_37 def test_log_attributes_override_scope_attributes(sentry_init, capture_envelopes): sentry_init(enable_logs=True) From 5ad9df4c5563f29311d4033c37171ce0f9851efa Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 13:45:44 +0100 Subject: [PATCH 31/33] docstrings --- sentry_sdk/scope.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 9bb415350b..aee9da1b7f 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1732,9 +1732,16 @@ def flags(self) -> "FlagBuffer": return self._flags def set_attribute(self, attribute: str, value: "AttributeValue") -> None: + """ + Set an attribute on the scope. + + Any attributes-based telemetry (logs, metrics) captured while this scope + is active will inherit attributes set on the scope. + """ self._attributes[attribute] = format_attribute(value) def remove_attribute(self, attribute: str) -> None: + """Remove an attribute if set on the scope.""" try: del self._attributes[attribute] except KeyError: From 7c3da6e71e79772b33dfafd75f5e3feb7f8c9e8d Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 13:47:26 +0100 Subject: [PATCH 32/33] . --- sentry_sdk/scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index aee9da1b7f..851a492578 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1741,7 +1741,7 @@ def set_attribute(self, attribute: str, value: "AttributeValue") -> None: self._attributes[attribute] = format_attribute(value) def remove_attribute(self, attribute: str) -> None: - """Remove an attribute if set on the scope.""" + """Remove an attribute if set on the scope. No-op if there is no such attribute.""" try: del self._attributes[attribute] except KeyError: From b858becba66e0a452a96262b41cc522121b1633c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 18 Dec 2025 16:13:49 +0100 Subject: [PATCH 33/33] apply global attrs last, conditionally --- sentry_sdk/scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 851a492578..1c3fe884e8 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1636,9 +1636,9 @@ def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None: if telemetry.get("span_id") is None and span_id: telemetry["span_id"] = span_id - self._apply_global_attributes_to_telemetry(telemetry) self._apply_scope_attributes_to_telemetry(telemetry) self._apply_user_attributes_to_telemetry(telemetry) + self._apply_global_attributes_to_telemetry(telemetry) def update_from_scope(self, scope: "Scope") -> None: """Update the scope with another scope's data."""