Skip to content

Commit 8aef633

Browse files
committed
Merge branch 'master' into sentry-sdk-2.0
2 parents ac90b7e + 500e087 commit 8aef633

File tree

10 files changed

+255
-9
lines changed

10 files changed

+255
-9
lines changed

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,40 @@
8484
- Passing a function to `sentry_sdk.init`'s `transport` keyword argument has been deprecated. If you wish to provide a custom transport, please pass a `sentry_sdk.transport.Transport` instance or a subclass.
8585
- The parameter `propagate_hub` in `ThreadingIntegration()` was deprecated and renamed to `propagate_scope`.
8686

87+
88+
89+
## 1.43.0
90+
91+
### Various fixes & improvements
92+
93+
- Add optional `keep_alive` (#2842) by @sentrivana
94+
95+
If you're experiencing frequent network issues between the SDK and Sentry,
96+
you can try turning on TCP keep-alive:
97+
98+
```python
99+
import sentry_sdk
100+
101+
sentry_sdk.init(
102+
# ...your usual settings...
103+
keep_alive=True,
104+
)
105+
```
106+
107+
- Add support for Celery Redbeat cron tasks (#2643) by @kwigley
108+
109+
The SDK now supports the Redbeat scheduler in addition to the default
110+
Celery Beat scheduler for auto instrumenting crons. See
111+
[the docs](https://docs.sentry.io/platforms/python/integrations/celery/crons/)
112+
for more information about how to set this up.
113+
114+
- `aws_event` can be an empty list (#2849) by @sentrivana
115+
- Re-export `Event` in `types.py` (#2829) by @szokeasaurusrex
116+
- Small API docs improvement (#2828) by @antonpirker
117+
- Fixed OpenAI tests (#2834) by @antonpirker
118+
- Bump `checkouts/data-schemas` from `ed078ed` to `8232f17` (#2832) by @dependabot
119+
120+
87121
## 1.42.0
88122

89123
### Various fixes & improvements

sentry_sdk/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ def __init__(
274274
ignore_errors=[], # type: Sequence[Union[type, str]] # noqa: B006
275275
max_request_body_size="medium", # type: str
276276
socket_options=None, # type: Optional[List[Tuple[int, int, int | bytes]]]
277+
keep_alive=False, # type: bool
277278
before_send=None, # type: Optional[EventProcessor]
278279
before_breadcrumb=None, # type: Optional[BreadcrumbProcessor]
279280
debug=None, # type: Optional[bool]

sentry_sdk/integrations/aws_lambda.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def sentry_handler(aws_event, aws_context, *args, **kwargs):
8080
# will be the same for all events in the list, since they're all hitting
8181
# the lambda in the same request.)
8282

83-
if isinstance(aws_event, list):
83+
if isinstance(aws_event, list) and len(aws_event) >= 1:
8484
request_data = aws_event[0]
8585
batch_size = len(aws_event)
8686
else:

sentry_sdk/integrations/celery.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
except ImportError:
5858
raise DidNotEnable("Celery not installed")
5959

60+
try:
61+
from redbeat.schedulers import RedBeatScheduler # type: ignore
62+
except ImportError:
63+
RedBeatScheduler = None
64+
6065

6166
CELERY_CONTROL_FLOW_EXCEPTIONS = (Retry, Ignore, Reject)
6267

@@ -77,6 +82,7 @@ def __init__(
7782

7883
if monitor_beat_tasks:
7984
_patch_beat_apply_entry()
85+
_patch_redbeat_maybe_due()
8086
_setup_celery_beat_signals()
8187

8288
@staticmethod
@@ -525,6 +531,61 @@ def sentry_apply_entry(*args, **kwargs):
525531
Scheduler.apply_entry = sentry_apply_entry
526532

527533

534+
def _patch_redbeat_maybe_due():
535+
# type: () -> None
536+
537+
if RedBeatScheduler is None:
538+
return
539+
540+
original_maybe_due = RedBeatScheduler.maybe_due
541+
542+
def sentry_maybe_due(*args, **kwargs):
543+
# type: (*Any, **Any) -> None
544+
scheduler, schedule_entry = args
545+
app = scheduler.app
546+
547+
celery_schedule = schedule_entry.schedule
548+
monitor_name = schedule_entry.name
549+
550+
integration = sentry_sdk.get_client().get_integration(CeleryIntegration)
551+
if integration is None:
552+
return original_maybe_due(*args, **kwargs)
553+
554+
if match_regex_list(monitor_name, integration.exclude_beat_tasks):
555+
return original_maybe_due(*args, **kwargs)
556+
557+
# When tasks are started from Celery Beat, make sure each task has its own trace.
558+
scope = Scope.get_isolation_scope()
559+
scope.set_new_propagation_context()
560+
561+
monitor_config = _get_monitor_config(celery_schedule, app, monitor_name)
562+
563+
is_supported_schedule = bool(monitor_config)
564+
if is_supported_schedule:
565+
headers = schedule_entry.options.pop("headers", {})
566+
headers.update(
567+
{
568+
"sentry-monitor-slug": monitor_name,
569+
"sentry-monitor-config": monitor_config,
570+
}
571+
)
572+
573+
check_in_id = capture_checkin(
574+
monitor_slug=monitor_name,
575+
monitor_config=monitor_config,
576+
status=MonitorStatus.IN_PROGRESS,
577+
)
578+
headers.update({"sentry-monitor-check-in-id": check_in_id})
579+
580+
# Set the Sentry configuration in the options of the ScheduleEntry.
581+
# Those will be picked up in `apply_async` and added to the headers.
582+
schedule_entry.options["headers"] = headers
583+
584+
return original_maybe_due(*args, **kwargs)
585+
586+
RedBeatScheduler.maybe_due = sentry_maybe_due
587+
588+
528589
def _setup_celery_beat_signals():
529590
# type: () -> None
530591
task_success.connect(crons_task_success)

sentry_sdk/transport.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
11
from abc import ABC, abstractmethod
22
import io
3-
import warnings
4-
import urllib3
5-
import certifi
63
import gzip
4+
import socket
75
import time
6+
import warnings
87
from datetime import datetime, timedelta, timezone
98
from collections import defaultdict
109
from urllib.request import getproxies
1110

11+
import urllib3
12+
import certifi
13+
1214
from sentry_sdk.consts import EndpointType
1315
from sentry_sdk.utils import Dsn, logger, capture_internal_exceptions
1416
from sentry_sdk.worker import BackgroundWorker
1517
from sentry_sdk.envelope import Envelope, Item, PayloadRef
16-
1718
from sentry_sdk._types import TYPE_CHECKING
1819

1920
if TYPE_CHECKING:
2021
from typing import Any
2122
from typing import Callable
2223
from typing import Dict
2324
from typing import Iterable
25+
from typing import List
2426
from typing import Optional
2527
from typing import Tuple
2628
from typing import Type
@@ -35,6 +37,21 @@
3537
DataCategory = Optional[str]
3638

3739

40+
KEEP_ALIVE_SOCKET_OPTIONS = []
41+
for option in [
42+
(socket.SOL_SOCKET, lambda: getattr(socket, "SO_KEEPALIVE"), 1), # noqa: B009
43+
(socket.SOL_TCP, lambda: getattr(socket, "TCP_KEEPIDLE"), 45), # noqa: B009
44+
(socket.SOL_TCP, lambda: getattr(socket, "TCP_KEEPINTVL"), 10), # noqa: B009
45+
(socket.SOL_TCP, lambda: getattr(socket, "TCP_KEEPCNT"), 6), # noqa: B009
46+
]:
47+
try:
48+
KEEP_ALIVE_SOCKET_OPTIONS.append((option[0], option[1](), option[2]))
49+
except AttributeError:
50+
# a specific option might not be available on specific systems,
51+
# e.g. TCP_KEEPIDLE doesn't exist on macOS
52+
pass
53+
54+
3855
class Transport(ABC):
3956
"""Baseclass for all transports.
4057
@@ -424,8 +441,22 @@ def _get_pool_options(self, ca_certs):
424441
"ca_certs": ca_certs or certifi.where(),
425442
}
426443

427-
if self.options["socket_options"]:
428-
options["socket_options"] = self.options["socket_options"]
444+
socket_options = None # type: Optional[List[Tuple[int, int, int | bytes]]]
445+
446+
if self.options["socket_options"] is not None:
447+
socket_options = self.options["socket_options"]
448+
449+
if self.options["keep_alive"]:
450+
if socket_options is None:
451+
socket_options = []
452+
453+
used_options = {(o[0], o[1]) for o in socket_options}
454+
for default_option in KEEP_ALIVE_SOCKET_OPTIONS:
455+
if (default_option[0], default_option[1]) not in used_options:
456+
socket_options.append(default_option)
457+
458+
if socket_options is not None:
459+
options["socket_options"] = socket_options
429460

430461
return options
431462

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def get_file_text(file_name):
4949
"beam": ["apache-beam>=2.12"],
5050
"bottle": ["bottle>=0.12.13"],
5151
"celery": ["celery>=3"],
52+
"celery-redbeat": ["celery-redbeat>=2"],
5253
"chalice": ["chalice>=1.16.0"],
5354
"clickhouse-driver": ["clickhouse-driver>=0.2.0"],
5455
"django": ["django>=1.8"],

tests/integrations/aws_lambda/test_aws.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ def test_handler(event, context):
481481
True,
482482
2,
483483
),
484+
(b"[]", False, 1),
484485
],
485486
)
486487
def test_non_dict_event(

tests/integrations/celery/test_celery_beat_crons.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
_get_humanized_interval,
1212
_get_monitor_config,
1313
_patch_beat_apply_entry,
14+
_patch_redbeat_maybe_due,
1415
crons_task_success,
1516
crons_task_failure,
1617
crons_task_retry,
@@ -440,3 +441,59 @@ def test_exclude_beat_tasks_option(
440441
# The original Scheduler.apply_entry() is called, AND _get_monitor_config is called.
441442
assert fake_apply_entry.call_count == 1
442443
assert _get_monitor_config.call_count == 1
444+
445+
446+
@pytest.mark.parametrize(
447+
"task_name,exclude_beat_tasks,task_in_excluded_beat_tasks",
448+
[
449+
["some_task_name", ["xxx", "some_task.*"], True],
450+
["some_task_name", ["xxx", "some_other_task.*"], False],
451+
],
452+
)
453+
def test_exclude_redbeat_tasks_option(
454+
task_name, exclude_beat_tasks, task_in_excluded_beat_tasks
455+
):
456+
"""
457+
Test excluding Celery RedBeat tasks from automatic instrumentation.
458+
"""
459+
fake_maybe_due = MagicMock()
460+
461+
fake_redbeat_scheduler = MagicMock()
462+
fake_redbeat_scheduler.maybe_due = fake_maybe_due
463+
464+
fake_integration = MagicMock()
465+
fake_integration.exclude_beat_tasks = exclude_beat_tasks
466+
467+
fake_client = MagicMock()
468+
fake_client.get_integration.return_value = fake_integration
469+
470+
fake_schedule_entry = MagicMock()
471+
fake_schedule_entry.name = task_name
472+
473+
fake_get_monitor_config = MagicMock()
474+
475+
with mock.patch(
476+
"sentry_sdk.integrations.celery.RedBeatScheduler", fake_redbeat_scheduler
477+
) as RedBeatScheduler: # noqa: N806
478+
with mock.patch(
479+
"sentry_sdk.integrations.celery.sentry_sdk.get_client",
480+
return_value=fake_client,
481+
):
482+
with mock.patch(
483+
"sentry_sdk.integrations.celery._get_monitor_config",
484+
fake_get_monitor_config,
485+
) as _get_monitor_config:
486+
# Mimic CeleryIntegration patching of RedBeatScheduler.maybe_due()
487+
_patch_redbeat_maybe_due()
488+
# Mimic Celery RedBeat calling a task from the RedBeat schedule
489+
RedBeatScheduler.maybe_due(fake_redbeat_scheduler, fake_schedule_entry)
490+
491+
if task_in_excluded_beat_tasks:
492+
# Only the original RedBeatScheduler.maybe_due() is called, _get_monitor_config is NOT called.
493+
assert fake_maybe_due.call_count == 1
494+
_get_monitor_config.assert_not_called()
495+
496+
else:
497+
# The original RedBeatScheduler.maybe_due() is called, AND _get_monitor_config is called.
498+
assert fake_maybe_due.call_count == 1
499+
assert _get_monitor_config.call_count == 1

tests/test_transport.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
from werkzeug.wrappers import Request, Response
1313

1414
from sentry_sdk import Hub, Client, add_breadcrumb, capture_message, Scope
15-
from sentry_sdk.transport import _parse_rate_limits
1615
from sentry_sdk.envelope import Envelope, parse_json
16+
from sentry_sdk.transport import KEEP_ALIVE_SOCKET_OPTIONS, _parse_rate_limits
1717
from sentry_sdk.integrations.logging import LoggingIntegration, ignore_logger
1818

1919

@@ -164,6 +164,66 @@ def test_socket_options(make_client):
164164
assert options["socket_options"] == socket_options
165165

166166

167+
def test_keep_alive_true(make_client):
168+
client = make_client(keep_alive=True)
169+
170+
options = client.transport._get_pool_options([])
171+
assert options["socket_options"] == KEEP_ALIVE_SOCKET_OPTIONS
172+
173+
174+
def test_keep_alive_off_by_default(make_client):
175+
client = make_client()
176+
options = client.transport._get_pool_options([])
177+
assert "socket_options" not in options
178+
179+
180+
def test_socket_options_override_keep_alive(make_client):
181+
socket_options = [
182+
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
183+
(socket.SOL_TCP, socket.TCP_KEEPINTVL, 10),
184+
(socket.SOL_TCP, socket.TCP_KEEPCNT, 6),
185+
]
186+
187+
client = make_client(socket_options=socket_options, keep_alive=False)
188+
189+
options = client.transport._get_pool_options([])
190+
assert options["socket_options"] == socket_options
191+
192+
193+
def test_socket_options_merge_with_keep_alive(make_client):
194+
socket_options = [
195+
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 42),
196+
(socket.SOL_TCP, socket.TCP_KEEPINTVL, 42),
197+
]
198+
199+
client = make_client(socket_options=socket_options, keep_alive=True)
200+
201+
options = client.transport._get_pool_options([])
202+
try:
203+
assert options["socket_options"] == [
204+
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 42),
205+
(socket.SOL_TCP, socket.TCP_KEEPINTVL, 42),
206+
(socket.SOL_TCP, socket.TCP_KEEPIDLE, 45),
207+
(socket.SOL_TCP, socket.TCP_KEEPCNT, 6),
208+
]
209+
except AttributeError:
210+
assert options["socket_options"] == [
211+
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 42),
212+
(socket.SOL_TCP, socket.TCP_KEEPINTVL, 42),
213+
(socket.SOL_TCP, socket.TCP_KEEPCNT, 6),
214+
]
215+
216+
217+
def test_socket_options_override_defaults(make_client):
218+
# If socket_options are set to [], this doesn't mean the user doesn't want
219+
# any custom socket_options, but rather that they want to disable the urllib3
220+
# socket option defaults, so we need to set this and not ignore it.
221+
client = make_client(socket_options=[])
222+
223+
options = client.transport._get_pool_options([])
224+
assert options["socket_options"] == []
225+
226+
167227
def test_transport_infinite_loop(capturing_server, request, make_client):
168228
client = make_client(
169229
debug=True,

0 commit comments

Comments
 (0)