Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
270be59
ref: Make logs, metrics go via scope
sentrivana Dec 10, 2025
c46c32d
Merge branch 'master' into ivana/make-logs-and-metrics-go-via-scope
sentrivana Dec 11, 2025
9be698f
Merge branch 'master' into ivana/make-logs-and-metrics-go-via-scope
sentrivana Dec 16, 2025
ddb5838
Merge branch 'master' into ivana/make-logs-and-metrics-go-via-scope
sentrivana Dec 16, 2025
329ea2c
typing fixes
sentrivana Dec 16, 2025
6c1897a
giving up on typing dispatches
sentrivana Dec 16, 2025
8ffc78a
span_id is not an attribute anymore
sentrivana Dec 16, 2025
2405282
move format_attributes to utils
sentrivana Dec 16, 2025
a0a603b
attr list values
sentrivana Dec 16, 2025
a2fee7c
.
sentrivana Dec 16, 2025
87537f0
add link
sentrivana Dec 16, 2025
8c32833
remove custom trace_id, span_id setting
sentrivana Dec 16, 2025
3747c5f
rename, fix
sentrivana Dec 17, 2025
4dc5dd8
.
sentrivana Dec 17, 2025
6acb510
simplify
sentrivana Dec 17, 2025
2bf0f3a
.
sentrivana Dec 17, 2025
52bcfee
Merge branch 'master' into ivana/make-logs-and-metrics-go-via-scope
sentrivana Dec 17, 2025
c83d76c
.
sentrivana Dec 17, 2025
a62d90b
first attrs, then before_send
sentrivana Dec 17, 2025
649c3ad
dont pass opts around
sentrivana Dec 17, 2025
3fffa7f
simplify dispatcher
sentrivana Dec 18, 2025
7ccbd5a
no support for array attributes yet
sentrivana Dec 18, 2025
d2cbf74
Merge branch 'master' into ivana/make-logs-and-metrics-go-via-scope
sentrivana Dec 18, 2025
9d8b3d2
put preserialization back
sentrivana Dec 18, 2025
0b25f46
preserialize after template, parameters
sentrivana Dec 18, 2025
3eac621
Merge branch 'master' into ivana/make-logs-and-metrics-go-via-scope
sentrivana Dec 18, 2025
ef5f9fb
fix
sentrivana Dec 18, 2025
603da41
Merge branch 'master' into ivana/make-logs-and-metrics-go-via-scope
sentrivana Dec 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 2 additions & 13 deletions sentry_sdk/_log_batcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"]:
Expand All @@ -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()
},
}

Expand Down
15 changes: 2 additions & 13 deletions sentry_sdk/_metrics_batcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -96,25 +96,14 @@ 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"],
"name": metric["name"],
"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()
},
}

Expand Down
38 changes: 28 additions & 10 deletions sentry_sdk/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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",
{
Expand All @@ -248,7 +266,7 @@ class SDKInfo(TypedDict):
"type": MetricType,
"value": float,
"unit": Optional[str],
"attributes": dict[str, str | bool | float | int],
"attributes": Attributes,
},
)

Expand Down
145 changes: 27 additions & 118 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scope here is the merged global + iso + current scope.

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:
Expand Down Expand Up @@ -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(
Copy link
Contributor Author

@sentrivana sentrivana Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now essentially a dispatcher on the ty argument. Unfortunately we need the explicit type and can't look at the actual type of the thing coming in since it's all just dicts. I'll be exploring whether we can make this better without introducing breaking changes.

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,
Expand Down
2 changes: 1 addition & 1 deletion sentry_sdk/integrations/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion sentry_sdk/integrations/loguru.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 16 additions & 22 deletions sentry_sdk/logger.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -28,37 +32,27 @@ 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

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,
Expand All @@ -67,7 +61,7 @@ def _capture_log(
"time_unix_nano": time.time_ns(),
"trace_id": None,
"span_id": None,
},
}
)


Expand Down
Loading
Loading