Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(logs): Make the logging integration send Sentry logs #4143

Merged
merged 18 commits into from
Mar 27, 2025
Merged
23 changes: 22 additions & 1 deletion sentry_sdk/_experimental_logger.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# NOTE: this is the logger sentry exposes to users, not some generic logger.
import functools
import time
from typing import Any

from sentry_sdk import get_client, get_current_scope
Expand All @@ -9,7 +10,27 @@ def _capture_log(severity_text, severity_number, template, **kwargs):
# type: (str, int, str, **Any) -> None
client = get_client()
scope = get_current_scope()
client.capture_log(scope, severity_text, severity_number, template, **kwargs)

attrs = {
"sentry.message.template": template,
} # type: dict[str, str | bool | float | int]
if "attributes" in kwargs:
attrs.update(kwargs.pop("attributes"))
for k, v in kwargs.items():
attrs[f"sentry.message.parameters.{k}"] = v

# noinspection PyProtectedMember
client._capture_experimental_log(
scope,
{
"severity_text": severity_text,
"severity_number": severity_number,
"attributes": attrs,
"body": template.format(**kwargs),
"time_unix_nano": time.time_ns(),
"trace_id": None,
},
)


trace = functools.partial(_capture_log, "trace", 1)
Expand Down
57 changes: 20 additions & 37 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
import os
import time
import uuid
import random
import socket
Expand Down Expand Up @@ -210,8 +209,8 @@ def capture_event(self, *args, **kwargs):
# type: (*Any, **Any) -> Optional[str]
return None

def capture_log(self, scope, severity_text, severity_number, template, **kwargs):
# type: (Scope, str, int, str, **Any) -> None
def _capture_experimental_log(self, scope, log):
# type: (Scope, Log) -> None
pass

def capture_session(self, *args, **kwargs):
Expand Down Expand Up @@ -863,47 +862,36 @@ def capture_event(

return return_value

def capture_log(self, scope, severity_text, severity_number, template, **kwargs):
# type: (Scope, str, int, str, **Any) -> None
def _capture_experimental_log(self, current_scope, log):
# type: (Scope, Log) -> None
logs_enabled = self.options["_experiments"].get("enable_sentry_logs", False)
if not logs_enabled:
return
isolation_scope = current_scope.get_isolation_scope()

headers = {
"sent_at": format_timestamp(datetime.now(timezone.utc)),
} # type: dict[str, object]

attrs = {
"sentry.message.template": template,
} # type: dict[str, str | bool | float | int]

kwargs_attributes = kwargs.get("attributes")
if kwargs_attributes is not None:
attrs.update(kwargs_attributes)

environment = self.options.get("environment")
if environment is not None:
attrs["sentry.environment"] = 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:
attrs["sentry.release"] = release
if release is not None and "sentry.release" not in log["attributes"]:
log["attributes"]["sentry.release"] = release

span = scope.span
if span is not None:
attrs["sentry.trace.parent_span_id"] = span.span_id
span = current_scope.span
if span is not None and "sentry.trace.parent_span_id" not in log["attributes"]:
log["attributes"]["sentry.trace.parent_span_id"] = span.span_id

for k, v in kwargs.items():
attrs[f"sentry.message.parameters.{k}"] = v

log = {
"severity_text": severity_text,
"severity_number": severity_number,
"body": template.format(**kwargs),
"attributes": attrs,
"time_unix_nano": time.time_ns(),
"trace_id": None,
} # type: Log
if log.get("trace_id") is None:
transaction = current_scope.transaction
propagation_context = isolation_scope.get_active_propagation_context()
if transaction is not None:
log["trace_id"] = transaction.trace_id
elif propagation_context is not None:
log["trace_id"] = propagation_context.trace_id

# If debug is enabled, log the log to the console
debug = self.options.get("debug", False)
Expand All @@ -917,15 +905,10 @@ def capture_log(self, scope, severity_text, severity_number, template, **kwargs)
"fatal": logging.CRITICAL,
}
logger.log(
severity_text_to_logging_level.get(severity_text, logging.DEBUG),
severity_text_to_logging_level.get(log["severity_text"], logging.DEBUG),
f'[Sentry Logs] {log["body"]}',
)

propagation_context = scope.get_active_propagation_context()
if propagation_context is not None:
headers["trace_id"] = propagation_context.trace_id
log["trace_id"] = propagation_context.trace_id

envelope = Envelope(headers=headers)

before_emit_log = self.options["_experiments"].get("before_emit_log")
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class CompressionAlgo(Enum):
Callable[[str, MetricValue, MeasurementUnit, MetricTags], bool]
],
"metric_code_locations": Optional[bool],
"enable_sentry_logs": Optional[bool],
},
total=False,
)
Expand Down
110 changes: 107 additions & 3 deletions sentry_sdk/integrations/logging.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import json
import logging
from datetime import datetime, timezone
from fnmatch import fnmatch

import sentry_sdk
from sentry_sdk.client import BaseClient
from sentry_sdk.utils import (
to_string,
event_from_exception,
Expand All @@ -11,7 +13,7 @@
)
from sentry_sdk.integrations import Integration

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Tuple

if TYPE_CHECKING:
from collections.abc import MutableMapping
Expand Down Expand Up @@ -61,14 +63,23 @@ def ignore_logger(
class LoggingIntegration(Integration):
identifier = "logging"

def __init__(self, level=DEFAULT_LEVEL, event_level=DEFAULT_EVENT_LEVEL):
# type: (Optional[int], Optional[int]) -> None
def __init__(
self,
level=DEFAULT_LEVEL,
event_level=DEFAULT_EVENT_LEVEL,
sentry_logs_level=DEFAULT_LEVEL,
):
# type: (Optional[int], Optional[int], Optional[int]) -> None
self._handler = None
self._breadcrumb_handler = None
self._sentry_logs_handler = None

if level is not None:
self._breadcrumb_handler = BreadcrumbHandler(level=level)

if sentry_logs_level is not None:
self._sentry_logs_handler = SentryLogsHandler(level=sentry_logs_level)

if event_level is not None:
self._handler = EventHandler(level=event_level)

Expand All @@ -83,6 +94,12 @@ def _handle_record(self, record):
):
self._breadcrumb_handler.handle(record)

if (
self._sentry_logs_handler is not None
and record.levelno >= self._sentry_logs_handler.level
):
self._sentry_logs_handler.handle(record)

@staticmethod
def setup_once():
# type: () -> None
Expand Down Expand Up @@ -296,3 +313,90 @@ def _breadcrumb_from_record(self, record):
"timestamp": datetime.fromtimestamp(record.created, timezone.utc),
"data": self._extra_from_record(record),
}


def _python_level_to_otel(record_level):
# type: (int) -> Tuple[int, str]
for py_level, otel_severity_number, otel_severity_text in [
(50, 21, "fatal"),
(40, 17, "error"),
(30, 13, "warn"),
(20, 9, "info"),
(10, 5, "debug"),
(5, 1, "trace"),
]:
if record_level >= py_level:
return otel_severity_number, otel_severity_text
return 0, "default"


class SentryLogsHandler(_BaseHandler):
"""
A logging handler that records Sentry logs for each Python log record.

Note that you do not have to use this class if the logging integration is enabled, which it is by default.
"""

def emit(self, record):
# type: (LogRecord) -> Any
with capture_internal_exceptions():
self.format(record)
if not self._can_record(record):
return

client = sentry_sdk.get_client()
if not client.is_active():
return

if not client.options["_experiments"].get("enable_sentry_logs", False):
return

SentryLogsHandler._capture_log_from_record(client, record)

@staticmethod
def _capture_log_from_record(client, record):
# type: (BaseClient, LogRecord) -> None
scope = sentry_sdk.get_current_scope()
otel_severity_number, otel_severity_text = _python_level_to_otel(record.levelno)
attrs = {
"sentry.message.template": (
record.msg if isinstance(record.msg, str) else json.dumps(record.msg)
),
} # type: dict[str, str | bool | float | int]
if record.args is not None:
if isinstance(record.args, tuple):
for i, arg in enumerate(record.args):
attrs[f"sentry.message.parameters.{i}"] = (
arg if isinstance(arg, str) else json.dumps(arg)
)
if record.lineno:
attrs["code.line.number"] = record.lineno
if record.pathname:
attrs["code.file.path"] = record.pathname
if record.funcName:
attrs["code.function.name"] = record.funcName

if record.thread:
attrs["thread.id"] = record.thread
if record.threadName:
attrs["thread.name"] = record.threadName

if record.process:
attrs["process.pid"] = record.process
if record.processName:
attrs["process.executable.name"] = record.processName
if record.name:
attrs["logger.name"] = record.name

# noinspection PyProtectedMember
client._capture_experimental_log(
scope,
{
"severity_text": otel_severity_text,
"severity_number": otel_severity_number,
"body": record.message,
"attributes": attrs,
"time_unix_nano": int(record.created * 1e9),
"trace_id": None,
},
)
Loading
Loading