diff --git a/sentry_sdk/_log_batcher.py b/sentry_sdk/_log_batcher.py index aee9b1db6f..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_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: @@ -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"]: @@ -138,7 +127,7 @@ def format_attribute(val: "int | float | str | bool") -> "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 3bac514ed2..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_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: @@ -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"], @@ -114,7 +103,7 @@ def format_attribute(val: "Union[int, float, str, bool]") -> "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/_types.py b/sentry_sdk/_types.py index 5497a27a3d..5a8cb936ac 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -215,13 +215,39 @@ class SDKInfo(TypedDict): # TODO: Make a proper type definition for this (PRs welcome!) Hint = Dict[str, Any] + AttributeValue = ( + str | bool | float | int + # TODO: relay support coming soon for + # | list[str] | list[bool] | list[float] | list[int] + ) + Attributes = dict[str, AttributeValue] + + SerializedAttributeValue = TypedDict( + # https://develop.sentry.dev/sdk/telemetry/attributes/#supported-types + "SerializedAttributeValue", + { + "type": Literal[ + "string", + "boolean", + "double", + "integer", + # TODO: relay support coming soon for: + # "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], "span_id": Optional[str], @@ -230,14 +256,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", { @@ -248,7 +266,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 ca7e0f5ed6..259196d1c6 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -217,10 +217,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: @@ -898,132 +898,41 @@ def capture_event( return return_value - def _capture_log(self, log: "Optional[Log]") -> None: - if not has_logs_enabled(self.options) or log is None: + def _capture_telemetry( + self, telemetry: "Optional[Union[Log, Metric]]", ty: str, scope: "Scope" + ) -> None: + # Capture attributes-based telemetry (logs, metrics, spansV2) + 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 log.get("span_id") is None: - log["span_id"] = span_id - - # The user, if present, is always set on the isolation scope. - if self.should_send_default_pii() and 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')}" - ) - - before_send_log = get_before_send_log(self.options) - if before_send_log is not None: - log = before_send_log(log, {}) + scope.apply_to_telemetry(telemetry) - if log is None: - return + 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 self.log_batcher: - self.log_batcher.add(log) + if before_send is not None: + telemetry = before_send(telemetry, {}) # type: ignore - def _capture_metric(self, metric: "Optional[Metric]") -> None: - if not has_metrics_enabled(self.options) or metric is None: + if telemetry is None: return - current_scope = sentry_sdk.get_current_scope() - isolation_scope = sentry_sdk.get_isolation_scope() + batcher = None + if ty == "log": + batcher = self.log_batcher + elif ty == "metric": + batcher = self.metrics_batcher # type: ignore - metric["attributes"]["sentry.sdk.name"] = SDK_INFO["name"] - metric["attributes"]["sentry.sdk.version"] = SDK_INFO["version"] + if batcher is not None: + batcher.add(telemetry) # type: ignore - 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 self.should_send_default_pii() and 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 + def _capture_log(self, log: "Optional[Log]", scope: "Scope") -> None: + self._capture_telemetry(log, "log", scope) - if self.metrics_batcher: - self.metrics_batcher.add(metric) + def _capture_metric(self, metric: "Optional[Metric]", scope: "Scope") -> None: + 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 97fc99de0a..42029c5a7a 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -396,7 +396,7 @@ def _capture_log_from_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 87e154d283..00bd3c022b 100644 --- a/sentry_sdk/integrations/loguru.py +++ b/sentry_sdk/integrations/loguru.py @@ -196,7 +196,7 @@ def loguru_sentry_logs_handler(message: "Message") -> None: 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 afdad436ef..b446ec7893 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 -from sentry_sdk import get_client +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 @@ -28,14 +32,16 @@ def __missing__(self, key: str) -> str: def _capture_log( severity_text: str, severity_number: int, template: str, **kwargs: "Any" ) -> None: - client = get_client() - 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(): attrs[f"sentry.message.parameter.{k}"] = v + if kwargs: # only attach template if there are parameters attrs["sentry.message.template"] = template @@ -43,22 +49,10 @@ 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 - client._capture_log( + 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, @@ -67,7 +61,7 @@ def _capture_log( "time_unix_nano": time.time_ns(), "trace_id": None, "span_id": None, - }, + } ) diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index d8f2159f7e..de40136590 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -10,7 +10,7 @@ from sentry_sdk.utils import safe_repr if TYPE_CHECKING: - from sentry_sdk._types import Metric, MetricType + from sentry_sdk._types import Attributes, Metric, MetricType def _capture_metric( @@ -18,23 +18,13 @@ def _capture_metric( metric_type: "MetricType", value: float, unit: "Optional[str]" = None, - attributes: "Optional[dict[str, Any]]" = None, + attributes: "Optional[Attributes]" = None, ) -> None: - client = sentry_sdk.get_client() + attrs: "Attributes" = {} - attrs: "dict[str, Union[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) - ) + attrs[k] = v if isinstance(v, (str, int, bool, float)) else safe_repr(v) metric: "Metric" = { "timestamp": time.time(), @@ -47,7 +37,7 @@ def _capture_metric( "attributes": attrs, } - 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 91945a09a0..a933159919 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 @@ -73,7 +80,9 @@ EventProcessor, ExcInfo, Hint, + Log, LogLevelStr, + Metric, SamplingContext, Type, ) @@ -1172,6 +1181,42 @@ def capture_event( return event_id + def _capture_log(self, log: "Optional[Log]") -> None: + if log is None: + return + + client = self.get_client() + if not has_logs_enabled(client.options): + return + + merged_scope = self._merge_scopes() + + 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=merged_scope) + + def _capture_metric(self, metric: "Optional[Metric]") -> None: + if metric is None: + return + + client = self.get_client() + if not has_metrics_enabled(client.options): + return + + merged_scope = self._merge_scopes() + + 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=merged_scope) + def capture_message( self, message: str, @@ -1415,6 +1460,49 @@ def _apply_flags_to_event( {"values": flags} ) + def _apply_global_attributes_to_telemetry( + 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 + # from there + from sentry_sdk.client import SDK_INFO + + attributes = telemetry["attributes"] + + 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 + + environment = options.get("environment") + if environment is not None and "sentry.environment" not in attributes: + attributes["sentry.environment"] = environment + + release = options.get("release") + if release is not None and "sentry.release" not in attributes: + attributes["sentry.release"] = release + + 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: + 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: "Any", ty: str) -> "Optional[Any]": logger.info("%s (%s) dropped event", ty, cause) return None @@ -1521,6 +1609,21 @@ def apply_to_event( return event + @_disable_capture + def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None: + # Attributes-based events and telemetry go through here (logs, metrics, + # spansV2) + trace_context = self.get_trace_context() + trace_id = trace_context.get("trace_id") + if telemetry.get("trace_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) + self._apply_user_attributes_to_telemetry(telemetry) + def update_from_scope(self, scope: "Scope") -> None: """Update the scope with another scope's data.""" if scope._level is not None: diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 551504fa8a..4965a13c0a 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -45,6 +45,7 @@ Dict, Iterator, List, + Literal, NoReturn, Optional, ParamSpec, @@ -59,7 +60,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 +2045,17 @@ def get_before_send_metric( return options.get("before_send_metric") or options["_experiments"].get( "before_send_metric" ) + + +def serialize_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"} + + # Coerce to string if we don't know what to do with the value + return {"value": safe_repr(val), "type": "string"}