Skip to content

Commit de21833

Browse files
authored
[WIP] More lambda improvements + docs (elastic#1368)
* Set transaction result for error cases in Lambda * Only use HTTP result/outcome for API Gateway * Enable (and fix) server version fetching * Add lambda docs (+elasticapm.get_client docs) * Move faas from metadata to top-level on the transaction * Fix for possibly-missing message attributes (used as headers) * Add API v2 sample payload and update data collection to match * Use nested_key and add docstring * CHANGELOG + experimental tag in docs
1 parent a06f74f commit de21833

File tree

10 files changed

+228
-34
lines changed

10 files changed

+228
-34
lines changed

CHANGELOG.asciidoc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ endif::[]
2828
//
2929
//[float]
3030
//===== Bug fixes
31+
=== Unreleased
32+
33+
// Unreleased changes go here
34+
// When the next release happens, nest these changes under the "Python Agent version 6.x" heading
35+
//[float]
36+
//===== Features
37+
38+
39+
[float]
40+
===== Bug fixes
41+
42+
* Fix some context fields and metadata handling in AWS Lambda support {pull}1368[#1368]
3143
3244
3345
[[release-notes-6.x]]

docs/api.asciidoc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ client = Client({'SERVICE_NAME': 'example'}, **defaults)
3434
NOTE: framework integrations like <<django-support, Django>> and <<flask-support, Flask>>
3535
instantiate the client automatically.
3636

37+
[float]
38+
[[api-get-client]]
39+
===== `elasticapm.get_client()`
40+
41+
[small]#Added in v6.1.0.
42+
43+
Retrieves the `Client` singleton. This is useful for many framework integrations,
44+
where the client is instantiated automatically.
45+
46+
[source,python]
47+
----
48+
client = elasticapm.get_client()
49+
client.capture_message('foo')
50+
----
51+
3752
[float]
3853
[[error-api]]
3954
==== Errors

docs/serverless.asciidoc

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
[[lambda-support]]
2+
=== AWS Lambda Support
3+
4+
experimental::[]
5+
6+
Incorporating Elastic APM into your AWS Lambda functions is easy!
7+
8+
[float]
9+
[[lambda-installation]]
10+
==== Installation
11+
12+
First, you need to add `elastic-apm` as a dependency for your python function.
13+
Depending on your deployment strategy, this could be as easy as adding
14+
`elastic-apm` to your `requirements.txt` file, or installing it in the directory
15+
you plan to deploy using pip:
16+
17+
[source,bash]
18+
----
19+
$ pip install -t <target_dir> elastic-apm
20+
----
21+
22+
You should also add the
23+
https://github.com/elastic/apm-aws-lambda[Elastic AWS Lambda Extension layer]
24+
to your function.
25+
26+
[float]
27+
[[lambda-setup]]
28+
==== Setup
29+
30+
Once the library is included as a dependency in your function, you must
31+
import the `capture_serverless` decorator and apply it to your handler:
32+
33+
[source,python]
34+
----
35+
from elasticapm import capture_serverless
36+
37+
@capture_serverless()
38+
def handler(event, context):
39+
return {"statusCode": r.status_code, "body": "Success!"}
40+
----
41+
42+
The agent uses environment variables for <<configuration,configuration>>
43+
44+
[source]
45+
----
46+
ELASTIC_APM_LAMBDA_APM_SERVER=<apm-server url>
47+
ELASTIC_APM_SECRET_TOKEN=<apm-server token>
48+
ELASTIC_APM_SERVICE_NAME=my-awesome-service
49+
----
50+
51+
Note that the above configuration assumes you're using the Elastic Lambda
52+
Extension. The agent will automatically send data to the extension at `localhost`,
53+
and the extension will then send to the APM Server as specified with
54+
`ELASTIC_APM_LAMBDA_APM_SERVER`.
55+
56+
[float]
57+
[[lambda-usage]]
58+
==== Usage
59+
60+
Once the agent is installed and working, spans will be captured for
61+
<<supported-technologies,supported technologies>>. You can also use
62+
<<api-capture-span,`capture_span`>> to capture custom spans, and
63+
you can retrieve the `Client` object for capturing exceptions/messages
64+
using <<api-get-client,`get_client`>>.
65+

docs/set-up.asciidoc

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ To get you off the ground, we’ve prepared guides for setting up the Agent with
88
* <<aiohttp-server-support,aiohttp>>
99
* <<tornado-support,Tornado>>
1010
* <<starlette-support,Starlette/FastAPI>>
11+
* <<lambda-support,AWS Lambda>>
1112

1213
For custom instrumentation, see <<instrumenting-custom-code, Instrumenting Custom Code>>.
1314

@@ -19,4 +20,6 @@ include::./aiohttp-server.asciidoc[]
1920

2021
include::./tornado.asciidoc[]
2122

22-
include::./starlette.asciidoc[]
23+
include::./starlette.asciidoc[]
24+
25+
include::./serverless.asciidoc[]

elasticapm/conf/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def _starmatch_to_regex(pattern):
5959

6060
EVENTS_API_PATH = "intake/v2/events"
6161
AGENT_CONFIG_PATH = "config/v1/agents"
62-
SERVER_INFO_PATH = "/"
62+
SERVER_INFO_PATH = ""
6363

6464
TRACE_CONTEXT_VERSION = 0
6565
TRACEPARENT_HEADER_NAME = "traceparent"

elasticapm/contrib/serverless/aws.py

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
import elasticapm
4040
from elasticapm.base import Client, get_client
4141
from elasticapm.conf import constants
42-
from elasticapm.utils import compat, encoding, get_name_from_func
42+
from elasticapm.utils import compat, encoding, get_name_from_func, nested_key
4343
from elasticapm.utils.disttracing import TraceParent
4444
from elasticapm.utils.logging import get_logger
4545

@@ -63,9 +63,6 @@ class capture_serverless(object):
6363
@capture_serverless()
6464
def handler(event, context):
6565
return {"statusCode": r.status_code, "body": "Success!"}
66-
67-
Note: This is an experimental feature, and we may introduce breaking
68-
changes in the future.
6966
"""
7067

7168
def __init__(self, name=None, **kwargs):
@@ -79,8 +76,6 @@ def __init__(self, name=None, **kwargs):
7976
kwargs["central_config"] = False
8077
kwargs["cloud_provider"] = "none"
8178
kwargs["framework_name"] = "AWS Lambda"
82-
# TODO this can probably be removed once the extension proxies the serverinfo endpoint
83-
kwargs["server_version"] = (8, 0, 0)
8479
if "service_name" not in kwargs:
8580
kwargs["service_name"] = os.environ["AWS_LAMBDA_FUNCTION_NAME"]
8681

@@ -123,10 +118,13 @@ def __enter__(self):
123118
transaction_type = "request"
124119
transaction_name = os.environ.get("AWS_LAMBDA_FUNCTION_NAME", self.name)
125120

126-
if "httpMethod" in self.event: # API Gateway
121+
self.httpmethod = nested_key(self.event, "requestContext", "httpMethod") or nested_key(
122+
self.event, "requestContext", "http", "method"
123+
)
124+
if self.httpmethod: # API Gateway
127125
self.source = "api"
128126
if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"):
129-
transaction_name = "{} {}".format(self.event["httpMethod"], os.environ["AWS_LAMBDA_FUNCTION_NAME"])
127+
transaction_name = "{} {}".format(self.httpmethod, os.environ["AWS_LAMBDA_FUNCTION_NAME"])
130128
else:
131129
transaction_name = self.name
132130
elif "Records" in self.event and len(self.event["Records"]) == 1:
@@ -160,9 +158,6 @@ def __exit__(self, exc_type, exc_val, exc_tb):
160158
"""
161159
Transaction teardown
162160
"""
163-
if exc_val:
164-
self.client.capture_exception(exc_info=(exc_type, exc_val, exc_tb), handled=False)
165-
166161
if self.response and isinstance(self.response, dict):
167162
elasticapm.set_context(
168163
lambda: get_data_from_response(self.response, capture_headers=self.client.config.capture_headers),
@@ -180,6 +175,16 @@ def __exit__(self, exc_type, exc_val, exc_tb):
180175
result = "HTTP {}xx".format(int(status_code) // 100)
181176
elasticapm.set_transaction_result(result, override=False)
182177

178+
if exc_val:
179+
self.client.capture_exception(exc_info=(exc_type, exc_val, exc_tb), handled=False)
180+
if self.source == "api":
181+
elasticapm.set_transaction_result("HTTP 5xx", override=False)
182+
elasticapm.set_transaction_outcome(http_status_code=500, override=False)
183+
elasticapm.set_context({"status_code": 500}, "response")
184+
else:
185+
elasticapm.set_transaction_result("failure", override=False)
186+
elasticapm.set_transaction_outcome(outcome="failure", override=False)
187+
183188
self.client.end_transaction()
184189

185190
try:
@@ -206,15 +211,19 @@ def set_metadata_and_context(self, coldstart):
206211
if self.source == "api":
207212
faas["trigger"]["type"] = "http"
208213
faas["trigger"]["request_id"] = self.event["requestContext"]["requestId"]
214+
path = (
215+
self.event["requestContext"].get("resourcePath")
216+
or self.event["requestContext"]["http"]["path"].split(self.event["requestContext"]["stage"])[-1]
217+
)
209218
service_context["origin"] = {
210219
"name": "{} {}/{}".format(
211-
self.event["requestContext"]["httpMethod"],
212-
self.event["requestContext"]["resourcePath"],
220+
self.httpmethod,
221+
path,
213222
self.event["requestContext"]["stage"],
214223
)
215224
}
216225
service_context["origin"]["id"] = self.event["requestContext"]["apiId"]
217-
service_context["origin"]["version"] = "2.0" if self.event["headers"]["Via"].startswith("2.0") else "1.0"
226+
service_context["origin"]["version"] = self.event.get("version", "1.0")
218227
cloud_context["origin"] = {}
219228
cloud_context["origin"]["service"] = {"name": "api gateway"}
220229
cloud_context["origin"]["account"] = {"id": self.event["requestContext"]["accountId"]}
@@ -236,7 +245,7 @@ def set_metadata_and_context(self, coldstart):
236245
message_context["age"] = int((time.time() * 1000) - int(record["attributes"]["SentTimestamp"]))
237246
if self.client.config.capture_body in ("transactions", "all") and "body" in record:
238247
message_context["body"] = record["body"]
239-
if self.client.config.capture_headers and record["messageAttributes"]:
248+
if self.client.config.capture_headers and record.get("messageAttributes"):
240249
message_context["headers"] = record["messageAttributes"]
241250
elif self.source == "sns":
242251
record = self.event["Records"][0]
@@ -262,7 +271,7 @@ def set_metadata_and_context(self, coldstart):
262271
)
263272
if self.client.config.capture_body in ("transactions", "all") and "Message" in record["Sns"]:
264273
message_context["body"] = record["Sns"]["Message"]
265-
if self.client.config.capture_headers and record["Sns"]["MessageAttributes"]:
274+
if self.client.config.capture_headers and record["Sns"].get("MessageAttributes"):
266275
message_context["headers"] = record["Sns"]["MessageAttributes"]
267276
elif self.source == "s3":
268277
record = self.event["Records"][0]
@@ -277,8 +286,6 @@ def set_metadata_and_context(self, coldstart):
277286
cloud_context["origin"]["region"] = record["awsRegion"]
278287
cloud_context["origin"]["provider"] = "aws"
279288

280-
metadata["faas"] = faas
281-
282289
metadata["service"] = {}
283290
metadata["service"]["name"] = os.environ.get("AWS_LAMBDA_FUNCTION_NAME")
284291
metadata["service"]["framework"] = {"name": "AWS Lambda"}
@@ -295,7 +302,7 @@ def set_metadata_and_context(self, coldstart):
295302
# This is the one piece of metadata that requires deep merging. We add it manually
296303
# here to avoid having to deep merge in _transport.add_metadata()
297304
if self.client._transport._metadata:
298-
node_name = self.client._transport._metadata.get("service", {}).get("node", {}).get("name")
305+
node_name = nested_key(self.client._transport._metadata, "service", "node", "name")
299306
if node_name:
300307
metadata["service"]["node"]["name"] = node_name
301308

@@ -307,6 +314,8 @@ def set_metadata_and_context(self, coldstart):
307314

308315
elasticapm.set_context(cloud_context, "cloud")
309316
elasticapm.set_context(service_context, "service")
317+
# faas doesn't actually belong in context, but we handle this in to_dict
318+
elasticapm.set_context(faas, "faas")
310319
if message_context:
311320
elasticapm.set_context(service_context, "message")
312321
self.client._transport.add_metadata(metadata)
@@ -319,12 +328,13 @@ def get_data_from_request(event, capture_body=False, capture_headers=True):
319328
result = {}
320329
if capture_headers and "headers" in event:
321330
result["headers"] = event["headers"]
322-
if "httpMethod" not in event:
331+
method = nested_key(event, "requestContext", "httpMethod") or nested_key(event, "requestContext", "http", "method")
332+
if not method:
323333
# Not API Gateway
324334
return result
325335

326-
result["method"] = event["httpMethod"]
327-
if event["httpMethod"] in constants.HTTP_WITH_BODY and "body" in event:
336+
result["method"] = method
337+
if method in constants.HTTP_WITH_BODY and "body" in event:
328338
body = event["body"]
329339
if capture_body:
330340
if event.get("isBase64Encoded"):
@@ -362,21 +372,23 @@ def get_url_dict(event):
362372
Reconstruct URL from API Gateway
363373
"""
364374
headers = event.get("headers", {})
365-
proto = headers.get("X-Forwarded-Proto", "https")
366-
host = headers.get("Host", "")
367-
path = event.get("path", "")
368-
port = headers.get("X-Forwarded-Port")
369-
stage = "/" + event.get("requestContext", {}).get("stage", "")
375+
protocol = headers.get("X-Forwarded-Proto", headers.get("x-forwarded-proto", "https"))
376+
host = headers.get("Host", headers.get("host", ""))
377+
stage = "/" + (nested_key(event, "requestContext", "stage") or "")
378+
path = event.get("path", event.get("rawPath", "").split(stage)[-1])
379+
port = headers.get("X-Forwarded-Port", headers.get("x-forwarded-port"))
370380
query = ""
371-
if event.get("queryStringParameters"):
381+
if "rawQueryString" in event:
382+
query = event["rawQueryString"]
383+
elif event.get("queryStringParameters"):
372384
query = "?"
373385
for k, v in compat.iteritems(event["queryStringParameters"]):
374386
query += "{}={}".format(k, v)
375-
url = proto + "://" + host + stage + path + query
387+
url = protocol + "://" + host + stage + path + query
376388

377389
url_dict = {
378390
"full": encoding.keyword_field(url),
379-
"protocol": proto,
391+
"protocol": protocol,
380392
"hostname": encoding.keyword_field(host),
381393
"pathname": encoding.keyword_field(stage + path),
382394
}

elasticapm/traces.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,9 @@ def to_dict(self) -> dict:
393393
# only set parent_id if this transaction isn't the root
394394
if self.trace_parent.span_id and self.trace_parent.span_id != self.id:
395395
result["parent_id"] = self.trace_parent.span_id
396+
# faas context belongs top-level on the transaction
397+
if "faas" in self.context:
398+
result["faas"] = self.context.pop("faas")
396399
if self.is_sampled:
397400
result["context"] = self.context
398401
return result
@@ -954,7 +957,7 @@ def set_transaction_name(name, override=True):
954957
def set_transaction_result(result, override=True):
955958
"""
956959
Sets the result of the transaction. The result could be e.g. the HTTP status class (e.g "HTTP 5xx") for
957-
HTTP requests, or "success"/"fail" for background tasks.
960+
HTTP requests, or "success"/"failure" for background tasks.
958961
959962
:param name: the name of the transaction
960963
:param override: if set to False, the name is only set if no name has been set before

elasticapm/utils/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,19 @@ def starmatch_to_regex(pattern: str) -> Pattern:
198198

199199

200200
def nested_key(d: dict, *args):
201+
"""
202+
Traverses a dictionary for nested keys. Returns `None` if the at any point
203+
in the traversal a key cannot be found.
204+
205+
Example:
206+
207+
>>> from elasticapm.utils import nested_key
208+
>>> d = {"a": {"b": {"c": 0}}}
209+
>>> nested_key(d, "a", "b", "c")
210+
0
211+
>>> nested_key(d, "a", "b", "d")
212+
None
213+
"""
201214
for arg in args:
202215
try:
203216
d = d[arg]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"version": "2.0",
3+
"routeKey": "ANY /fetch_all",
4+
"rawPath": "/dev/fetch_all",
5+
"rawQueryString": "",
6+
"headers": {
7+
"accept": "*/*",
8+
"content-length": "0",
9+
"host": "02plqthge2.execute-api.us-east-1.amazonaws.com",
10+
"user-agent": "curl/7.64.1",
11+
"x-amzn-trace-id": "Root=1-618018c5-763ade2b18f5734547c93e98",
12+
"x-forwarded-for": "67.171.184.49",
13+
"x-forwarded-port": "443",
14+
"x-forwarded-proto": "https"
15+
},
16+
"requestContext": {
17+
"accountId": "627286350134",
18+
"apiId": "02plqthge2",
19+
"domainName": "02plqthge2.execute-api.us-east-1.amazonaws.com",
20+
"domainPrefix": "02plqthge2",
21+
"http": {
22+
"method": "GET",
23+
"path": "/dev/fetch_all",
24+
"protocol": "HTTP/1.1",
25+
"sourceIp": "67.171.184.49",
26+
"userAgent": "curl/7.64.1"
27+
},
28+
"requestId": "IIjO5hs7PHcEPIA=",
29+
"routeKey": "ANY /fetch_all",
30+
"stage": "dev",
31+
"time": "01/Nov/2021:16:41:41 +0000",
32+
"timeEpoch": 1635784901594
33+
},
34+
"isBase64Encoded": false
35+
}

0 commit comments

Comments
 (0)