diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index a39e27d884..93f673fbe3 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -217,9 +217,18 @@ 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] + | tuple[str, ...] + | tuple[bool, ...] + | tuple[float, ...] + | tuple[int, ...] ) Attributes = dict[str, AttributeValue] @@ -232,11 +241,10 @@ class SDKInfo(TypedDict): "boolean", "double", "integer", - # TODO: relay support coming soon for: - # "string[]", - # "boolean[]", - # "double[]", - # "integer[]", + "string[]", + "boolean[]", + "double[]", + "integer[]", ], "value": AttributeValue, }, diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index c99b81a2f5..2fbca486de 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -4,6 +4,7 @@ import logging import math import os +import copy import random import re import subprocess @@ -2061,6 +2062,25 @@ def format_attribute(val: "Any") -> "AttributeValue": if isinstance(val, (bool, int, float, str)): return val + # 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[]", + } + + if isinstance(val, (list, tuple)) and not val: + return [] + elif isinstance(val, list): + ty = type(val[0]) + if ty in list_types and all(type(v) is ty for v in val): + return copy.deepcopy(val) + elif isinstance(val, tuple): + ty = type(val[0]) + if ty in list_types and all(type(v) is ty for v in val): + return list(val) + return safe_repr(val) @@ -2075,6 +2095,22 @@ def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue": if isinstance(val, str): return {"value": val, "type": "string"} + if isinstance(val, list): + if not val: + return {"value": [], "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(type(v) is 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. 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 d91d3fa64d..386e22658e 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -661,13 +661,71 @@ def test_log_attributes_override_scope_attributes(sentry_init, capture_envelopes assert log["attributes"]["temp.attribute"] == "value2" +@minimum_python_37 +def test_log_array_attributes(sentry_init, capture_envelopes): + """Test homogeneous list and tuple attributes, and fallback for inhomogeneous collections.""" + + sentry_init(enable_logs=True) + + envelopes = capture_envelopes() + + with sentry_sdk.new_scope() as scope: + scope.set_attribute("string_list", ["value1", "value2"]) + scope.set_attribute("int_tuple", (3, 2, 1, 4)) + scope.set_attribute("inhomogeneous_tuple", (3, 2.0, 1, 4)) # type: ignore[arg-type] + + sentry_sdk.logger.warning( + "Hello, world!", + attributes={ + "float_list": [3.0, 3.5, 4.2], + "bool_tuple": (False, False, True), + "inhomogeneous_list": [3.2, True, None], + }, + ) + + get_client().flush() + + assert len(envelopes) == 1 + assert len(envelopes[0].items) == 1 + item = envelopes[0].items[0] + serialized_attributes = item.payload.json["items"][0]["attributes"] + + assert serialized_attributes["string_list"] == { + "value": ["value1", "value2"], + "type": "string[]", + } + assert serialized_attributes["int_tuple"] == { + "value": [3, 2, 1, 4], + "type": "integer[]", + } + assert serialized_attributes["inhomogeneous_tuple"] == { + "value": "(3, 2.0, 1, 4)", + "type": "string", + } + + assert serialized_attributes["float_list"] == { + "value": [3.0, 3.5, 4.2], + "type": "double[]", + } + assert serialized_attributes["bool_tuple"] == { + "value": [False, False, True], + "type": "boolean[]", + } + assert serialized_attributes["inhomogeneous_list"] == { + "value": "[3.2, True, None]", + "type": "string", + } + + @minimum_python_37 def test_attributes_preserialized_in_before_send(sentry_init, capture_envelopes): - """We don't surface references to objects in attributes.""" + """We don't surface user-held references to objects in attributes.""" def before_send_log(log, _): assert isinstance(log["attributes"]["instance"], str) assert isinstance(log["attributes"]["dictionary"], str) + assert isinstance(log["attributes"]["inhomogeneous_list"], str) + assert isinstance(log["attributes"]["inhomogeneous_tuple"], str) return log @@ -686,6 +744,8 @@ class Cat: attributes={ "instance": instance, "dictionary": dictionary, + "inhomogeneous_list": [3.2, True, None], + "inhomogeneous_tuple": (3, 2.0, 1, 4), }, ) @@ -696,3 +756,31 @@ class Cat: assert isinstance(log["attributes"]["instance"], str) assert isinstance(log["attributes"]["dictionary"], str) + assert isinstance(log["attributes"]["inhomogeneous_list"], str) + assert isinstance(log["attributes"]["inhomogeneous_tuple"], str) + + +@minimum_python_37 +def test_array_attributes_deep_copied_in_before_send(sentry_init, capture_envelopes): + """We don't surface user-held references to objects in attributes.""" + + strings = ["value1", "value2"] + ints = (3, 2, 1, 4) + + def before_send_log(log, _): + assert log["attributes"]["string_list"] is not strings + assert log["attributes"]["int_tuple"] is not ints + + return log + + sentry_init(enable_logs=True, before_send_log=before_send_log) + + sentry_sdk.logger.warning( + "Hello world!", + attributes={ + "string_list": strings, + "int_tuple": ints, + }, + ) + + get_client().flush() diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 4f64279488..185c1c594d 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -394,12 +394,70 @@ def test_metric_attributes_override_scope_attributes(sentry_init, capture_envelo assert metric["attributes"]["temp.attribute"] == "value2" +def test_log_array_attributes(sentry_init, capture_envelopes): + """Test homogeneous list and tuple attributes, and fallback for inhomogeneous collections.""" + + sentry_init() + + envelopes = capture_envelopes() + + with sentry_sdk.new_scope() as scope: + scope.set_attribute("string_list.attribute", ["value1", "value2"]) + scope.set_attribute("int_tuple.attribute", (3, 2, 1, 4)) + scope.set_attribute("inhomogeneous_tuple.attribute", (3, 2.0, 1, 4)) # type: ignore[arg-type] + + sentry_sdk.metrics.count( + "test", + 1, + attributes={ + "float_list.attribute": [3.0, 3.5, 4.2], + "bool_tuple.attribute": (False, False, True), + "inhomogeneous_list.attribute": [3.2, True, None], + }, + ) + + get_client().flush() + + assert len(envelopes) == 1 + assert len(envelopes[0].items) == 1 + item = envelopes[0].items[0] + serialized_attributes = item.payload.json["items"][0]["attributes"] + + assert serialized_attributes["string_list.attribute"] == { + "value": ["value1", "value2"], + "type": "string[]", + } + assert serialized_attributes["int_tuple.attribute"] == { + "value": [3, 2, 1, 4], + "type": "integer[]", + } + assert serialized_attributes["inhomogeneous_tuple.attribute"] == { + "value": "(3, 2.0, 1, 4)", + "type": "string", + } + + assert serialized_attributes["float_list.attribute"] == { + "value": [3.0, 3.5, 4.2], + "type": "double[]", + } + assert serialized_attributes["bool_tuple.attribute"] == { + "value": [False, False, True], + "type": "boolean[]", + } + assert serialized_attributes["inhomogeneous_list.attribute"] == { + "value": "[3.2, True, None]", + "type": "string", + } + + def test_attributes_preserialized_in_before_send(sentry_init, capture_envelopes): - """We don't surface references to objects in attributes.""" + """We don't surface user-held references to objects in attributes.""" def before_send_metric(metric, _): assert isinstance(metric["attributes"]["instance"], str) assert isinstance(metric["attributes"]["dictionary"], str) + assert isinstance(metric["attributes"]["inhomogeneous_list"], str) + assert isinstance(metric["attributes"]["inhomogeneous_tuple"], str) return metric @@ -419,6 +477,8 @@ class Cat: attributes={ "instance": instance, "dictionary": dictionary, + "inhomogeneous_list": [3.2, True, None], + "inhomogeneous_tuple": (3, 2.0, 1, 4), }, ) @@ -429,3 +489,29 @@ class Cat: assert isinstance(metric["attributes"]["instance"], str) assert isinstance(metric["attributes"]["dictionary"], str) + + +def test_array_attributes_deep_copied_in_before_send(sentry_init, capture_envelopes): + """We don't surface user-held references to objects in attributes.""" + + strings = ["value1", "value2"] + ints = (3, 2, 1, 4) + + def before_send_metric(metric, _): + assert metric["attributes"]["string_list"] is not strings + assert metric["attributes"]["int_tuple"] is not ints + + return metric + + sentry_init(before_send_metric=before_send_metric) + + sentry_sdk.metrics.count( + "test.counter", + 1, + attributes={ + "string_list": strings, + "int_tuple": ints, + }, + ) + + get_client().flush()