Skip to content

Commit 209eb65

Browse files
authored
ref: Make logs, metrics go via scope (#5213)
### Description Logs and metrics were going through a completely separate pipeline compared to other events. Conceptually, they're still different from regular events since they're more lightweight and attribute-based (no data, contexts, etc., everything is an attribute) so separate handling makes sense. However, the pipeline should still conceptually resemble the one we use for other event types, for consistency. ## Current pipeline for non-log, non-metric events - The top-level API calls `scope.capture_XXX`. This merges the active scope stack (global + isolation + current scope) and calls `client.capture_XXX` with the resulting merged scope. - `client.capture_XXX` contains virtually all of the logic, most notably: - It applies the scope to the event by calling `scope.apply_to_event`, populating contexts, user data, etc. - It serializes the event. - It constructs the final envelope and sends it to the transport. ## This PR - Instead of the logging/metrics functionality going straight to `client.capture_XXX`, we call `scope.capture_XXX`, like we do for other event types, and then call `client.capture_XXX` from there. - Instead of inlining (and duplicating) all the attribute logic, `client.capture_XXX` now calls a new `scope.apply_to_telemetry` function internally (akin to `scope.apply_to_event`, but sets attributes instead). - The rest of the pipeline was left as-is for now, so metrics and logs are directly put into the batcher which itself serializes them. It's questionable whether making this part of the pipeline more similar to the event one would be a good idea since in Span First it'll be beneficial to have unserialized telemetry in the buffer, as is the case now with logs and metrics. Additionally: - Unify attribute-related types - Move duplicated `format_attribute` to utils Re: naming: I'm calling the new-style, attribute-based things simply "telemetry", since not all of them are events (for example, spans v2 which are coming with span streaming). Note: I might refactor further. I'd like to have proper classes for Logs and Metrics and give them ownership of how to serialize themselves, how to call before_send, etc., but need to see whether there's a nice way to do this without breaking backwards compat (the log/metric needs to be a dict in before_send_x). #### Issues <!-- * resolves: #1234 * resolves: LIN-1234 --> #### 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 76cae5f commit 209eb65

File tree

10 files changed

+210
-195
lines changed

10 files changed

+210
-195
lines changed

sentry_sdk/_log_batcher.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from datetime import datetime, timezone
55
from typing import Optional, List, Callable, TYPE_CHECKING, Any
66

7-
from sentry_sdk.utils import format_timestamp, safe_repr
7+
from sentry_sdk.utils import format_timestamp, safe_repr, serialize_attribute
88
from sentry_sdk.envelope import Envelope, Item, PayloadRef
99

1010
if TYPE_CHECKING:
@@ -115,17 +115,6 @@ def flush(self) -> None:
115115

116116
@staticmethod
117117
def _log_to_transport_format(log: "Log") -> "Any":
118-
def format_attribute(val: "int | float | str | bool") -> "Any":
119-
if isinstance(val, bool):
120-
return {"value": val, "type": "boolean"}
121-
if isinstance(val, int):
122-
return {"value": val, "type": "integer"}
123-
if isinstance(val, float):
124-
return {"value": val, "type": "double"}
125-
if isinstance(val, str):
126-
return {"value": val, "type": "string"}
127-
return {"value": safe_repr(val), "type": "string"}
128-
129118
if "sentry.severity_number" not in log["attributes"]:
130119
log["attributes"]["sentry.severity_number"] = log["severity_number"]
131120
if "sentry.severity_text" not in log["attributes"]:
@@ -138,7 +127,7 @@ def format_attribute(val: "int | float | str | bool") -> "Any":
138127
"level": str(log["severity_text"]),
139128
"body": str(log["body"]),
140129
"attributes": {
141-
k: format_attribute(v) for (k, v) in log["attributes"].items()
130+
k: serialize_attribute(v) for (k, v) in log["attributes"].items()
142131
},
143132
}
144133

sentry_sdk/_metrics_batcher.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from datetime import datetime, timezone
55
from typing import Optional, List, Callable, TYPE_CHECKING, Any, Union
66

7-
from sentry_sdk.utils import format_timestamp, safe_repr
7+
from sentry_sdk.utils import format_timestamp, safe_repr, serialize_attribute
88
from sentry_sdk.envelope import Envelope, Item, PayloadRef
99

1010
if TYPE_CHECKING:
@@ -96,25 +96,14 @@ def flush(self) -> None:
9696

9797
@staticmethod
9898
def _metric_to_transport_format(metric: "Metric") -> "Any":
99-
def format_attribute(val: "Union[int, float, str, bool]") -> "Any":
100-
if isinstance(val, bool):
101-
return {"value": val, "type": "boolean"}
102-
if isinstance(val, int):
103-
return {"value": val, "type": "integer"}
104-
if isinstance(val, float):
105-
return {"value": val, "type": "double"}
106-
if isinstance(val, str):
107-
return {"value": val, "type": "string"}
108-
return {"value": safe_repr(val), "type": "string"}
109-
11099
res = {
111100
"timestamp": metric["timestamp"],
112101
"trace_id": metric["trace_id"],
113102
"name": metric["name"],
114103
"type": metric["type"],
115104
"value": metric["value"],
116105
"attributes": {
117-
k: format_attribute(v) for (k, v) in metric["attributes"].items()
106+
k: serialize_attribute(v) for (k, v) in metric["attributes"].items()
118107
},
119108
}
120109

sentry_sdk/_types.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,39 @@ class SDKInfo(TypedDict):
215215
# TODO: Make a proper type definition for this (PRs welcome!)
216216
Hint = Dict[str, Any]
217217

218+
AttributeValue = (
219+
str | bool | float | int
220+
# TODO: relay support coming soon for
221+
# | list[str] | list[bool] | list[float] | list[int]
222+
)
223+
Attributes = dict[str, AttributeValue]
224+
225+
SerializedAttributeValue = TypedDict(
226+
# https://develop.sentry.dev/sdk/telemetry/attributes/#supported-types
227+
"SerializedAttributeValue",
228+
{
229+
"type": Literal[
230+
"string",
231+
"boolean",
232+
"double",
233+
"integer",
234+
# TODO: relay support coming soon for:
235+
# "string[]",
236+
# "boolean[]",
237+
# "double[]",
238+
# "integer[]",
239+
],
240+
"value": AttributeValue,
241+
},
242+
)
243+
218244
Log = TypedDict(
219245
"Log",
220246
{
221247
"severity_text": str,
222248
"severity_number": int,
223249
"body": str,
224-
"attributes": dict[str, str | bool | float | int],
250+
"attributes": Attributes,
225251
"time_unix_nano": int,
226252
"trace_id": Optional[str],
227253
"span_id": Optional[str],
@@ -230,14 +256,6 @@ class SDKInfo(TypedDict):
230256

231257
MetricType = Literal["counter", "gauge", "distribution"]
232258

233-
MetricAttributeValue = TypedDict(
234-
"MetricAttributeValue",
235-
{
236-
"value": Union[str, bool, float, int],
237-
"type": Literal["string", "boolean", "double", "integer"],
238-
},
239-
)
240-
241259
Metric = TypedDict(
242260
"Metric",
243261
{
@@ -248,7 +266,7 @@ class SDKInfo(TypedDict):
248266
"type": MetricType,
249267
"value": float,
250268
"unit": Optional[str],
251-
"attributes": dict[str, str | bool | float | int],
269+
"attributes": Attributes,
252270
},
253271
)
254272

sentry_sdk/client.py

Lines changed: 27 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,10 @@ def is_active(self) -> bool:
217217
def capture_event(self, *args: "Any", **kwargs: "Any") -> "Optional[str]":
218218
return None
219219

220-
def _capture_log(self, log: "Log") -> None:
220+
def _capture_log(self, log: "Log", scope: "Scope") -> None:
221221
pass
222222

223-
def _capture_metric(self, metric: "Metric") -> None:
223+
def _capture_metric(self, metric: "Metric", scope: "Scope") -> None:
224224
pass
225225

226226
def capture_session(self, *args: "Any", **kwargs: "Any") -> None:
@@ -898,132 +898,41 @@ def capture_event(
898898

899899
return return_value
900900

901-
def _capture_log(self, log: "Optional[Log]") -> None:
902-
if not has_logs_enabled(self.options) or log is None:
901+
def _capture_telemetry(
902+
self, telemetry: "Optional[Union[Log, Metric]]", ty: str, scope: "Scope"
903+
) -> None:
904+
# Capture attributes-based telemetry (logs, metrics, spansV2)
905+
if telemetry is None:
903906
return
904907

905-
current_scope = sentry_sdk.get_current_scope()
906-
isolation_scope = sentry_sdk.get_isolation_scope()
907-
908-
log["attributes"]["sentry.sdk.name"] = SDK_INFO["name"]
909-
log["attributes"]["sentry.sdk.version"] = SDK_INFO["version"]
910-
911-
server_name = self.options.get("server_name")
912-
if server_name is not None and SPANDATA.SERVER_ADDRESS not in log["attributes"]:
913-
log["attributes"][SPANDATA.SERVER_ADDRESS] = server_name
914-
915-
environment = self.options.get("environment")
916-
if environment is not None and "sentry.environment" not in log["attributes"]:
917-
log["attributes"]["sentry.environment"] = environment
918-
919-
release = self.options.get("release")
920-
if release is not None and "sentry.release" not in log["attributes"]:
921-
log["attributes"]["sentry.release"] = release
922-
923-
trace_context = current_scope.get_trace_context()
924-
trace_id = trace_context.get("trace_id")
925-
span_id = trace_context.get("span_id")
926-
927-
if trace_id is not None and log.get("trace_id") is None:
928-
log["trace_id"] = trace_id
929-
930-
if span_id is not None and log.get("span_id") is None:
931-
log["span_id"] = span_id
932-
933-
# The user, if present, is always set on the isolation scope.
934-
if self.should_send_default_pii() and isolation_scope._user is not None:
935-
for log_attribute, user_attribute in (
936-
("user.id", "id"),
937-
("user.name", "username"),
938-
("user.email", "email"),
939-
):
940-
if (
941-
user_attribute in isolation_scope._user
942-
and log_attribute not in log["attributes"]
943-
):
944-
log["attributes"][log_attribute] = isolation_scope._user[
945-
user_attribute
946-
]
947-
948-
# If debug is enabled, log the log to the console
949-
debug = self.options.get("debug", False)
950-
if debug:
951-
logger.debug(
952-
f"[Sentry Logs] [{log.get('severity_text')}] {log.get('body')}"
953-
)
954-
955-
before_send_log = get_before_send_log(self.options)
956-
if before_send_log is not None:
957-
log = before_send_log(log, {})
908+
scope.apply_to_telemetry(telemetry)
958909

959-
if log is None:
960-
return
910+
before_send = None
911+
if ty == "log":
912+
before_send = get_before_send_log(self.options)
913+
elif ty == "metric":
914+
before_send = get_before_send_metric(self.options) # type: ignore
961915

962-
if self.log_batcher:
963-
self.log_batcher.add(log)
916+
if before_send is not None:
917+
telemetry = before_send(telemetry, {}) # type: ignore
964918

965-
def _capture_metric(self, metric: "Optional[Metric]") -> None:
966-
if not has_metrics_enabled(self.options) or metric is None:
919+
if telemetry is None:
967920
return
968921

969-
current_scope = sentry_sdk.get_current_scope()
970-
isolation_scope = sentry_sdk.get_isolation_scope()
922+
batcher = None
923+
if ty == "log":
924+
batcher = self.log_batcher
925+
elif ty == "metric":
926+
batcher = self.metrics_batcher # type: ignore
971927

972-
metric["attributes"]["sentry.sdk.name"] = SDK_INFO["name"]
973-
metric["attributes"]["sentry.sdk.version"] = SDK_INFO["version"]
928+
if batcher is not None:
929+
batcher.add(telemetry) # type: ignore
974930

975-
server_name = self.options.get("server_name")
976-
if (
977-
server_name is not None
978-
and SPANDATA.SERVER_ADDRESS not in metric["attributes"]
979-
):
980-
metric["attributes"][SPANDATA.SERVER_ADDRESS] = server_name
981-
982-
environment = self.options.get("environment")
983-
if environment is not None and "sentry.environment" not in metric["attributes"]:
984-
metric["attributes"]["sentry.environment"] = environment
985-
986-
release = self.options.get("release")
987-
if release is not None and "sentry.release" not in metric["attributes"]:
988-
metric["attributes"]["sentry.release"] = release
989-
990-
trace_context = current_scope.get_trace_context()
991-
trace_id = trace_context.get("trace_id")
992-
span_id = trace_context.get("span_id")
993-
994-
metric["trace_id"] = trace_id or "00000000-0000-0000-0000-000000000000"
995-
if span_id is not None:
996-
metric["span_id"] = span_id
997-
998-
if self.should_send_default_pii() and isolation_scope._user is not None:
999-
for metric_attribute, user_attribute in (
1000-
("user.id", "id"),
1001-
("user.name", "username"),
1002-
("user.email", "email"),
1003-
):
1004-
if (
1005-
user_attribute in isolation_scope._user
1006-
and metric_attribute not in metric["attributes"]
1007-
):
1008-
metric["attributes"][metric_attribute] = isolation_scope._user[
1009-
user_attribute
1010-
]
1011-
1012-
debug = self.options.get("debug", False)
1013-
if debug:
1014-
logger.debug(
1015-
f"[Sentry Metrics] [{metric.get('type')}] {metric.get('name')}: {metric.get('value')}"
1016-
)
1017-
1018-
before_send_metric = get_before_send_metric(self.options)
1019-
if before_send_metric is not None:
1020-
metric = before_send_metric(metric, {})
1021-
1022-
if metric is None:
1023-
return
931+
def _capture_log(self, log: "Optional[Log]", scope: "Scope") -> None:
932+
self._capture_telemetry(log, "log", scope)
1024933

1025-
if self.metrics_batcher:
1026-
self.metrics_batcher.add(metric)
934+
def _capture_metric(self, metric: "Optional[Metric]", scope: "Scope") -> None:
935+
self._capture_telemetry(metric, "metric", scope)
1027936

1028937
def capture_session(
1029938
self,

sentry_sdk/integrations/logging.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ def _capture_log_from_record(
396396
attrs["logger.name"] = record.name
397397

398398
# noinspection PyProtectedMember
399-
client._capture_log(
399+
sentry_sdk.get_current_scope()._capture_log(
400400
{
401401
"severity_text": otel_severity_text,
402402
"severity_number": otel_severity_number,

sentry_sdk/integrations/loguru.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def loguru_sentry_logs_handler(message: "Message") -> None:
196196
else:
197197
attrs[f"sentry.message.parameter.{key}"] = safe_repr(value)
198198

199-
client._capture_log(
199+
sentry_sdk.get_current_scope()._capture_log(
200200
{
201201
"severity_text": otel_severity_text,
202202
"severity_number": otel_severity_number,

sentry_sdk/logger.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
# NOTE: this is the logger sentry exposes to users, not some generic logger.
22
import functools
33
import time
4-
from typing import Any
4+
from typing import Any, TYPE_CHECKING
55

6-
from sentry_sdk import get_client
6+
import sentry_sdk
77
from sentry_sdk.utils import safe_repr, capture_internal_exceptions
88

9+
if TYPE_CHECKING:
10+
from sentry_sdk._types import Attributes, Log
11+
12+
913
OTEL_RANGES = [
1014
# ((severity level range), severity text)
1115
# https://opentelemetry.io/docs/specs/otel/logs/data-model
@@ -28,37 +32,27 @@ def __missing__(self, key: str) -> str:
2832
def _capture_log(
2933
severity_text: str, severity_number: int, template: str, **kwargs: "Any"
3034
) -> None:
31-
client = get_client()
32-
3335
body = template
34-
attrs: "dict[str, str | bool | float | int]" = {}
36+
37+
attrs: "Attributes" = {}
38+
3539
if "attributes" in kwargs:
3640
attrs.update(kwargs.pop("attributes"))
41+
3742
for k, v in kwargs.items():
3843
attrs[f"sentry.message.parameter.{k}"] = v
44+
3945
if kwargs:
4046
# only attach template if there are parameters
4147
attrs["sentry.message.template"] = template
4248

4349
with capture_internal_exceptions():
4450
body = template.format_map(_dict_default_key(kwargs))
4551

46-
attrs = {
47-
k: (
48-
v
49-
if (
50-
isinstance(v, str)
51-
or isinstance(v, int)
52-
or isinstance(v, bool)
53-
or isinstance(v, float)
54-
)
55-
else safe_repr(v)
56-
)
57-
for (k, v) in attrs.items()
58-
}
59-
60-
# noinspection PyProtectedMember
61-
client._capture_log(
52+
for k, v in attrs.items():
53+
attrs[k] = v if isinstance(v, (str, int, bool, float)) else safe_repr(v)
54+
55+
sentry_sdk.get_current_scope()._capture_log(
6256
{
6357
"severity_text": severity_text,
6458
"severity_number": severity_number,
@@ -67,7 +61,7 @@ def _capture_log(
6761
"time_unix_nano": time.time_ns(),
6862
"trace_id": None,
6963
"span_id": None,
70-
},
64+
}
7165
)
7266

7367

0 commit comments

Comments
 (0)