Skip to content

Commit 2f4b028

Browse files
feat(logs): Make the logging integration send Sentry logs (#4143)
We have integrations that make the python logger create breadcrumbs and issues. This adds a third handler which creates Sentry logs on `logger.log` statements. Enable the logger with: ```python sentry_sdk.init( ... _experiments={ "enable_sentry_logs": True } ) some_logger = logging.Logger("some-logger") some_logger.info('Finished sending answer! #chunks=%s', chunks) ``` ![Screenshot 2025-03-17 at 4 12 27 PM](https://github.com/user-attachments/assets/0e8dcd46-6361-47c0-8662-389fcb924969) Refs #4150 --------- Co-authored-by: Anton Pirker <[email protected]>
1 parent 6f49bfb commit 2f4b028

File tree

5 files changed

+241
-91
lines changed

5 files changed

+241
-91
lines changed

sentry_sdk/_experimental_logger.py

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# NOTE: this is the logger sentry exposes to users, not some generic logger.
22
import functools
3+
import time
34
from typing import Any
45

56
from sentry_sdk import get_client, get_current_scope
@@ -9,7 +10,27 @@ def _capture_log(severity_text, severity_number, template, **kwargs):
910
# type: (str, int, str, **Any) -> None
1011
client = get_client()
1112
scope = get_current_scope()
12-
client.capture_log(scope, severity_text, severity_number, template, **kwargs)
13+
14+
attrs = {
15+
"sentry.message.template": template,
16+
} # type: dict[str, str | bool | float | int]
17+
if "attributes" in kwargs:
18+
attrs.update(kwargs.pop("attributes"))
19+
for k, v in kwargs.items():
20+
attrs[f"sentry.message.parameters.{k}"] = v
21+
22+
# noinspection PyProtectedMember
23+
client._capture_experimental_log(
24+
scope,
25+
{
26+
"severity_text": severity_text,
27+
"severity_number": severity_number,
28+
"attributes": attrs,
29+
"body": template.format(**kwargs),
30+
"time_unix_nano": time.time_ns(),
31+
"trace_id": None,
32+
},
33+
)
1334

1435

1536
trace = functools.partial(_capture_log, "trace", 1)

sentry_sdk/client.py

+20-37
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import json
22
import os
3-
import time
43
import uuid
54
import random
65
import socket
@@ -210,8 +209,8 @@ def capture_event(self, *args, **kwargs):
210209
# type: (*Any, **Any) -> Optional[str]
211210
return None
212211

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

217216
def capture_session(self, *args, **kwargs):
@@ -863,47 +862,36 @@ def capture_event(
863862

864863
return return_value
865864

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

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

876-
attrs = {
877-
"sentry.message.template": template,
878-
} # type: dict[str, str | bool | float | int]
879-
880-
kwargs_attributes = kwargs.get("attributes")
881-
if kwargs_attributes is not None:
882-
attrs.update(kwargs_attributes)
883-
884876
environment = self.options.get("environment")
885-
if environment is not None:
886-
attrs["sentry.environment"] = environment
877+
if environment is not None and "sentry.environment" not in log["attributes"]:
878+
log["attributes"]["sentry.environment"] = environment
887879

888880
release = self.options.get("release")
889-
if release is not None:
890-
attrs["sentry.release"] = release
881+
if release is not None and "sentry.release" not in log["attributes"]:
882+
log["attributes"]["sentry.release"] = release
891883

892-
span = scope.span
893-
if span is not None:
894-
attrs["sentry.trace.parent_span_id"] = span.span_id
884+
span = current_scope.span
885+
if span is not None and "sentry.trace.parent_span_id" not in log["attributes"]:
886+
log["attributes"]["sentry.trace.parent_span_id"] = span.span_id
895887

896-
for k, v in kwargs.items():
897-
attrs[f"sentry.message.parameters.{k}"] = v
898-
899-
log = {
900-
"severity_text": severity_text,
901-
"severity_number": severity_number,
902-
"body": template.format(**kwargs),
903-
"attributes": attrs,
904-
"time_unix_nano": time.time_ns(),
905-
"trace_id": None,
906-
} # type: Log
888+
if log.get("trace_id") is None:
889+
transaction = current_scope.transaction
890+
propagation_context = isolation_scope.get_active_propagation_context()
891+
if transaction is not None:
892+
log["trace_id"] = transaction.trace_id
893+
elif propagation_context is not None:
894+
log["trace_id"] = propagation_context.trace_id
907895

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

924-
propagation_context = scope.get_active_propagation_context()
925-
if propagation_context is not None:
926-
headers["trace_id"] = propagation_context.trace_id
927-
log["trace_id"] = propagation_context.trace_id
928-
929912
envelope = Envelope(headers=headers)
930913

931914
before_emit_log = self.options["_experiments"].get("before_emit_log")

sentry_sdk/consts.py

+1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ class CompressionAlgo(Enum):
7878
Callable[[str, MetricValue, MeasurementUnit, MetricTags], bool]
7979
],
8080
"metric_code_locations": Optional[bool],
81+
"enable_sentry_logs": Optional[bool],
8182
},
8283
total=False,
8384
)

sentry_sdk/integrations/logging.py

+107-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import json
12
import logging
23
from datetime import datetime, timezone
34
from fnmatch import fnmatch
45

56
import sentry_sdk
7+
from sentry_sdk.client import BaseClient
68
from sentry_sdk.utils import (
79
to_string,
810
event_from_exception,
@@ -11,7 +13,7 @@
1113
)
1214
from sentry_sdk.integrations import Integration
1315

14-
from typing import TYPE_CHECKING
16+
from typing import TYPE_CHECKING, Tuple
1517

1618
if TYPE_CHECKING:
1719
from collections.abc import MutableMapping
@@ -61,14 +63,23 @@ def ignore_logger(
6163
class LoggingIntegration(Integration):
6264
identifier = "logging"
6365

64-
def __init__(self, level=DEFAULT_LEVEL, event_level=DEFAULT_EVENT_LEVEL):
65-
# type: (Optional[int], Optional[int]) -> None
66+
def __init__(
67+
self,
68+
level=DEFAULT_LEVEL,
69+
event_level=DEFAULT_EVENT_LEVEL,
70+
sentry_logs_level=DEFAULT_LEVEL,
71+
):
72+
# type: (Optional[int], Optional[int], Optional[int]) -> None
6673
self._handler = None
6774
self._breadcrumb_handler = None
75+
self._sentry_logs_handler = None
6876

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

80+
if sentry_logs_level is not None:
81+
self._sentry_logs_handler = SentryLogsHandler(level=sentry_logs_level)
82+
7283
if event_level is not None:
7384
self._handler = EventHandler(level=event_level)
7485

@@ -83,6 +94,12 @@ def _handle_record(self, record):
8394
):
8495
self._breadcrumb_handler.handle(record)
8596

97+
if (
98+
self._sentry_logs_handler is not None
99+
and record.levelno >= self._sentry_logs_handler.level
100+
):
101+
self._sentry_logs_handler.handle(record)
102+
86103
@staticmethod
87104
def setup_once():
88105
# type: () -> None
@@ -296,3 +313,90 @@ def _breadcrumb_from_record(self, record):
296313
"timestamp": datetime.fromtimestamp(record.created, timezone.utc),
297314
"data": self._extra_from_record(record),
298315
}
316+
317+
318+
def _python_level_to_otel(record_level):
319+
# type: (int) -> Tuple[int, str]
320+
for py_level, otel_severity_number, otel_severity_text in [
321+
(50, 21, "fatal"),
322+
(40, 17, "error"),
323+
(30, 13, "warn"),
324+
(20, 9, "info"),
325+
(10, 5, "debug"),
326+
(5, 1, "trace"),
327+
]:
328+
if record_level >= py_level:
329+
return otel_severity_number, otel_severity_text
330+
return 0, "default"
331+
332+
333+
class SentryLogsHandler(_BaseHandler):
334+
"""
335+
A logging handler that records Sentry logs for each Python log record.
336+
337+
Note that you do not have to use this class if the logging integration is enabled, which it is by default.
338+
"""
339+
340+
def emit(self, record):
341+
# type: (LogRecord) -> Any
342+
with capture_internal_exceptions():
343+
self.format(record)
344+
if not self._can_record(record):
345+
return
346+
347+
client = sentry_sdk.get_client()
348+
if not client.is_active():
349+
return
350+
351+
if not client.options["_experiments"].get("enable_sentry_logs", False):
352+
return
353+
354+
SentryLogsHandler._capture_log_from_record(client, record)
355+
356+
@staticmethod
357+
def _capture_log_from_record(client, record):
358+
# type: (BaseClient, LogRecord) -> None
359+
scope = sentry_sdk.get_current_scope()
360+
otel_severity_number, otel_severity_text = _python_level_to_otel(record.levelno)
361+
attrs = {
362+
"sentry.message.template": (
363+
record.msg if isinstance(record.msg, str) else json.dumps(record.msg)
364+
),
365+
} # type: dict[str, str | bool | float | int]
366+
if record.args is not None:
367+
if isinstance(record.args, tuple):
368+
for i, arg in enumerate(record.args):
369+
attrs[f"sentry.message.parameters.{i}"] = (
370+
arg if isinstance(arg, str) else json.dumps(arg)
371+
)
372+
if record.lineno:
373+
attrs["code.line.number"] = record.lineno
374+
if record.pathname:
375+
attrs["code.file.path"] = record.pathname
376+
if record.funcName:
377+
attrs["code.function.name"] = record.funcName
378+
379+
if record.thread:
380+
attrs["thread.id"] = record.thread
381+
if record.threadName:
382+
attrs["thread.name"] = record.threadName
383+
384+
if record.process:
385+
attrs["process.pid"] = record.process
386+
if record.processName:
387+
attrs["process.executable.name"] = record.processName
388+
if record.name:
389+
attrs["logger.name"] = record.name
390+
391+
# noinspection PyProtectedMember
392+
client._capture_experimental_log(
393+
scope,
394+
{
395+
"severity_text": otel_severity_text,
396+
"severity_number": otel_severity_number,
397+
"body": record.message,
398+
"attributes": attrs,
399+
"time_unix_nano": int(record.created * 1e9),
400+
"trace_id": None,
401+
},
402+
)

0 commit comments

Comments
 (0)