Skip to content

Commit c0059aa

Browse files
authored
Fix lambda performance issue (#1727)
* Move Client creation back out of function execution * Update capture_serverless for more flexibility Can now be run with or without arguments, and deprecates the use of arguments * Add CHANGELOG and fix docs * Add a comment about functools.partial usage * More docs + changelog * Implement new spec changes from elastic/apm#749 * Fix ELB test * Variable naming cleanup + docstrings
1 parent 4cbe4d7 commit c0059aa

File tree

4 files changed

+123
-79
lines changed

4 files changed

+123
-79
lines changed

CHANGELOG.asciidoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ endif::[]
4444
4545
* Fix an async issue with long elasticsearch queries {pull}1725[#1725]
4646
* Fix a minor inconsistency with the W3C tracestate spec {pull}1728[#1728]
47+
* Fix a performance issue with our AWS Lambda integration {pull}1727[#1727]
48+
* Mark `**kwargs` config usage in our AWS Lambda integration as deprecated {pull}1727[#1727]
4749
4850
4951
[[release-notes-6.x]]

docs/serverless.asciidoc

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,17 @@ you plan to deploy using pip:
3434
$ pip install -t <target_dir> elastic-apm
3535
----
3636

37+
Note: Please use the latest version of the APM Python agent. A performance
38+
issue was introduced in version 6.9.0 of the agent, and fixed in version 6.14.0.
39+
3740
Once the library is included as a dependency in your function, you must
3841
import the `capture_serverless` decorator and apply it to your handler:
3942

4043
[source,python]
4144
----
4245
from elasticapm import capture_serverless
4346
44-
@capture_serverless()
47+
@capture_serverless
4548
def handler(event, context):
4649
return {"statusCode": r.status_code, "body": "Success!"}
4750
----
@@ -55,7 +58,7 @@ see missing spans. Example:
5558
----
5659
conn = None
5760
58-
@capture_serverless()
61+
@capture_serverless
5962
def handler(event, context):
6063
global conn
6164
if not conn:

elasticapm/contrib/serverless/aws.py

Lines changed: 82 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@
3535
import os
3636
import platform
3737
import time
38+
import warnings
3839
from typing import Optional, TypeVar
3940
from urllib.parse import urlencode
4041

4142
import elasticapm
42-
from elasticapm.base import Client
43+
from elasticapm.base import Client, get_client
4344
from elasticapm.conf import constants
4445
from elasticapm.utils import encoding, get_name_from_func, nested_key
4546
from elasticapm.utils.disttracing import TraceParent
@@ -50,84 +51,94 @@
5051
logger = get_logger("elasticapm.serverless")
5152

5253
COLD_START = True
54+
INSTRUMENTED = False
5355

54-
_AnnotatedFunctionT = TypeVar("_AnnotatedFunctionT")
56+
_AWSLambdaContextT = TypeVar("_AWSLambdaContextT")
5557

5658

57-
class capture_serverless(object):
59+
def capture_serverless(func: Optional[callable] = None, **kwargs) -> callable:
5860
"""
59-
Context manager and decorator designed for instrumenting serverless
60-
functions.
61-
62-
Begins and ends a single transaction, waiting for the transport to flush
63-
before returning from the wrapped function.
61+
Decorator for instrumenting AWS Lambda functions.
6462
6563
Example usage:
6664
6765
from elasticapm import capture_serverless
6866
69-
@capture_serverless()
67+
@capture_serverless
7068
def handler(event, context):
7169
return {"statusCode": r.status_code, "body": "Success!"}
7270
"""
71+
if not func:
72+
# This allows for `@capture_serverless()` in addition to
73+
# `@capture_serverless` decorator usage
74+
return functools.partial(capture_serverless, **kwargs)
75+
76+
if kwargs:
77+
warnings.warn(
78+
PendingDeprecationWarning(
79+
"Passing keyword arguments to capture_serverless will be deprecated in the next major release."
80+
)
81+
)
7382

74-
def __init__(self, name: Optional[str] = None, elasticapm_client: Optional[Client] = None, **kwargs) -> None:
75-
self.name = name
76-
self.event = {}
77-
self.context = {}
78-
self.response = None
79-
self.instrumented = False
80-
self.client = elasticapm_client # elasticapm_client is intended for testing only
81-
82-
# Disable all background threads except for transport
83-
kwargs["metrics_interval"] = "0ms"
84-
kwargs["breakdown_metrics"] = False
85-
if "metrics_sets" not in kwargs and "ELASTIC_APM_METRICS_SETS" not in os.environ:
86-
# Allow users to override metrics sets
87-
kwargs["metrics_sets"] = []
88-
kwargs["central_config"] = False
89-
kwargs["cloud_provider"] = "none"
90-
kwargs["framework_name"] = "AWS Lambda"
91-
if "service_name" not in kwargs and "ELASTIC_APM_SERVICE_NAME" not in os.environ:
92-
kwargs["service_name"] = os.environ["AWS_LAMBDA_FUNCTION_NAME"]
93-
if "service_version" not in kwargs and "ELASTIC_APM_SERVICE_VERSION" not in os.environ:
94-
kwargs["service_version"] = os.environ.get("AWS_LAMBDA_FUNCTION_VERSION")
95-
96-
self.client_kwargs = kwargs
97-
98-
def __call__(self, func: _AnnotatedFunctionT) -> _AnnotatedFunctionT:
99-
self.name = self.name or get_name_from_func(func)
100-
101-
@functools.wraps(func)
102-
def decorated(*args, **kwds):
103-
if len(args) == 2:
104-
# Saving these for request context later
105-
self.event, self.context = args
106-
else:
107-
self.event, self.context = {}, {}
108-
# We delay client creation until the function is called, so that
109-
# multiple @capture_serverless instances in the same file don't create
110-
# multiple clients
111-
if not self.client:
112-
# Don't use get_client() as we may have a config mismatch due to **kwargs
113-
self.client = Client(**self.client_kwargs)
114-
if (
115-
not self.instrumented
116-
and not self.client.config.debug
117-
and self.client.config.instrument
118-
and self.client.config.enabled
119-
):
120-
elasticapm.instrument()
121-
self.instrumented = True
122-
123-
if not self.client.config.debug and self.client.config.instrument and self.client.config.enabled:
124-
with self:
125-
self.response = func(*args, **kwds)
126-
return self.response
127-
else:
128-
return func(*args, **kwds)
83+
name = kwargs.pop("name", None)
84+
85+
# Disable all background threads except for transport
86+
kwargs["metrics_interval"] = "0ms"
87+
kwargs["breakdown_metrics"] = False
88+
if "metrics_sets" not in kwargs and "ELASTIC_APM_METRICS_SETS" not in os.environ:
89+
# Allow users to override metrics sets
90+
kwargs["metrics_sets"] = []
91+
kwargs["central_config"] = False
92+
kwargs["cloud_provider"] = "none"
93+
kwargs["framework_name"] = "AWS Lambda"
94+
if "service_name" not in kwargs and "ELASTIC_APM_SERVICE_NAME" not in os.environ:
95+
kwargs["service_name"] = os.environ["AWS_LAMBDA_FUNCTION_NAME"]
96+
if "service_version" not in kwargs and "ELASTIC_APM_SERVICE_VERSION" not in os.environ:
97+
kwargs["service_version"] = os.environ.get("AWS_LAMBDA_FUNCTION_VERSION")
98+
99+
global INSTRUMENTED
100+
client = get_client()
101+
if not client:
102+
client = Client(**kwargs)
103+
if not client.config.debug and client.config.instrument and client.config.enabled and not INSTRUMENTED:
104+
elasticapm.instrument()
105+
INSTRUMENTED = True
106+
107+
@functools.wraps(func)
108+
def decorated(*args, **kwds):
109+
if len(args) == 2:
110+
# Saving these for request context later
111+
event, context = args
112+
else:
113+
event, context = {}, {}
114+
115+
if not client.config.debug and client.config.instrument and client.config.enabled:
116+
with _lambda_transaction(func, name, client, event, context) as sls:
117+
sls.response = func(*args, **kwds)
118+
return sls.response
119+
else:
120+
return func(*args, **kwds)
121+
122+
return decorated
123+
124+
125+
class _lambda_transaction(object):
126+
"""
127+
Context manager for creating transactions around AWS Lambda functions.
129128
130-
return decorated
129+
Begins and ends a single transaction, waiting for the transport to flush
130+
before releasing the context.
131+
"""
132+
133+
def __init__(
134+
self, func: callable, name: Optional[str], client: Client, event: dict, context: _AWSLambdaContextT
135+
) -> None:
136+
self.func = func
137+
self.name = name or get_name_from_func(func)
138+
self.event = event
139+
self.context = context
140+
self.response = None
141+
self.client = client
131142

132143
def __enter__(self):
133144
"""
@@ -152,7 +163,7 @@ def __enter__(self):
152163
if self.httpmethod: # http request
153164
if nested_key(self.event, "requestContext", "elb"):
154165
self.source = "elb"
155-
resource = nested_key(self.event, "path")
166+
resource = "unknown route"
156167
elif nested_key(self.event, "requestContext", "httpMethod"):
157168
self.source = "api"
158169
# API v1
@@ -193,7 +204,7 @@ def __enter__(self):
193204
else:
194205
links = []
195206

196-
self.transaction = self.client.begin_transaction(transaction_type, trace_parent=trace_parent, links=links)
207+
self.client.begin_transaction(transaction_type, trace_parent=trace_parent, links=links)
197208
elasticapm.set_transaction_name(transaction_name, override=False)
198209
if self.source in SERVERLESS_HTTP_REQUEST:
199210
elasticapm.set_context(
@@ -205,6 +216,7 @@ def __enter__(self):
205216
"request",
206217
)
207218
self.set_metadata_and_context(cold_start)
219+
return self
208220

209221
def __exit__(self, exc_type, exc_val, exc_tb):
210222
"""
@@ -219,9 +231,12 @@ def __exit__(self, exc_type, exc_val, exc_tb):
219231
try:
220232
result = "HTTP {}xx".format(int(self.response["statusCode"]) // 100)
221233
elasticapm.set_transaction_result(result, override=False)
234+
if result == "HTTP 5xx":
235+
elasticapm.set_transaction_outcome(outcome="failure", override=False)
222236
except ValueError:
223237
logger.warning("Lambda function's statusCode was not formed as an int. Assuming 5xx result.")
224238
elasticapm.set_transaction_result("HTTP 5xx", override=False)
239+
elasticapm.set_transaction_outcome(outcome="failure", override=False)
225240
if exc_val:
226241
self.client.capture_exception(exc_info=(exc_type, exc_val, exc_tb), handled=False)
227242
if self.source in SERVERLESS_HTTP_REQUEST:

tests/contrib/serverless/aws_tests.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ def test_response_data():
180180
def test_capture_serverless_api_gateway(event_api, context, elasticapm_client):
181181
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
182182

183-
@capture_serverless(elasticapm_client=elasticapm_client)
183+
@capture_serverless
184184
def test_func(event, context):
185185
with capture_span("test_span"):
186186
time.sleep(0.01)
@@ -199,10 +199,34 @@ def test_func(event, context):
199199
assert transaction["context"]["response"]["status_code"] == 200
200200

201201

202+
def test_capture_serverless_api_gateway_with_args_deprecated(event_api, context, elasticapm_client):
203+
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
204+
205+
with pytest.warns(PendingDeprecationWarning):
206+
207+
@capture_serverless(name="test_func")
208+
def test_func(event, context):
209+
with capture_span("test_span"):
210+
time.sleep(0.01)
211+
return {"statusCode": 200, "headers": {"foo": "bar"}}
212+
213+
test_func(event_api, context)
214+
215+
assert len(elasticapm_client.events[constants.TRANSACTION]) == 1
216+
transaction = elasticapm_client.events[constants.TRANSACTION][0]
217+
218+
assert transaction["name"] == "GET /dev/fetch_all"
219+
assert transaction["result"] == "HTTP 2xx"
220+
assert transaction["span_count"]["started"] == 1
221+
assert transaction["context"]["request"]["method"] == "GET"
222+
assert transaction["context"]["request"]["headers"]
223+
assert transaction["context"]["response"]["status_code"] == 200
224+
225+
202226
def test_capture_serverless_api_gateway_v2(event_api2, context, elasticapm_client):
203227
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
204228

205-
@capture_serverless(elasticapm_client=elasticapm_client)
229+
@capture_serverless
206230
def test_func(event, context):
207231
with capture_span("test_span"):
208232
time.sleep(0.01)
@@ -225,7 +249,7 @@ def test_func(event, context):
225249
def test_capture_serverless_lambda_url(event_lurl, context, elasticapm_client):
226250
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
227251

228-
@capture_serverless(elasticapm_client=elasticapm_client)
252+
@capture_serverless
229253
def test_func(event, context):
230254
with capture_span("test_span"):
231255
time.sleep(0.01)
@@ -248,7 +272,7 @@ def test_func(event, context):
248272
def test_capture_serverless_elb(event_elb, context, elasticapm_client):
249273
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
250274

251-
@capture_serverless(elasticapm_client=elasticapm_client)
275+
@capture_serverless
252276
def test_func(event, context):
253277
with capture_span("test_span"):
254278
time.sleep(0.01)
@@ -259,7 +283,7 @@ def test_func(event, context):
259283
assert len(elasticapm_client.events[constants.TRANSACTION]) == 1
260284
transaction = elasticapm_client.events[constants.TRANSACTION][0]
261285

262-
assert transaction["name"] == "POST /toolz/api/v2.0/downloadPDF/PDF_2020-09-11_11-06-01.pdf"
286+
assert transaction["name"] == "POST unknown route"
263287
assert transaction["result"] == "HTTP 2xx"
264288
assert transaction["span_count"]["started"] == 1
265289
assert transaction["context"]["request"]["method"] == "POST"
@@ -271,7 +295,7 @@ def test_func(event, context):
271295
def test_capture_serverless_s3(event_s3, context, elasticapm_client):
272296
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
273297

274-
@capture_serverless(elasticapm_client=elasticapm_client)
298+
@capture_serverless
275299
def test_func(event, context):
276300
with capture_span("test_span"):
277301
time.sleep(0.01)
@@ -289,7 +313,7 @@ def test_func(event, context):
289313
def test_capture_serverless_sns(event_sns, context, elasticapm_client):
290314
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
291315

292-
@capture_serverless(elasticapm_client=elasticapm_client)
316+
@capture_serverless
293317
def test_func(event, context):
294318
with capture_span("test_span"):
295319
time.sleep(0.01)
@@ -309,7 +333,7 @@ def test_func(event, context):
309333
def test_capture_serverless_sqs(event_sqs, context, elasticapm_client):
310334
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
311335

312-
@capture_serverless(elasticapm_client=elasticapm_client)
336+
@capture_serverless
313337
def test_func(event, context):
314338
with capture_span("test_span"):
315339
time.sleep(0.01)
@@ -331,7 +355,7 @@ def test_func(event, context):
331355
def test_capture_serverless_s3_batch(event_s3_batch, context, elasticapm_client):
332356
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
333357

334-
@capture_serverless(elasticapm_client=elasticapm_client)
358+
@capture_serverless
335359
def test_func(event, context):
336360
with capture_span("test_span"):
337361
time.sleep(0.01)
@@ -350,7 +374,7 @@ def test_func(event, context):
350374
def test_service_name_override(event_api, context, elasticapm_client):
351375
os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func"
352376

353-
@capture_serverless(elasticapm_client=elasticapm_client)
377+
@capture_serverless
354378
def test_func(event, context):
355379
with capture_span("test_span"):
356380
time.sleep(0.01)

0 commit comments

Comments
 (0)