Skip to content

Commit 60ac126

Browse files
serialize list and tuple attributes
1 parent fedb6a3 commit 60ac126

File tree

3 files changed

+212
-2
lines changed

3 files changed

+212
-2
lines changed

sentry_sdk/utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import math
66
import os
7+
import copy
78
import random
89
import re
910
import subprocess
@@ -2061,6 +2062,25 @@ def format_attribute(val: "Any") -> "AttributeValue":
20612062
if isinstance(val, (bool, int, float, str)):
20622063
return val
20632064

2065+
# Only lists of elements of a single type are supported
2066+
list_types: 'dict[type, Literal["string[]", "integer[]", "double[]", "boolean[]"]]' = {
2067+
str: "string[]",
2068+
int: "integer[]",
2069+
float: "double[]",
2070+
bool: "boolean[]",
2071+
}
2072+
2073+
if isinstance(val, (list, tuple)) and not val:
2074+
return []
2075+
elif isinstance(val, list):
2076+
ty = type(val[0])
2077+
if ty in list_types and all(isinstance(v, ty) for v in val):
2078+
return copy.deepcopy(val)
2079+
elif isinstance(val, tuple):
2080+
ty = type(val[0])
2081+
if ty in list_types and all(isinstance(v, ty) for v in val):
2082+
return list(val)
2083+
20642084
return safe_repr(val)
20652085

20662086

@@ -2075,6 +2095,22 @@ def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue":
20752095
if isinstance(val, str):
20762096
return {"value": val, "type": "string"}
20772097

2098+
if isinstance(val, list):
2099+
if not val:
2100+
return {"value": [], "type": "string[]"}
2101+
2102+
# Only lists of elements of a single type are supported
2103+
list_types: 'dict[type, Literal["string[]", "integer[]", "double[]", "boolean[]"]]' = {
2104+
str: "string[]",
2105+
int: "integer[]",
2106+
float: "double[]",
2107+
bool: "boolean[]",
2108+
}
2109+
2110+
ty = type(val[0])
2111+
if ty in list_types and all(isinstance(v, ty) for v in val):
2112+
return {"value": val, "type": list_types[ty]}
2113+
20782114
# Coerce to string if we don't know what to do with the value. This should
20792115
# never happen as we pre-format early in format_attribute, but let's be safe.
20802116
return {"value": safe_repr(val), "type": "string"}

tests/test_logs.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -661,13 +661,71 @@ def test_log_attributes_override_scope_attributes(sentry_init, capture_envelopes
661661
assert log["attributes"]["temp.attribute"] == "value2"
662662

663663

664+
@minimum_python_37
665+
def test_log_array_attributes(sentry_init, capture_envelopes):
666+
"""Test homogeneous list and tuple attributes, and fallback for inhomogeneous collections."""
667+
668+
sentry_init(enable_logs=True)
669+
670+
envelopes = capture_envelopes()
671+
672+
with sentry_sdk.new_scope() as scope:
673+
scope.set_attribute("string_list", ["value1", "value2"])
674+
scope.set_attribute("int_tuple", (3, 2, 1, 4))
675+
scope.set_attribute("inhomogeneous_tuple", (3, 2.0, 1, 4)) # type: ignore[arg-type]
676+
677+
sentry_sdk.logger.warning(
678+
"Hello, world!",
679+
attributes={
680+
"float_list": [3.0, 3.5, 4.2],
681+
"bool_tuple": (False, False, True),
682+
"inhomogeneous_list": [3.2, True, None],
683+
},
684+
)
685+
686+
get_client().flush()
687+
688+
assert len(envelopes) == 1
689+
assert len(envelopes[0].items) == 1
690+
item = envelopes[0].items[0]
691+
serialized_attributes = item.payload.json["items"][0]["attributes"]
692+
693+
assert serialized_attributes["string_list"] == {
694+
"value": ["value1", "value2"],
695+
"type": "string[]",
696+
}
697+
assert serialized_attributes["int_tuple"] == {
698+
"value": [3, 2, 1, 4],
699+
"type": "integer[]",
700+
}
701+
assert serialized_attributes["inhomogeneous_tuple"] == {
702+
"value": "(3, 2.0, 1, 4)",
703+
"type": "string",
704+
}
705+
706+
assert serialized_attributes["float_list"] == {
707+
"value": [3.0, 3.5, 4.2],
708+
"type": "double[]",
709+
}
710+
assert serialized_attributes["bool_tuple"] == {
711+
"value": [False, False, True],
712+
"type": "boolean[]",
713+
}
714+
assert serialized_attributes["inhomogeneous_list"] == {
715+
"value": "[3.2, True, None]",
716+
"type": "string",
717+
}
718+
719+
664720
@minimum_python_37
665721
def test_attributes_preserialized_in_before_send(sentry_init, capture_envelopes):
666-
"""We don't surface references to objects in attributes."""
722+
"""We don't surface user-held references to objects in attributes."""
667723

668724
def before_send_log(log, _):
669725
assert isinstance(log["attributes"]["instance"], str)
670726
assert isinstance(log["attributes"]["dictionary"], str)
727+
assert isinstance(log["attributes"]["inhomogeneous_list"], str)
728+
assert isinstance(log["attributes"]["inhomogeneous_tuple"], str)
671729

672730
return log
673731

@@ -686,6 +744,8 @@ class Cat:
686744
attributes={
687745
"instance": instance,
688746
"dictionary": dictionary,
747+
"inhomogeneous_list": [3.2, True, None],
748+
"inhomogeneous_tuple": (3, 2.0, 1, 4),
689749
},
690750
)
691751

@@ -696,3 +756,31 @@ class Cat:
696756

697757
assert isinstance(log["attributes"]["instance"], str)
698758
assert isinstance(log["attributes"]["dictionary"], str)
759+
assert isinstance(log["attributes"]["inhomogeneous_list"], str)
760+
assert isinstance(log["attributes"]["inhomogeneous_tuple"], str)
761+
762+
763+
@minimum_python_37
764+
def test_array_attributes_deep_copied_in_before_send(sentry_init, capture_envelopes):
765+
"""We don't surface user-held references to objects in attributes."""
766+
767+
strings = ["value1", "value2"]
768+
ints = (3, 2, 1, 4)
769+
770+
def before_send_log(log, _):
771+
assert log["attributes"]["string_list"] is not strings
772+
assert log["attributes"]["int_tuple"] is not ints
773+
774+
return log
775+
776+
sentry_init(enable_logs=True, before_send_log=before_send_log)
777+
778+
sentry_sdk.logger.warning(
779+
"Hello world!",
780+
attributes={
781+
"string_list": strings,
782+
"int_tuple": ints,
783+
},
784+
)
785+
786+
get_client().flush()

tests/test_metrics.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,12 +394,70 @@ def test_metric_attributes_override_scope_attributes(sentry_init, capture_envelo
394394
assert metric["attributes"]["temp.attribute"] == "value2"
395395

396396

397+
def test_log_array_attributes(sentry_init, capture_envelopes):
398+
"""Test homogeneous list and tuple attributes, and fallback for inhomogeneous collections."""
399+
400+
sentry_init()
401+
402+
envelopes = capture_envelopes()
403+
404+
with sentry_sdk.new_scope() as scope:
405+
scope.set_attribute("string_list.attribute", ["value1", "value2"])
406+
scope.set_attribute("int_tuple.attribute", (3, 2, 1, 4))
407+
scope.set_attribute("inhomogeneous_tuple.attribute", (3, 2.0, 1, 4)) # type: ignore[arg-type]
408+
409+
sentry_sdk.metrics.count(
410+
"test",
411+
1,
412+
attributes={
413+
"float_list.attribute": [3.0, 3.5, 4.2],
414+
"bool_tuple.attribute": (False, False, True),
415+
"inhomogeneous_list.attribute": [3.2, True, None],
416+
},
417+
)
418+
419+
get_client().flush()
420+
421+
assert len(envelopes) == 1
422+
assert len(envelopes[0].items) == 1
423+
item = envelopes[0].items[0]
424+
serialized_attributes = item.payload.json["items"][0]["attributes"]
425+
426+
assert serialized_attributes["string_list.attribute"] == {
427+
"value": ["value1", "value2"],
428+
"type": "string[]",
429+
}
430+
assert serialized_attributes["int_tuple.attribute"] == {
431+
"value": [3, 2, 1, 4],
432+
"type": "integer[]",
433+
}
434+
assert serialized_attributes["inhomogeneous_tuple.attribute"] == {
435+
"value": "(3, 2.0, 1, 4)",
436+
"type": "string",
437+
}
438+
439+
assert serialized_attributes["float_list.attribute"] == {
440+
"value": [3.0, 3.5, 4.2],
441+
"type": "double[]",
442+
}
443+
assert serialized_attributes["bool_tuple.attribute"] == {
444+
"value": [False, False, True],
445+
"type": "boolean[]",
446+
}
447+
assert serialized_attributes["inhomogeneous_list.attribute"] == {
448+
"value": "[3.2, True, None]",
449+
"type": "string",
450+
}
451+
452+
397453
def test_attributes_preserialized_in_before_send(sentry_init, capture_envelopes):
398-
"""We don't surface references to objects in attributes."""
454+
"""We don't surface user-held references to objects in attributes."""
399455

400456
def before_send_metric(metric, _):
401457
assert isinstance(metric["attributes"]["instance"], str)
402458
assert isinstance(metric["attributes"]["dictionary"], str)
459+
assert isinstance(metric["attributes"]["inhomogeneous_list"], str)
460+
assert isinstance(metric["attributes"]["inhomogeneous_tuple"], str)
403461

404462
return metric
405463

@@ -419,6 +477,8 @@ class Cat:
419477
attributes={
420478
"instance": instance,
421479
"dictionary": dictionary,
480+
"inhomogeneous_list": [3.2, True, None],
481+
"inhomogeneous_tuple": (3, 2.0, 1, 4),
422482
},
423483
)
424484

@@ -429,3 +489,29 @@ class Cat:
429489

430490
assert isinstance(metric["attributes"]["instance"], str)
431491
assert isinstance(metric["attributes"]["dictionary"], str)
492+
493+
494+
def test_array_attributes_deep_copied_in_before_send(sentry_init, capture_envelopes):
495+
"""We don't surface user-held references to objects in attributes."""
496+
497+
strings = ["value1", "value2"]
498+
ints = (3, 2, 1, 4)
499+
500+
def before_send_metric(metric, _):
501+
assert metric["attributes"]["string_list"] is not strings
502+
assert metric["attributes"]["int_tuple"] is not ints
503+
504+
return metric
505+
506+
sentry_init(before_send_metric=before_send_metric)
507+
508+
sentry_sdk.metrics.count(
509+
"test.counter",
510+
1,
511+
attributes={
512+
"string_list": strings,
513+
"int_tuple": ints,
514+
},
515+
)
516+
517+
get_client().flush()

0 commit comments

Comments
 (0)