Skip to content

Commit 007d12a

Browse files
tonyman19Anton Matviyenkobasepi
authored
Instrument aws elb serverless requests (#1605)
* add elb events processing. fix full url forming for api gw events * add test for query parameters for api gateway v1 * fmt * Small fix + CHANGELOG * Fix origin.account_id vs origin.account.id for SNS Co-authored-by: Anton Matviyenko <[email protected]> Co-authored-by: Colton Myers <[email protected]>
1 parent 046b6e9 commit 007d12a

File tree

5 files changed

+138
-25
lines changed

5 files changed

+138
-25
lines changed

CHANGELOG.asciidoc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ endif::[]
2929
//===== Bug fixes
3030
//
3131
32+
=== Unreleased
33+
34+
// Unreleased changes go here
35+
// When the next release happens, nest these changes under the "Python Agent version 6.x" heading
36+
[float]
37+
===== Features
38+
39+
* Added lambda support for ELB triggers {pull}#1605[#1605]
40+
41+
//[float]
42+
//===== Bug fixes
43+
3244
3345
3446
[[release-notes-6.x]]

elasticapm/contrib/serverless/aws.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import platform
3737
import time
3838
from typing import Optional
39+
from urllib.parse import urlencode
3940

4041
import elasticapm
4142
from elasticapm.base import Client
@@ -44,6 +45,8 @@
4445
from elasticapm.utils.disttracing import TraceParent
4546
from elasticapm.utils.logging import get_logger
4647

48+
SERVERLESS_HTTP_REQUEST = ("api", "elb")
49+
4750
logger = get_logger("elasticapm.serverless")
4851

4952
COLD_START = True
@@ -134,18 +137,25 @@ def __enter__(self):
134137
transaction_type = "request"
135138
transaction_name = os.environ.get("AWS_LAMBDA_FUNCTION_NAME", self.name)
136139

137-
self.httpmethod = nested_key(self.event, "requestContext", "httpMethod") or nested_key(
138-
self.event, "requestContext", "http", "method"
140+
self.httpmethod = (
141+
nested_key(self.event, "requestContext", "httpMethod")
142+
or nested_key(self.event, "requestContext", "http", "method")
143+
or nested_key(self.event, "httpMethod")
139144
)
140-
if self.httpmethod: # API Gateway
141-
self.source = "api"
142-
if nested_key(self.event, "requestContext", "httpMethod"):
145+
146+
if self.httpmethod: # http request
147+
if nested_key(self.event, "requestContext", "elb"):
148+
self.source = "elb"
149+
resource = nested_key(self.event, "path")
150+
elif nested_key(self.event, "requestContext", "httpMethod"):
151+
self.source = "api"
143152
# API v1
144153
resource = "/{}{}".format(
145154
nested_key(self.event, "requestContext", "stage"),
146155
nested_key(self.event, "requestContext", "resourcePath"),
147156
)
148157
else:
158+
self.source = "api"
149159
# API v2
150160
route_key = nested_key(self.event, "requestContext", "routeKey")
151161
route_key = f"/{route_key}" if route_key.startswith("$") else route_key.split(" ", 1)[-1]
@@ -170,7 +180,7 @@ def __enter__(self):
170180

171181
self.transaction = self.client.begin_transaction(transaction_type, trace_parent=trace_parent)
172182
elasticapm.set_transaction_name(transaction_name, override=False)
173-
if self.source == "api":
183+
if self.source in SERVERLESS_HTTP_REQUEST:
174184
elasticapm.set_context(
175185
lambda: get_data_from_request(
176186
self.event,
@@ -199,7 +209,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
199209
elasticapm.set_transaction_result("HTTP 5xx", override=False)
200210
if exc_val:
201211
self.client.capture_exception(exc_info=(exc_type, exc_val, exc_tb), handled=False)
202-
if self.source == "api":
212+
if self.source in SERVERLESS_HTTP_REQUEST:
203213
elasticapm.set_transaction_result("HTTP 5xx", override=False)
204214
elasticapm.set_transaction_outcome(http_status_code=500, override=False)
205215
elasticapm.set_context({"status_code": 500}, "response")
@@ -246,6 +256,17 @@ def set_metadata_and_context(self, coldstart: bool) -> None:
246256
cloud_context["origin"]["service"] = {"name": "api gateway"}
247257
cloud_context["origin"]["account"] = {"id": self.event["requestContext"]["accountId"]}
248258
cloud_context["origin"]["provider"] = "aws"
259+
elif self.source == "elb":
260+
elb_target_group_arn = self.event["requestContext"]["elb"]["targetGroupArn"]
261+
faas["trigger"]["type"] = "http"
262+
faas["trigger"]["request_id"] = self.event["headers"]["x-amzn-trace-id"]
263+
service_context["origin"] = {"name": elb_target_group_arn.split(":")[5]}
264+
service_context["origin"]["id"] = elb_target_group_arn
265+
cloud_context["origin"] = {}
266+
cloud_context["origin"]["service"] = {"name": "elb"}
267+
cloud_context["origin"]["account"] = {"id": elb_target_group_arn.split(":")[4]}
268+
cloud_context["origin"]["region"] = elb_target_group_arn.split(":")[3]
269+
cloud_context["origin"]["provider"] = "aws"
249270
elif self.source == "sqs":
250271
record = self.event["Records"][0]
251272
faas["trigger"]["type"] = "pubsub"
@@ -281,7 +302,7 @@ def set_metadata_and_context(self, coldstart: bool) -> None:
281302
service_context["origin"]["service"] = {"name": "sns"}
282303
cloud_context["origin"] = {}
283304
cloud_context["origin"]["region"] = record["Sns"]["TopicArn"].split(":")[3]
284-
cloud_context["origin"]["account_id"] = record["Sns"]["TopicArn"].split(":")[4]
305+
cloud_context["origin"]["account"] = {"id": record["Sns"]["TopicArn"].split(":")[4]}
285306
cloud_context["origin"]["provider"] = "aws"
286307
message_context["queue"] = {"name": service_context["origin"]["name"]}
287308
if "Timestamp" in record["Sns"]:
@@ -354,7 +375,13 @@ def get_data_from_request(event: dict, capture_body: bool = False, capture_heade
354375
result = {}
355376
if capture_headers and "headers" in event:
356377
result["headers"] = event["headers"]
357-
method = nested_key(event, "requestContext", "httpMethod") or nested_key(event, "requestContext", "http", "method")
378+
379+
method = (
380+
nested_key(event, "requestContext", "httpMethod")
381+
or nested_key(event, "requestContext", "http", "method")
382+
or nested_key(event, "httpMethod")
383+
)
384+
358385
if not method:
359386
# Not API Gateway
360387
return result
@@ -405,16 +432,23 @@ def get_url_dict(event: dict) -> dict:
405432
headers = event.get("headers", {})
406433
protocol = headers.get("X-Forwarded-Proto", headers.get("x-forwarded-proto", "https"))
407434
host = headers.get("Host", headers.get("host", ""))
408-
stage = "/" + (nested_key(event, "requestContext", "stage") or "")
409-
path = event.get("path", event.get("rawPath", "").split(stage)[-1])
435+
stage = nested_key(event, "requestContext", "stage") or ""
436+
raw_path = event.get("rawPath", "")
437+
if stage:
438+
stage = "/" + stage
439+
raw_path = raw_path.split(stage)[-1]
440+
441+
path = event.get("path", raw_path)
410442
port = headers.get("X-Forwarded-Port", headers.get("x-forwarded-port"))
411443
query = ""
412444
if "rawQueryString" in event:
413445
query = event["rawQueryString"]
414446
elif event.get("queryStringParameters"):
415-
query = "?"
416-
for k, v in event["queryStringParameters"].items():
417-
query += "{}={}".format(k, v)
447+
if stage: # api requires parameters encoding to build correct url
448+
query = "?" + urlencode(event["queryStringParameters"])
449+
else: # for elb we do not have the stage
450+
query = "?" + "&".join(["{}={}".format(k, v) for k, v in event["queryStringParameters"].items()])
451+
418452
url = protocol + "://" + host + stage + path + query
419453

420454
url_dict = {

tests/contrib/serverless/aws_api_test_data.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@
7878
"https"
7979
]
8080
},
81-
"queryStringParameters": null,
81+
"queryStringParameters": {
82+
"test@key": "test@value"
83+
},
8284
"multiValueQueryStringParameters": null,
8385
"pathParameters": null,
8486
"stageVariables": null,
@@ -114,4 +116,4 @@
114116
},
115117
"body": null,
116118
"isBase64Encoded": false
117-
}
119+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"requestContext": {
3+
"elb": {
4+
"targetGroupArn": "arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a"
5+
}
6+
},
7+
"httpMethod": "POST",
8+
"path": "/toolz/api/v2.0/downloadPDF/PDF_2020-09-11_11-06-01.pdf",
9+
"queryStringParameters": {
10+
"test%40key": "test%40value",
11+
"language": "en-DE"
12+
},
13+
"headers": {
14+
"accept-encoding": "gzip,deflate",
15+
"connection": "Keep-Alive",
16+
"host": "blabla.com",
17+
"user-agent": "Apache-HttpClient/4.5.13 (Java/11.0.15)",
18+
"x-amzn-trace-id": "Root=1-xxxxxxxxxxxxxx",
19+
"x-forwarded-for": "199.99.99.999",
20+
"x-forwarded-port": "443",
21+
"x-forwarded-proto": "https"
22+
},
23+
"body": "blablablabody",
24+
"isBase64Encoded": false
25+
}

tests/contrib/serverless/aws_tests.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ def event_api2():
5353
return json.load(f)
5454

5555

56+
@pytest.fixture
57+
def event_elb():
58+
aws_data_file = os.path.join(os.path.dirname(__file__), "aws_elb_test_data.json")
59+
with open(aws_data_file) as f:
60+
return json.load(f)
61+
62+
5663
@pytest.fixture
5764
def event_s3():
5865
aws_data_file = os.path.join(os.path.dirname(__file__), "aws_s3_test_data.json")
@@ -100,7 +107,7 @@ def test_request_data(event_api, event_api2):
100107
data = get_data_from_request(event_api, capture_body=True, capture_headers=True)
101108

102109
assert data["method"] == "GET"
103-
assert data["url"]["full"] == "https://02plqthge2.execute-api.us-east-1.amazonaws.com/dev/fetch_all"
110+
assert data["url"]["full"] == "https://02plqthge2.execute-api.us-east-1.amazonaws.com/dev/fetch_all?test%40key=test%40value"
104111
assert data["headers"]["Host"] == "02plqthge2.execute-api.us-east-1.amazonaws.com"
105112

106113
data = get_data_from_request(event_api2, capture_body=True, capture_headers=True)
@@ -112,10 +119,28 @@ def test_request_data(event_api, event_api2):
112119
data = get_data_from_request(event_api, capture_body=False, capture_headers=False)
113120

114121
assert data["method"] == "GET"
115-
assert data["url"]["full"] == "https://02plqthge2.execute-api.us-east-1.amazonaws.com/dev/fetch_all"
122+
assert data["url"]["full"] == "https://02plqthge2.execute-api.us-east-1.amazonaws.com/dev/fetch_all?test%40key=test%40value"
116123
assert "headers" not in data
117124

118125

126+
def test_elb_request_data(event_elb):
127+
data = get_data_from_request(event_elb, capture_body=True, capture_headers=True)
128+
129+
assert data["method"] == "POST"
130+
assert data["url"][
131+
"full"] == "https://blabla.com/toolz/api/v2.0/downloadPDF/PDF_2020-09-11_11-06-01.pdf?test%40key=test%40value&language=en-DE"
132+
assert data["headers"]["host"] == "blabla.com"
133+
assert data["body"] == "blablablabody"
134+
135+
data = get_data_from_request(event_elb, capture_body=False, capture_headers=False)
136+
137+
assert data["method"] == "POST"
138+
assert data["url"][
139+
"full"] == "https://blabla.com/toolz/api/v2.0/downloadPDF/PDF_2020-09-11_11-06-01.pdf?test%40key=test%40value&language=en-DE"
140+
assert "headers" not in data
141+
assert data["body"] == "[REDACTED]"
142+
143+
119144
def test_response_data():
120145
response = {"statusCode": "200", "headers": {"foo": "bar"}}
121146
data = get_data_from_response(response, capture_headers=True)
@@ -136,7 +161,6 @@ def test_response_data():
136161

137162

138163
def test_capture_serverless_api_gateway(event_api, context, elasticapm_client):
139-
140164
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
141165

142166
@capture_serverless(elasticapm_client=elasticapm_client)
@@ -159,7 +183,6 @@ def test_func(event, context):
159183

160184

161185
def test_capture_serverless_api_gateway_v2(event_api2, context, elasticapm_client):
162-
163186
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
164187

165188
@capture_serverless(elasticapm_client=elasticapm_client)
@@ -181,8 +204,29 @@ def test_func(event, context):
181204
assert transaction["context"]["response"]["status_code"] == 200
182205

183206

184-
def test_capture_serverless_s3(event_s3, context, elasticapm_client):
207+
def test_capture_serverless_elb(event_elb, context, elasticapm_client):
208+
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
209+
210+
@capture_serverless(elasticapm_client=elasticapm_client)
211+
def test_func(event, context):
212+
with capture_span("test_span"):
213+
time.sleep(0.01)
214+
return {"statusCode": 200, "headers": {"foo": "bar"}}
215+
216+
test_func(event_elb, context)
185217

218+
assert len(elasticapm_client.events[constants.TRANSACTION]) == 1
219+
transaction = elasticapm_client.events[constants.TRANSACTION][0]
220+
221+
assert transaction["name"] == "POST /toolz/api/v2.0/downloadPDF/PDF_2020-09-11_11-06-01.pdf"
222+
assert transaction["result"] == "HTTP 2xx"
223+
assert transaction["span_count"]["started"] == 1
224+
assert transaction["context"]["request"]["method"] == "POST"
225+
assert transaction["context"]["request"]["headers"]
226+
assert transaction["context"]["response"]["status_code"] == 200
227+
228+
229+
def test_capture_serverless_s3(event_s3, context, elasticapm_client):
186230
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
187231

188232
@capture_serverless(elasticapm_client=elasticapm_client)
@@ -201,7 +245,6 @@ def test_func(event, context):
201245

202246

203247
def test_capture_serverless_sns(event_sns, context, elasticapm_client):
204-
205248
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
206249

207250
@capture_serverless(elasticapm_client=elasticapm_client)
@@ -222,7 +265,6 @@ def test_func(event, context):
222265

223266

224267
def test_capture_serverless_sqs(event_sqs, context, elasticapm_client):
225-
226268
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
227269

228270
@capture_serverless(elasticapm_client=elasticapm_client)
@@ -243,7 +285,6 @@ def test_func(event, context):
243285

244286

245287
def test_capture_serverless_s3_batch(event_s3_batch, context, elasticapm_client):
246-
247288
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
248289

249290
@capture_serverless(elasticapm_client=elasticapm_client)
@@ -263,7 +304,6 @@ def test_func(event, context):
263304

264305
@pytest.mark.parametrize("elasticapm_client", [{"service_name": "override"}], indirect=True)
265306
def test_service_name_override(event_api, context, elasticapm_client):
266-
267307
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
268308

269309
@capture_serverless(elasticapm_client=elasticapm_client)

0 commit comments

Comments
 (0)