Skip to content

Commit 244658e

Browse files
authored
feat(api): Add Scope.set_attribute (#5256)
### Description Added new scope APIs: `set_attribute` and `remove_attribute` - Scopes now store their own attributes in `_attributes` - Attributes set on the scope are applied to logs and metrics Also: - unify pre-serialization of attribute values a bit (`format_attribute`) **Resources:** - [Spec](https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes) #### Issues * Closes #5210 * Closes https://linear.app/getsentry/issue/PY-2009/implement-set-attributes-api #### Reminders - Please add tests to validate your changes, and lint your code using `tox -e linters`. - Add GH Issue ID _&_ Linear ID (if applicable) - PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`) - For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr)
1 parent 209eb65 commit 244658e

File tree

7 files changed

+281
-13
lines changed

7 files changed

+281
-13
lines changed

sentry_sdk/logger.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Any, TYPE_CHECKING
55

66
import sentry_sdk
7-
from sentry_sdk.utils import safe_repr, capture_internal_exceptions
7+
from sentry_sdk.utils import format_attribute, safe_repr, capture_internal_exceptions
88

99
if TYPE_CHECKING:
1010
from sentry_sdk._types import Attributes, Log
@@ -34,29 +34,28 @@ def _capture_log(
3434
) -> None:
3535
body = template
3636

37-
attrs: "Attributes" = {}
37+
attributes: "Attributes" = {}
3838

3939
if "attributes" in kwargs:
40-
attrs.update(kwargs.pop("attributes"))
40+
provided_attributes = kwargs.pop("attributes") or {}
41+
for attribute, value in provided_attributes.items():
42+
attributes[attribute] = format_attribute(value)
4143

4244
for k, v in kwargs.items():
43-
attrs[f"sentry.message.parameter.{k}"] = v
45+
attributes[f"sentry.message.parameter.{k}"] = format_attribute(v)
4446

4547
if kwargs:
4648
# only attach template if there are parameters
47-
attrs["sentry.message.template"] = template
49+
attributes["sentry.message.template"] = format_attribute(template)
4850

4951
with capture_internal_exceptions():
5052
body = template.format_map(_dict_default_key(kwargs))
5153

52-
for k, v in attrs.items():
53-
attrs[k] = v if isinstance(v, (str, int, bool, float)) else safe_repr(v)
54-
5554
sentry_sdk.get_current_scope()._capture_log(
5655
{
5756
"severity_text": severity_text,
5857
"severity_number": severity_number,
59-
"attributes": attrs,
58+
"attributes": attributes,
6059
"body": body,
6160
"time_unix_nano": time.time_ns(),
6261
"trace_id": None,

sentry_sdk/metrics.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Any, Optional, TYPE_CHECKING, Union
88

99
import sentry_sdk
10-
from sentry_sdk.utils import safe_repr
10+
from sentry_sdk.utils import format_attribute, safe_repr
1111

1212
if TYPE_CHECKING:
1313
from sentry_sdk._types import Attributes, Metric, MetricType
@@ -24,7 +24,7 @@ def _capture_metric(
2424

2525
if attributes:
2626
for k, v in attributes.items():
27-
attrs[k] = v if isinstance(v, (str, int, bool, float)) else safe_repr(v)
27+
attrs[k] = format_attribute(v)
2828

2929
metric: "Metric" = {
3030
"timestamp": time.time(),

sentry_sdk/scope.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
disable_capture_event,
4747
event_from_exception,
4848
exc_info_from_error,
49+
format_attribute,
4950
logger,
5051
has_logs_enabled,
5152
has_metrics_enabled,
@@ -73,6 +74,8 @@
7374
from typing_extensions import Unpack
7475

7576
from sentry_sdk._types import (
77+
Attributes,
78+
AttributeValue,
7679
Breadcrumb,
7780
BreadcrumbHint,
7881
ErrorProcessor,
@@ -230,6 +233,7 @@ class Scope:
230233
"_type",
231234
"_last_event_id",
232235
"_flags",
236+
"_attributes",
233237
)
234238

235239
def __init__(
@@ -296,6 +300,8 @@ def __copy__(self) -> "Scope":
296300

297301
rv._flags = deepcopy(self._flags)
298302

303+
rv._attributes = self._attributes.copy()
304+
299305
return rv
300306

301307
@classmethod
@@ -684,6 +690,8 @@ def clear(self) -> None:
684690
self._last_event_id: "Optional[str]" = None
685691
self._flags: "Optional[FlagBuffer]" = None
686692

693+
self._attributes: "Attributes" = {}
694+
687695
@_attr_setter
688696
def level(self, value: "LogLevelStr") -> None:
689697
"""
@@ -1487,6 +1495,13 @@ def _apply_global_attributes_to_telemetry(
14871495
if release is not None and "sentry.release" not in attributes:
14881496
attributes["sentry.release"] = release
14891497

1498+
def _apply_scope_attributes_to_telemetry(
1499+
self, telemetry: "Union[Log, Metric]"
1500+
) -> None:
1501+
for attribute, value in self._attributes.items():
1502+
if attribute not in telemetry["attributes"]:
1503+
telemetry["attributes"][attribute] = value
1504+
14901505
def _apply_user_attributes_to_telemetry(
14911506
self, telemetry: "Union[Log, Metric]"
14921507
) -> None:
@@ -1621,8 +1636,9 @@ def apply_to_telemetry(self, telemetry: "Union[Log, Metric]") -> None:
16211636
if telemetry.get("span_id") is None and span_id:
16221637
telemetry["span_id"] = span_id
16231638

1624-
self._apply_global_attributes_to_telemetry(telemetry)
1639+
self._apply_scope_attributes_to_telemetry(telemetry)
16251640
self._apply_user_attributes_to_telemetry(telemetry)
1641+
self._apply_global_attributes_to_telemetry(telemetry)
16261642

16271643
def update_from_scope(self, scope: "Scope") -> None:
16281644
"""Update the scope with another scope's data."""
@@ -1668,6 +1684,8 @@ def update_from_scope(self, scope: "Scope") -> None:
16681684
else:
16691685
for flag in scope._flags.get():
16701686
self._flags.set(flag["flag"], flag["result"])
1687+
if scope._attributes:
1688+
self._attributes.update(scope._attributes)
16711689

16721690
def update_from_kwargs(
16731691
self,
@@ -1677,6 +1695,7 @@ def update_from_kwargs(
16771695
contexts: "Optional[Dict[str, Dict[str, Any]]]" = None,
16781696
tags: "Optional[Dict[str, str]]" = None,
16791697
fingerprint: "Optional[List[str]]" = None,
1698+
attributes: "Optional[Attributes]" = None,
16801699
) -> None:
16811700
"""Update the scope's attributes."""
16821701
if level is not None:
@@ -1691,6 +1710,8 @@ def update_from_kwargs(
16911710
self._tags.update(tags)
16921711
if fingerprint is not None:
16931712
self._fingerprint = fingerprint
1713+
if attributes is not None:
1714+
self._attributes.update(attributes)
16941715

16951716
def __repr__(self) -> str:
16961717
return "<%s id=%s name=%s type=%s>" % (
@@ -1710,6 +1731,22 @@ def flags(self) -> "FlagBuffer":
17101731
self._flags = FlagBuffer(capacity=max_flags)
17111732
return self._flags
17121733

1734+
def set_attribute(self, attribute: str, value: "AttributeValue") -> None:
1735+
"""
1736+
Set an attribute on the scope.
1737+
1738+
Any attributes-based telemetry (logs, metrics) captured while this scope
1739+
is active will inherit attributes set on the scope.
1740+
"""
1741+
self._attributes[attribute] = format_attribute(value)
1742+
1743+
def remove_attribute(self, attribute: str) -> None:
1744+
"""Remove an attribute if set on the scope. No-op if there is no such attribute."""
1745+
try:
1746+
del self._attributes[attribute]
1747+
except KeyError:
1748+
pass
1749+
17131750

17141751
@contextmanager
17151752
def new_scope() -> "Generator[Scope, None, None]":

sentry_sdk/utils.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2047,7 +2047,25 @@ def get_before_send_metric(
20472047
)
20482048

20492049

2050+
def format_attribute(val: "Any") -> "AttributeValue":
2051+
"""
2052+
Turn unsupported attribute value types into an AttributeValue.
2053+
2054+
We do this as soon as a user-provided attribute is set, to prevent spans,
2055+
logs, metrics and similar from having live references to various objects.
2056+
2057+
Note: This is not the final attribute value format. Before they're sent,
2058+
they're serialized further into the actual format the protocol expects:
2059+
https://develop.sentry.dev/sdk/telemetry/attributes/
2060+
"""
2061+
if isinstance(val, (bool, int, float, str)):
2062+
return val
2063+
2064+
return safe_repr(val)
2065+
2066+
20502067
def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue":
2068+
"""Serialize attribute value to the transport format."""
20512069
if isinstance(val, bool):
20522070
return {"value": val, "type": "boolean"}
20532071
if isinstance(val, int):
@@ -2057,5 +2075,6 @@ def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue":
20572075
if isinstance(val, str):
20582076
return {"value": val, "type": "string"}
20592077

2060-
# Coerce to string if we don't know what to do with the value
2078+
# Coerce to string if we don't know what to do with the value. This should
2079+
# never happen as we pre-format early in format_attribute, but let's be safe.
20612080
return {"value": safe_repr(val), "type": "string"}

tests/test_attributes.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import sentry_sdk
2+
3+
from tests.test_metrics import envelopes_to_metrics
4+
5+
6+
def test_scope_precedence(sentry_init, capture_envelopes):
7+
# Order of precedence, from most important to least:
8+
# 1. telemetry attributes (directly supplying attributes on creation or using set_attribute)
9+
# 2. current scope attributes
10+
# 3. isolation scope attributes
11+
# 4. global scope attributes
12+
sentry_init()
13+
14+
envelopes = capture_envelopes()
15+
16+
global_scope = sentry_sdk.get_global_scope()
17+
global_scope.set_attribute("global.attribute", "global")
18+
global_scope.set_attribute("overwritten.attribute", "global")
19+
20+
isolation_scope = sentry_sdk.get_isolation_scope()
21+
isolation_scope.set_attribute("isolation.attribute", "isolation")
22+
isolation_scope.set_attribute("overwritten.attribute", "isolation")
23+
24+
current_scope = sentry_sdk.get_current_scope()
25+
current_scope.set_attribute("current.attribute", "current")
26+
current_scope.set_attribute("overwritten.attribute", "current")
27+
28+
sentry_sdk.metrics.count("test", 1)
29+
sentry_sdk.get_client().flush()
30+
31+
metrics = envelopes_to_metrics(envelopes)
32+
(metric,) = metrics
33+
34+
assert metric["attributes"]["global.attribute"] == "global"
35+
assert metric["attributes"]["isolation.attribute"] == "isolation"
36+
assert metric["attributes"]["current.attribute"] == "current"
37+
38+
assert metric["attributes"]["overwritten.attribute"] == "current"
39+
40+
41+
def test_telemetry_precedence(sentry_init, capture_envelopes):
42+
# Order of precedence, from most important to least:
43+
# 1. telemetry attributes (directly supplying attributes on creation or using set_attribute)
44+
# 2. current scope attributes
45+
# 3. isolation scope attributes
46+
# 4. global scope attributes
47+
sentry_init()
48+
49+
envelopes = capture_envelopes()
50+
51+
global_scope = sentry_sdk.get_global_scope()
52+
global_scope.set_attribute("global.attribute", "global")
53+
global_scope.set_attribute("overwritten.attribute", "global")
54+
55+
isolation_scope = sentry_sdk.get_isolation_scope()
56+
isolation_scope.set_attribute("isolation.attribute", "isolation")
57+
isolation_scope.set_attribute("overwritten.attribute", "isolation")
58+
59+
current_scope = sentry_sdk.get_current_scope()
60+
current_scope.set_attribute("current.attribute", "current")
61+
current_scope.set_attribute("overwritten.attribute", "current")
62+
63+
sentry_sdk.metrics.count(
64+
"test",
65+
1,
66+
attributes={
67+
"telemetry.attribute": "telemetry",
68+
"overwritten.attribute": "telemetry",
69+
},
70+
)
71+
72+
sentry_sdk.get_client().flush()
73+
74+
metrics = envelopes_to_metrics(envelopes)
75+
(metric,) = metrics
76+
77+
assert metric["attributes"]["global.attribute"] == "global"
78+
assert metric["attributes"]["isolation.attribute"] == "isolation"
79+
assert metric["attributes"]["current.attribute"] == "current"
80+
assert metric["attributes"]["telemetry.attribute"] == "telemetry"
81+
82+
assert metric["attributes"]["overwritten.attribute"] == "telemetry"
83+
84+
85+
def test_attribute_out_of_scope(sentry_init, capture_envelopes):
86+
sentry_init()
87+
88+
envelopes = capture_envelopes()
89+
90+
with sentry_sdk.new_scope() as scope:
91+
scope.set_attribute("outofscope.attribute", "out of scope")
92+
93+
sentry_sdk.metrics.count("test", 1)
94+
95+
sentry_sdk.get_client().flush()
96+
97+
metrics = envelopes_to_metrics(envelopes)
98+
(metric,) = metrics
99+
100+
assert "outofscope.attribute" not in metric["attributes"]
101+
102+
103+
def test_remove_attribute(sentry_init, capture_envelopes):
104+
sentry_init()
105+
106+
envelopes = capture_envelopes()
107+
108+
with sentry_sdk.new_scope() as scope:
109+
scope.set_attribute("some.attribute", 123)
110+
scope.remove_attribute("some.attribute")
111+
112+
sentry_sdk.metrics.count("test", 1)
113+
114+
sentry_sdk.get_client().flush()
115+
116+
metrics = envelopes_to_metrics(envelopes)
117+
(metric,) = metrics
118+
119+
assert "some.attribute" not in metric["attributes"]

tests/test_logs.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,3 +548,52 @@ def record_lost_event(reason, data_category=None, item=None, *, quantity=1):
548548
}
549549
]
550550
}
551+
552+
553+
@minimum_python_37
554+
def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes):
555+
sentry_init(enable_logs=True)
556+
557+
envelopes = capture_envelopes()
558+
559+
global_scope = sentry_sdk.get_global_scope()
560+
global_scope.set_attribute("global.attribute", "value")
561+
562+
with sentry_sdk.new_scope() as scope:
563+
scope.set_attribute("current.attribute", "value")
564+
sentry_sdk.logger.warning("Hello, world!")
565+
566+
sentry_sdk.logger.warning("Hello again!")
567+
568+
get_client().flush()
569+
570+
logs = envelopes_to_logs(envelopes)
571+
(log1, log2) = logs
572+
573+
assert log1["attributes"]["global.attribute"] == "value"
574+
assert log1["attributes"]["current.attribute"] == "value"
575+
576+
assert log2["attributes"]["global.attribute"] == "value"
577+
assert "current.attribute" not in log2["attributes"]
578+
579+
580+
@minimum_python_37
581+
def test_log_attributes_override_scope_attributes(sentry_init, capture_envelopes):
582+
sentry_init(enable_logs=True)
583+
584+
envelopes = capture_envelopes()
585+
586+
with sentry_sdk.new_scope() as scope:
587+
scope.set_attribute("durable.attribute", "value1")
588+
scope.set_attribute("temp.attribute", "value1")
589+
sentry_sdk.logger.warning(
590+
"Hello, world!", attributes={"temp.attribute": "value2"}
591+
)
592+
593+
get_client().flush()
594+
595+
logs = envelopes_to_logs(envelopes)
596+
(log,) = logs
597+
598+
assert log["attributes"]["durable.attribute"] == "value1"
599+
assert log["attributes"]["temp.attribute"] == "value2"

0 commit comments

Comments
 (0)