Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 25 additions & 10 deletions sentry_sdk/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,36 @@ class SDKInfo(TypedDict):
# TODO: Make a proper type definition for this (PRs welcome!)
Hint = Dict[str, Any]

AttributeValue = (
str | bool | float | int | list[str] | list[bool] | list[float] | list[int]
)
Comment on lines +218 to +220
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm probably out of the loop on this. According to the develop docs we don't currently support array attributes.
Either we should update the develop docs or this type is too permissive, at least when it comes to what services after the SDK support.

https://develop.sentry.dev/sdk/telemetry/logs/#default-attributes

Attributes = dict[str, AttributeValue]

SerializedAttributeValue = TypedDict(
# https://develop.sentry.dev/sdk/telemetry/attributes/#supported-types
"SerializedAttributeValue",
{
"type": Literal[
"string",
"boolean",
"double",
"integer",
"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 +253,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 +263,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_getter = {
"log": lambda: get_before_send_log(self.options),
"metric": lambda: get_before_send_metric(self.options),
}.get(ty)
Comment on lines +910 to +913
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this return a function instead of a lambda that returns a function?

Copy link
Member

Choose a reason for hiding this comment

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

imo this causes useless allocations, just use an if else


if self.log_batcher:
self.log_batcher.add(log)
if before_send_getter is not None:
before_send = before_send_getter()
if before_send is not None:
telemetry = before_send(telemetry, {}) # type: ignore[arg-type]

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: "Optional[Union[LogBatcher, MetricsBatcher]]" = {
"log": self.log_batcher,
"metric": self.metrics_batcher,
}.get(ty)

metric["attributes"]["sentry.sdk.name"] = SDK_INFO["name"]
metric["attributes"]["sentry.sdk.version"] = SDK_INFO["version"]
if batcher:
batcher.add(telemetry) # type: ignore[arg-type]

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
32 changes: 10 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,10 +32,9 @@ 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():
Expand All @@ -43,22 +46,7 @@ def _capture_log(
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(
sentry_sdk.get_current_scope()._capture_log(
{
"severity_text": severity_text,
"severity_number": severity_number,
Expand All @@ -67,7 +55,7 @@ def _capture_log(
"time_unix_nano": time.time_ns(),
"trace_id": None,
"span_id": None,
},
}
)


Expand Down
Loading
Loading