10000 feat: AWS lambda support (#76) · etherscan-io/sentry-python@540fc06 · GitHub
[go: up one dir, main page]

Skip to content

Commit 540fc06

Browse files
authored
feat: AWS lambda support (getsentry#76)
* feat: AWS lambda support * fix: remove unused import * ref: Dont cause syntaxerror * fix: Only test aws_lambda in aws_lambda build * fix: Pass environment variables s.t. tests actually run
1 parent da4150a commit 540fc06

File tree

5 files changed

+327
-9
lines changed

5 files changed

+327
-9
lines changed

sentry_sdk/integrations/_wsgi.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -228,15 +228,19 @@ def event_processor(event, hint):
228228
request_info["env"] = dict(get_environ(environ))
229229

230230
if "headers" not in request_info:
231-
request_info["headers"] = dict(get_headers(environ))
232-
if not _should_send_default_pii():
233-
request_info["headers"] = {
234-
k: v
235-
for k, v in request_info["headers"].items()
236-
if k.lower().replace("_", "-")
237-
not in ("set-cookie", "cookie", "authorization")
238-
}
231+
request_info["headers"] = _filter_headers(dict(get_headers(environ)))
239232

240233
return event
241234

242235
return event_processor
236+
237+
238+
def _filter_headers(headers):
239+
if _should_send_default_pii():
240+
return headers
241+
242+
return {
243+
k: v
244+
for k, v in headers.items()
245+
if k.lower().replace("_", "-") not in ("set-cookie", "cookie", "authorization")
246+
}

sentry_sdk/integrations/aws_lambda.py

Lines changed: 109 additions & 0 deletions
9E81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import sys
2+
3+
from sentry_sdk import configure_scope
4+
from sentry_sdk.hub import Hub, _should_send_default_pii
5+
from sentry_sdk._compat import reraise
6+
from sentry_sdk.utils import (
7+
AnnotatedValue,
8+
capture_internal_exceptions,
9+
event_from_exception,
10+
)
11+
from sentry_sdk.integrations import Integration
12+
from sentry_sdk.integrations._wsgi import _filter_headers
13+
14+
import __main__ as lambda_bootstrap
15+
16+
17+
class AwsLambdaIntegration(Integration):
18+
identifier = "aws_lambda"
19+
20+
def install(self):
21+
old_make_final_handler = lambda_bootstrap.make_final_handler
22+
23+
def sentry_make_final_handler(*args, **kwargs):
24+
handler = old_make_final_handler(*args, **kwargs)
25+
26+
def sentry_handler(event, context, *args, **kwargs):
27+
hub = Hub.current
28+
29+
with hub.push_scope():
30+
with capture_internal_exceptions():
31+
with configure_scope() as scope:
32+
scope.transaction = context.function_name
33+
scope.add_event_processor(
34+
_make_request_event_processor(event, context)
35+
)
36+
37+
try:
38+
return handler(event, context, *args, **kwargs)
39+
except Exception:
40+
exc_info = sys.exc_info()
41+
event, hint = event_from_exception(
42+
exc_info,
43+
with_locals=hub.client.options["with_locals"],
44+
mechanism={"type": "aws_lambda", "handled": False},
45+
)
46+
47+
hub.capture_event(event, hint=hint)
48+
reraise(*exc_info)
49+
finally:
50+
client = hub.client
51+
# Flush out the event queue before AWS kills the
52+
# process. This is not threadsafe.
53+
if client is not None:
54+
# make new transport with empty queue
55+
new_transport = client.transport.copy()
56+
client.close()
57+
client.transport = new_transport
58+
59+
return sentry_handler
60+
61+
lambda_bootstrap.make_final_handler = sentry_make_final_handler
62+
63+
64+
def _make_request_event_processor(aws_event, aws_context):
65+
def event_processor(event, hint):
66+
extra = event.setdefault("extra", {})
67+
extra["lambda"] = {
68+
"remaining_time_in_millis": aws_context.get_remaining_time_in_millis(),
69+
"function_name": aws_context.function_name,
70+
"function_version": aws_context.function_version,
71+
"invoked_function_arn": aws_context.invoked_function_arn,
72+
"aws_request_id": aws_context.aws_request_id,
73+
}
74+
75+
request = event.setdefault("request", {})
76+
77+
if "httpMethod" in aws_event and "method" not in request:
78+
request["method"] = aws_event["httpMethod"]
79+
if "url" not in request:
80+
request["url"] = _get_url(aws_event, aws_context)
81+
if "queryStringParameters" in aws_event and "query_string" not in request:
82+
request["query_string"] = aws_event["queryStringParameters"]
83+
if "headers" in aws_event and "headers" not in request:
84+
request["headers"] = _filter_headers(aws_event["headers"])
85+
if aws_event.get("body", None):
86+
# Unfortunately couldn't find a way to get structured body from AWS
87+
# event. Meaning every body is unstructured to us.
88+
request["data"] = AnnotatedValue("", {"rem": [["!raw", "x", 0, 0]]})
89+
90+
if _should_send_default_pii():
91+
user_info = event.setdefault("user", {})
92+
if "id" not in user_info:
93+
user_info["id"] = aws_event.get("identity", {}).get("userArn")
94+
if "ip_address" not in user_info:
95+
user_info["ip_address"] = aws_event.get("identity", {}).get("sourceIp")
96+
97+
return event
98+
99+
return event_processor
100+
101+
102+
def _get_url(event, context):
103+
path = event.get("path", None)
104+
headers = event.get("headers", {})
105+
host = headers.get("Host", None)
106+
proto = headers.get("X-Forwarded-Proto", None)
107+
if proto and host and path:
108+
return "{}://{}{}".format(proto, host, path)
109+
return "awslambda:///{}".format(context.function_name)

sentry_sdk/transport.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ def kill(self):
6262
"""Forcefully kills the transport."""
6363
pass
6464

65+
def copy(self):
66+
"""Copy the transport.
67+
68+
The returned transport should behave completely independent from the
69+
previous one. It still may share HTTP connection pools, but not share
70+
any state such as internal queues.
71+
"""
72+
return self
73+
6574
def __del__(self):
6675
try:
6776
self.kill()
@@ -83,6 +92,7 @@ def __init__(self, options):
8392
)
8493
self._disabled_until = None
8594
self._retry = urllib3.util.Retry()
95+
self.options = options
8696

8797
def _send_event(self, event):
8898
if self._disabled_until is not None:
@@ -143,6 +153,11 @@ def kill(self):
143153
logger.debug("Killing HTTP transport")
144154
self._worker.kill()
145155

156+
def copy(self):
157+
transport = type(self)(self.options)
158+
transport._pool = self._pool
159+
return transport
160+
146161

147162
class _FunctionTransport(Transport):
148163
def __init__(self, func):
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import base64
2+
import json
3+
import os
4+
import shutil
5+
import subprocess
6+
import sys
7+
import uuid
8+
9+
import pytest
10+
11+
boto3 = pytest.importorskip("boto3")
12+
13+
LAMBDA_TEMPLATE = """
14+
from __future__ import print_function
15+
16+
from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration
17+
import sentry_sdk
18+
import json
19+
from sentry_sdk.transport import Transport
20+
21+
class TestTransport(Transport):
22+
def __init__(self):
23+
Transport.__init__(self)
24+
self._queue = []
25+
26+
def capture_event(self, event):
27+
self._queue.append(event)
28+
29+
def shutdown(self, timeout, callback=None):
30+
# Delay event output like this to test proper shutdown
31+
for event in self._queue:
32+
print("EVENT:", json.dumps(event))
33+
34+
sentry_sdk.init(
35+
"http://bogus@example.com/2",
36+
transport=TestTransport(),
37+
integrations=[AwsLambdaIntegration()],
38+
**{extra_init_args}
39+
)
40+
41+
42+
def test_handler(event, context):
43+
{code}
44+
"""
45+
46+
47+
@pytest.fixture
48+
def lambda_client():
49+
if "AWS_ACCESS_KEY_ID" not in os.environ:
50+
pytest.skip("AWS environ vars not set")
51+
52+
return boto3.client(
53+
"lambda",
54+
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
55+
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
56+
region_name="us-east-1",
57+
)
58+
59+
60+
@pytest.fixture(params=["python3.6", "python2.7"])
61+
def run_lambda_function(tmpdir, lambda_client, request, assert_semaphore_acceptance):
62+
def inner(lambda_body, payload, extra_init_args=None):
63+
tmpdir.ensure_dir("lambda_tmp").remove()
64+
tmp = tmpdir.ensure_dir("lambda_tmp")
65+
66+
# https://docs.aws.amazon.com/lambda/latest/dg/lambda-python-how-to-create-deployment-package.html
67+
tmp.join("test_lambda.py").write(
68+
LAMBDA_TEMPLATE.format(
69+
code="\n".join(" " + x.strip() for x in lambda_body.splitlines()),
70+
extra_init_args=repr(extra_init_args or {}),
71+
)
72+
)
73+
tmp.join("setup.cfg").write("[install]\nprefix=")
74+
subprocess.check_call([sys.executable, "setup.py", "sdist", "-d", str(tmpdir)])
75+
subprocess.check_call("pip install ../*.tar.gz -t .", cwd=str(tmp), shell=True)
76+
shutil.make_archive(tmpdir.join("ball"), "zip", str(tmp))
77+
78+
fn_name = "test_function_{}".format(uuid.uuid4())
79+
80+
lambda_client.create_function(
81+
FunctionName=fn_name,
82+
Runtime=request.param,
83+
Role=os.environ["AWS_IAM_ROLE"],
84+
Handler="test_lambda.test_handler",
85+
Code={"ZipFile": tmpdir.join("ball.zip").read(mode="rb")},
86+
Description="Created as part of testsuite for getsentry/sentry-python",
87+
)
88+
89+
@request.addfinalizer
90+
def delete_function():
91+
lambda_client.delete_function(FunctionName=fn_name)
92+
93+
response = lambda_client.invoke(
94+
FunctionName=fn_name,
95+
InvocationType="RequestResponse",
96+
LogType="Tail",
97+
Payload=payload,
98+
)
99+
100+
assert 200 <= response["StatusCode"] < 300, response
101+
102+
events = []
103+
104+
for line in base64.b64decode(response["LogResult"]).splitlines():
105+
print("AWS:", line)
106+
if not line.startswith(b"EVENT: "):
107+
continue
108+
line = line[len(b"EVENT: ") :]
109+
events.append(json.loads(line.decode("utf-8")))
110+
assert_semaphore_acceptance(events[-1])
111+
112+
return events, response
113+
114+
return inner
115+
116+
117+
def test_basic(run_lambda_function):
118+
events, response = run_lambda_function(
119+
'raise Exception("something went wrong")\n', b'{"foo": "bar"}'
120+
)
121+
122+
assert response["FunctionError"] == "Unhandled"
123+
124+
event, = events
125+
assert event["level"] == "error"
126+
exception, = event["exception"]["values"]
127+
assert exception["type"] == "Exception"
128+
assert exception["value"] == "something went wrong"
129+
130+
frame1, = exception["stacktrace"]["frames"]
131+
assert frame1["filename"] == "test_lambda.py"
132+
assert frame1["abs_path"] == "/var/task/test_lambda.py"
133+
assert frame1["function"] == "test_handler"
134+
135+
assert frame1["in_app"] is True
136+
137+
assert exception["mechanism"] == {"type": "aws_lambda", "handled": False}
138+
139+
assert event["extra"]["lambda"]["function_name"].startswith("test_function_")
140+
141+
142+
def test_request_data(run_lambda_function):
143+
events, _response = run_lambda_function(
144+
'sentry_sdk.capture_message("hi")\nreturn "ok"',
145+
payload=b"""
146+
{
147+
"resource": "/asd",
148+
"path": "/asd",
149+
"httpMethod": "GET",
150+
"headers": {
151+
"Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com",
152+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:62.0) Gecko/20100101 Firefox/62.0",
153+
"X-Forwarded-Proto": "https"
154+
},
155+
"queryStringParameters": {
156+
"bonkers": "true"
157+
},
158+
"pathParameters": null,
159+
"stageVariables": null,
160+
"requestContext": {
161+
"identity": {
162+
"sourceIp": "213.47.147.207",
163+
"userArn": "42"
164+
}
165+
},
166+
"body": null,
167+
"isBase64Encoded": false
168+
}
169+
""",
170+
)
171+
172+
event, = events
173+
174+
assert event["request"] == {
175+
"headers": {
176+
"Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com",
177+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:62.0) Gecko/20100101 Firefox/62.0",
178+
"X-Forwarded-Proto": "https",
179+
},
180+
"method": "GET",
181+
"query_string": {"bonkers": "true"},
182+
"url": "https://iwsz2c7uwi.execute-api.us-east-1.amazonaws.com/asd",
183+
}

tox.ini

Lines changed: 8 additions & 1 deletion
< 4DF9 td data-grid-cell-id="diff-ef2cef9f88b4fe09ca3082140e67f5ad34fb65fb6e228f119d3812261ae51449-57-63-2" data-line-anchor="diff-ef2cef9f88b4fe09ca3082140e67f5ad34fb65fb6e228f119d3812261ae51449R63" data-selected="false" role="gridcell" style="background-color:var(--diffBlob-additionLine-bgColor, var(--diffBlob-addition-bgColor-line));padding-right:24px" tabindex="-1" valign="top" class="focusable-grid-cell diff-text-cell right-side-diff-cell left-side">+
AWS_SECRET_ACCESS_KEY
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ envlist =
2121

2222
{py2.7,py3.7}-requests
2323
{pypy,py2.7,py3.5,py3.6,py3.7,py3.8}-celery-4
24+
py3.7-aws_lambda
2425
{pypy,py2.7}-celery-3
2526

2627

@@ -44,7 +45,8 @@ deps =
4445
flask-dev: git+https://github.com/pallets/flask.git#egg=flask
4546
celery-3: Celery>=3.1,<4.0
4647
celery-4: Celery>=4.0,<5.0
47-
requests: requests>=2.0<3.0
48+
requests: requests>=2.0
49+
aws_lambda: boto3
4850
flask: flask-login
4951
linters: black
5052
linters: flake8
@@ -55,6 +57,11 @@ setenv =
5557
flask: TESTPATH=tests/integrations/flask
5658
celery: TESTPATH=tests/integrations/celery
5759
requests: TESTPATH=tests/integrations/requests
60+
aws_lambda: TESTPATH=tests/integrations/aws_lambda
61+
passenv =
62+
AWS_ACCESS_KEY_ID
63
64+
AWS_IAM_ROLE
5865
usedevelop = True
5966
extras =
6067
flask: flask

0 commit comments

Comments
 (0)
0