Skip to content

Commit f7c494b

Browse files
authored
Capturing Initialization and Timeout errors for AWS Lambda Integration (#756)
Changes: Added a new wrapper decorator for post_init_error method to capture initialization error for AWS Lambda integration. Modified _wrap_handler decorator to include code which runs a parallel thread to capture timeout error. Modified _make_request_event_processor decorator to include execution duration as parameter. Added TimeoutThread class in utils.py which is useful to capture timeout error.
1 parent 62a6d32 commit f7c494b

File tree

3 files changed

+190
-9
lines changed

3 files changed

+190
-9
lines changed

sentry_sdk/integrations/aws_lambda.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from datetime import datetime, timedelta
22
from os import environ
33
import sys
4+
import json
45

56
from sentry_sdk.hub import Hub, _should_send_default_pii
67
from sentry_sdk._compat import reraise
@@ -9,6 +10,7 @@
910
capture_internal_exceptions,
1011
event_from_exception,
1112
logger,
13+
TimeoutThread,
1214
)
1315
from sentry_sdk.integrations import Integration
1416
from sentry_sdk.integrations._wsgi_common import _filter_headers
@@ -25,6 +27,45 @@
2527

2628
F = TypeVar("F", bound=Callable[..., Any])
2729

30+
# Constants
31+
TIMEOUT_WARNING_BUFFER = 1500 # Buffer time required to send timeout warning to Sentry
32+
MILLIS_TO_SECONDS = 1000.0
33+
34+
35+
def _wrap_init_error(init_error):
36+
# type: (F) -> F
37+
def sentry_init_error(*args, **kwargs):
38+
# type: (*Any, **Any) -> Any
39+
40+
hub = Hub.current
41+
integration = hub.get_integration(AwsLambdaIntegration)
42+
if integration is None:
43+
return init_error(*args, **kwargs)
44+
45+
# Fetch Initialization error details from arguments
46+
error = json.loads(args[1])
47+
48+
# If an integration is there, a client has to be there.
49+
client = hub.client # type: Any
50+
51+
with hub.push_scope() as scope:
52+
with capture_internal_exceptions():
53+
scope.clear_breadcrumbs()
54+
# Checking if there is any error/exception which is raised in the runtime
55+
# environment from arguments and, re-raising it to capture it as an event.
56+
if error.get("errorType"):
57+
exc_info = sys.exc_info()
58+
event, hint = event_from_exception(
59+
exc_info,
60+
client_options=client.options,
61+
mechanism={"type": "aws_lambda", "handled": False},
62+
)
63+
hub.capture_event(event, hint=hint)
64+
65+
return init_error(*args, **kwargs)
66+
67+
return sentry_init_error # type: ignore
68+
2869

2970
def _wrap_handler(handler):
3071
# type: (F) -> F
@@ -37,12 +78,31 @@ def sentry_handler(event, context, *args, **kwargs):
3778

3879
# If an integration is there, a client has to be there.
3980
client = hub.client # type: Any
81+
configured_time = context.get_remaining_time_in_millis()
4082

4183
with hub.push_scope() as scope:
4284
with capture_internal_exceptions():
4385
scope.clear_breadcrumbs()
4486
scope.transaction = context.function_name
45-
scope.add_event_processor(_make_request_event_processor(event, context))
87+
scope.add_event_processor(
88+
_make_request_event_processor(event, context, configured_time)
89+
)
90+
# Starting the Timeout thread only if the configured time is greater than Timeout warning
91+
# buffer and timeout_warning parameter is set True.
92+
if (
93+
integration.timeout_warning
94+
and configured_time > TIMEOUT_WARNING_BUFFER
95+
):
96+
waiting_time = (
97+
configured_time - TIMEOUT_WARNING_BUFFER
98+
) / MILLIS_TO_SECONDS
99+
100+
timeout_thread = TimeoutThread(
101+
waiting_time, configured_time / MILLIS_TO_SECONDS
102+
)
103+
104+
# Starting the thread to raise timeout warning exception
105+
timeout_thread.start()
46106

47107
try:
48108
return handler(event, context, *args, **kwargs)
@@ -73,6 +133,10 @@ def _drain_queue():
73133
class AwsLambdaIntegration(Integration):
74134
identifier = "aws_lambda"
75135

136+
def __init__(self, timeout_warning=False):
137+
# type: (bool) -> None
138+
self.timeout_warning = timeout_warning
139+
76140
@staticmethod
77141
def setup_once():
78142
# type: () -> None
@@ -126,6 +190,10 @@ def sentry_to_json(*args, **kwargs):
126190

127191
lambda_bootstrap.to_json = sentry_to_json
128192
else:
193+
lambda_bootstrap.LambdaRuntimeClient.post_init_error = _wrap_init_error(
194+
lambda_bootstrap.LambdaRuntimeClient.post_init_error
195+
)
196+
129197
old_handle_event_request = lambda_bootstrap.handle_event_request
130198

131199
def sentry_handle_event_request( # type: ignore
@@ -158,19 +226,23 @@ def inner(*args, **kwargs):
158226
)
159227

160228

161-
def _make_request_event_processor(aws_event, aws_context):
162-
# type: (Any, Any) -> EventProcessor
229+
def _make_request_event_processor(aws_event, aws_context, configured_timeout):
230+
# type: (Any, Any, Any) -> EventProcessor
163231
start_time = datetime.now()
164232

165233
def event_processor(event, hint, start_time=start_time):
166234
# type: (Event, Hint, datetime) -> Optional[Event]
235+
remaining_time_in_milis = aws_context.get_remaining_time_in_millis()
236+
exec_duration = configured_timeout - remaining_time_in_milis
237+
167238
extra = event.setdefault("extra", {})
168239
extra["lambda"] = {
169240
"function_name": aws_context.function_name,
170241
"function_version": aws_context.function_version,
171242
"invoked_function_arn": aws_context.invoked_function_arn,
172-
"remaining_time_in_millis": aws_context.get_remaining_time_in_millis(),
173243
"aws_request_id": aws_context.aws_request_id,
244+
"execution_duration_in_millis": exec_duration,
245+
"remaining_time_in_millis": remaining_time_in_milis,
174246
}
175247

176248
extra["cloudwatch logs"] = {

sentry_sdk/utils.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import logging
44
import os
55
import sys
6+
import time
7+
import threading
68

79
from datetime import datetime
810

@@ -871,3 +873,39 @@ def transaction_from_function(func):
871873

872874

873875
disable_capture_event = ContextVar("disable_capture_event")
876+
877+
878+
class ServerlessTimeoutWarning(Exception):
879+
"""Raised when a serverless method is about to reach its timeout."""
880+
881+
pass
882+
883+
884+
class TimeoutThread(threading.Thread):
885+
"""Creates a Thread which runs (sleeps) for a time duration equal to
886+
waiting_time and raises a custom ServerlessTimeout exception.
887+
"""
888+
889+
def __init__(self, waiting_time, configured_timeout):
890+
# type: (float, int) -> None
891+
threading.Thread.__init__(self)
892+
self.waiting_time = waiting_time
893+
self.configured_timeout = configured_timeout
894+
895+
def run(self):
896+
# type: () -> None
897+
898+
time.sleep(self.waiting_time)
899+
900+
integer_configured_timeout = int(self.configured_timeout)
901+
902+
# Setting up the exact integer value of configured time(in seconds)
903+
if integer_configured_timeout < self.configured_timeout:
904+
integer_configured_timeout = integer_configured_timeout + 1
905+
906+
# Raising Exception after timeout duration is reached
907+
raise ServerlessTimeoutWarning(
908+
"WARNING : Function is expected to get timed out. Configured timeout duration = {} seconds.".format(
909+
integer_configured_timeout
910+
)
911+
)

tests/integrations/aws_lambda/test_aws.py

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,23 @@
2222
import json
2323
from sentry_sdk.transport import HttpTransport
2424
25+
FLUSH_EVENT = True
26+
2527
class TestTransport(HttpTransport):
2628
def _send_event(self, event):
2729
# Delay event output like this to test proper shutdown
2830
# Note that AWS Lambda truncates the log output to 4kb, so you better
2931
# pray that your events are smaller than that or else tests start
3032
# failing.
31-
time.sleep(1)
33+
if FLUSH_EVENT:
34+
time.sleep(1)
3235
print("\\nEVENT:", json.dumps(event))
3336
34-
def init_sdk(**extra_init_args):
37+
def init_sdk(timeout_warning=False, **extra_init_args):
3538
sentry_sdk.init(
3639
dsn="https://[email protected]/123",
3740
transport=TestTransport,
38-
integrations=[AwsLambdaIntegration()],
41+
integrations=[AwsLambdaIntegration(timeout_warning=timeout_warning)],
3942
shutdown_timeout=10,
4043
**extra_init_args
4144
)
@@ -60,7 +63,7 @@ def run_lambda_function(tmpdir, lambda_client, request, relay_normalize):
6063
if request.param == "python3.8":
6164
pytest.xfail("Python 3.8 is currently broken")
6265

63-
def inner(code, payload):
66+
def inner(code, payload, syntax_check=True):
6467
runtime = request.param
6568
tmpdir.ensure_dir("lambda_tmp").remove()
6669
tmp = tmpdir.ensure_dir("lambda_tmp")
@@ -70,7 +73,8 @@ def inner(code, payload):
7073
# Check file for valid syntax first, and that the integration does not
7174
# crash when not running in Lambda (but rather a local deployment tool
7275
# such as chalice's)
73-
subprocess.check_call([sys.executable, str(tmp.join("test_lambda.py"))])
76+
if syntax_check:
77+
subprocess.check_call([sys.executable, str(tmp.join("test_lambda.py"))])
7478

7579
tmp.join("setup.cfg").write("[install]\nprefix=")
7680
subprocess.check_call([sys.executable, "setup.py", "sdist", "-d", str(tmpdir)])
@@ -88,6 +92,7 @@ def inner(code, payload):
8892
Handler="test_lambda.test_handler",
8993
Code={"ZipFile": tmpdir.join("ball.zip").read(mode="rb")},
9094
Description="Created as part of testsuite for getsentry/sentry-python",
95+
Timeout=4,
9196
)
9297

9398
@request.addfinalizer
@@ -124,6 +129,8 @@ def test_basic(run_lambda_function):
124129
+ dedent(
125130
"""
126131
init_sdk()
132+
133+
127134
def test_handler(event, context):
128135
raise Exception("something went wrong")
129136
"""
@@ -237,3 +244,67 @@ def test_handler(event, context):
237244
"query_string": {"bonkers": "true"},
238245
"url": "https://iwsz2c7uwi.execute-api.us-east-1.amazonaws.com/asd",
239246
}
247+
248+
249+
def test_init_error(run_lambda_function):
250+
events, response = run_lambda_function(
251+
LAMBDA_PRELUDE
252+
+ dedent(
253+
"""
254+
init_sdk()
255+
func()
256+
257+
def test_handler(event, context):
258+
return 0
259+
"""
260+
),
261+
b'{"foo": "bar"}',
262+
syntax_check=False,
263+
)
264+
265+
log_result = (base64.b64decode(response["LogResult"])).decode("utf-8")
266+
expected_text = "name 'func' is not defined"
267+
assert expected_text in log_result
268+
269+
270+
def test_timeout_error(run_lambda_function):
271+
events, response = run_lambda_function(
272+
LAMBDA_PRELUDE
273+
+ dedent(
274+
"""
275+
init_sdk(timeout_warning=True)
276+
FLUSH_EVENT=False
277+
278+
279+
def test_handler(event, context):
280+
time.sleep(10)
281+
return 0
282+
"""
283+
),
284+
b'{"foo": "bar"}',
285+
)
286+
287+
(event,) = events
288+
assert event["level"] == "error"
289+
(exception,) = event["exception"]["values"]
290+
assert exception["type"] == "ServerlessTimeoutWarning"
291+
assert (
292+
exception["value"]
293+
== "WARNING : Function is expected to get timed out. Configured timeout duration = 4 seconds."
294+
)
295+
296+
assert exception["mechanism"] == {"type": "threading", "handled": False}
297+
298+
assert event["extra"]["lambda"]["function_name"].startswith("test_function_")
299+
300+
logs_url = event["extra"]["cloudwatch logs"]["url"]
301+
assert logs_url.startswith("https://console.aws.amazon.com/cloudwatch/home?region=")
302+
assert not re.search("(=;|=$)", logs_url)
303+
assert event["extra"]["cloudwatch logs"]["log_group"].startswith(
304+
"/aws/lambda/test_function_"
305+
)
306+
307+
log_stream_re = "^[0-9]{4}/[0-9]{2}/[0-9]{2}/\\[[^\\]]+][a-f0-9]+$"
308+
log_stream = event["extra"]["cloudwatch logs"]["log_stream"]
309+
310+
assert re.match(log_stream_re, log_stream)

0 commit comments

Comments
 (0)