From 3163616f31a00ae93c1be6d1d7728c8dc8578ebf Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Wed, 12 Feb 2020 15:49:19 -0700 Subject: [PATCH 01/30] Add skeleton for serverless transport and decorator --- elasticapm/base.py | 35 ++++++++++++++++- elasticapm/transport/serverless.py | 61 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 elasticapm/transport/serverless.py diff --git a/elasticapm/base.py b/elasticapm/base.py index 18e7a6d24..f9c264a19 100644 --- a/elasticapm/base.py +++ b/elasticapm/base.py @@ -31,6 +31,7 @@ from __future__ import absolute_import +import functools import inspect import itertools import logging @@ -47,7 +48,7 @@ from elasticapm.conf.constants import ERROR from elasticapm.metrics.base_metrics import MetricsRegistry from elasticapm.traces import Tracer, execution_context -from elasticapm.utils import cgroup, compat, is_master_process, stacks, varmap +from elasticapm.utils import cgroup, compat, get_name_from_func, is_master_process, stacks, varmap from elasticapm.utils.encoding import enforce_label_format, keyword_field, shorten, transform from elasticapm.utils.logging import get_logger from elasticapm.utils.module_import import import_string @@ -532,3 +533,35 @@ class DummyClient(Client): def send(self, url, **kwargs): return None + + +class capture_serverless(object): + """ + Context manager and decorator designed for instrumenting serverless + functions. + + Uses a logging-only version of the transport, and no background threads. + Begins and ends a single transaction. + """ + + def __init__(self, **kwargs): + # TODO set up Client + pass + + def __call__(self, func): + self.name = self.name or get_name_from_func(func) + + @functools.wraps(func) + def decorated(*args, **kwds): + with self: + return func(*args, **kwds) + + return decorated + + def __enter__(self): + self.client.begin_transaction(self.name) + # TODO + + def __exit__(self, exc_type, exc_val, exc_tb): + self.client.end_transaction(self.name) + # TODO diff --git a/elasticapm/transport/serverless.py b/elasticapm/transport/serverless.py new file mode 100644 index 000000000..0b3509442 --- /dev/null +++ b/elasticapm/transport/serverless.py @@ -0,0 +1,61 @@ +# BSD 3-Clause License +# +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAG + +import json +import logging + +from elasticapm.transport.base import Transport + +# TODO logging formatter for minimal formatting +logger = logging.getLogger("elasticapm.transport.serverless") + + +class ServerlessTransport(Transport): + """ + Transport class for use in serverless environments. + + No background threads are used, and the queue() function is overridden + such that it will log the JSON object for the event rather than sending + it anywhere. + """ + + def start_thread(self): + """ + No background threads are needed, as we have no queueing needs + """ + pass + + def queue(self, event_type, data, flush=False): + """ + Rather than queueing the data, just dump it to a log immediately + """ + # TODO definitely need to enrich and format this data + # TODO log level? + logger.info(json.dumps(data)) From 76a0ed596dbd18e2529a387bb308526bea57eb30 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 13 Feb 2020 14:24:19 -0700 Subject: [PATCH 02/30] Remove references to Client.send() This function appears to have been replaced by Client.queue() anywhere that it actually matters. I can't find any code paths that actually use this function, which further makes DummyClient useless. --- elasticapm/base.py | 7 ------- elasticapm/contrib/django/client.py | 13 ------------- 2 files changed, 20 deletions(-) diff --git a/elasticapm/base.py b/elasticapm/base.py index f9c264a19..d66e96214 100644 --- a/elasticapm/base.py +++ b/elasticapm/base.py @@ -528,13 +528,6 @@ def load_processors(self): return [seen.setdefault(path, import_string(path)) for path in processors if path not in seen] -class DummyClient(Client): - """Sends messages into an empty void""" - - def send(self, url, **kwargs): - return None - - class capture_serverless(object): """ Context manager and decorator designed for instrumenting serverless diff --git a/elasticapm/contrib/django/client.py b/elasticapm/contrib/django/client.py index 8074871b7..006d05106 100644 --- a/elasticapm/contrib/django/client.py +++ b/elasticapm/contrib/django/client.py @@ -208,19 +208,6 @@ def _get_stack_info_for_trace( ) ) - def send(self, url, **kwargs): - """ - Serializes and signs ``data`` and passes the payload off to ``send_remote`` - - If ``server`` was passed into the constructor, this will serialize the data and pipe it to - the server using ``send_remote()``. - """ - if self.config.server_url: - return super(DjangoClient, self).send(url, **kwargs) - else: - self.error_logger.error("No server configured, and elasticapm not installed. Cannot send message") - return None - class ProxyClient(object): """ From 4e5bcc11b4f00667a6414a165ef2bf9f9bd7b0aa Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 13 Feb 2020 15:06:24 -0700 Subject: [PATCH 03/30] Add ServerlessClient class --- elasticapm/base.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/elasticapm/base.py b/elasticapm/base.py index d66e96214..09b772990 100644 --- a/elasticapm/base.py +++ b/elasticapm/base.py @@ -528,6 +528,26 @@ def load_processors(self): return [seen.setdefault(path, import_string(path)) for path in processors if path not in seen] +class ServerlessClient(Client): + """ + Custom client for serverless applications, where we don't want any + background threads and need to dump messages to logs rather than to the + APM server directly. + """ + + def __init__(self, config=None, **inline): + inline["transport_class"] = "elasticapm.transport.serverless.ServerlessTransport" + if isinstance(config, dict) and "transport_class" in config: + config.pop("transport_class") + super(ServerlessClient, self).__init__(config, **inline) + + def start_threads(self): + """ + No background threads for serverless + """ + pass + + class capture_serverless(object): """ Context manager and decorator designed for instrumenting serverless @@ -537,9 +557,12 @@ class capture_serverless(object): Begins and ends a single transaction. """ + # TODO save event information from API gateway in __call__, add to + # transaction in __exit__/__enter__ + def __init__(self, **kwargs): - # TODO set up Client - pass + self.client = ServerlessClient(**kwargs) + elasticapm.instrument() def __call__(self, func): self.name = self.name or get_name_from_func(func) @@ -552,9 +575,9 @@ def decorated(*args, **kwds): return decorated def __enter__(self): - self.client.begin_transaction(self.name) - # TODO + self.transaction = self.client.begin_transaction(self.name) def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val: + self.client.capture_exception(exc_info=(exc_type, exc_val, exc_tb), handled=False) self.client.end_transaction(self.name) - # TODO From eee211e11b434da6bfa63397e33ccebef34d8ae6 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 13 Feb 2020 16:18:38 -0700 Subject: [PATCH 04/30] Use kwargs for all transport __init__ parameters The http transport has wandered from the parent Transport class --- elasticapm/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elasticapm/base.py b/elasticapm/base.py index 09b772990..63e91e171 100644 --- a/elasticapm/base.py +++ b/elasticapm/base.py @@ -145,7 +145,7 @@ def __init__(self, config=None, **inline): constants.EVENTS_API_PATH, ) transport_class = import_string(self.config.transport_class) - self._transport = transport_class(self._api_endpoint_url, self, **transport_kwargs) + self._transport = transport_class(url=self._api_endpoint_url, client=self, **transport_kwargs) self.config.transport = self._transport self._thread_managers["transport"] = self._transport From 342e31fbd8acc3f52d9b628046ce775b2c6f7d5d Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 13 Feb 2020 16:25:25 -0700 Subject: [PATCH 05/30] Make sure `self.name` is present --- elasticapm/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/elasticapm/base.py b/elasticapm/base.py index 63e91e171..3c114690c 100644 --- a/elasticapm/base.py +++ b/elasticapm/base.py @@ -561,6 +561,7 @@ class capture_serverless(object): # transaction in __exit__/__enter__ def __init__(self, **kwargs): + self.name = kwargs.get("name") self.client = ServerlessClient(**kwargs) elasticapm.instrument() From f5aa82c5a623e6372aad0873612543cc3dce6a1a Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 13 Feb 2020 16:29:01 -0700 Subject: [PATCH 06/30] Explicitly set the logLevel --- elasticapm/transport/serverless.py | 1 + 1 file changed, 1 insertion(+) diff --git a/elasticapm/transport/serverless.py b/elasticapm/transport/serverless.py index 0b3509442..f2135a7c5 100644 --- a/elasticapm/transport/serverless.py +++ b/elasticapm/transport/serverless.py @@ -35,6 +35,7 @@ # TODO logging formatter for minimal formatting logger = logging.getLogger("elasticapm.transport.serverless") +logger.setLevel(logging.INFO) class ServerlessTransport(Transport): From 6b671946a787657313b1cee795f4bb8f846d2f5a Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 14 Feb 2020 16:13:36 -0700 Subject: [PATCH 07/30] Just use print to avoid logging formatter issues --- elasticapm/transport/serverless.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/elasticapm/transport/serverless.py b/elasticapm/transport/serverless.py index f2135a7c5..1a1aff48a 100644 --- a/elasticapm/transport/serverless.py +++ b/elasticapm/transport/serverless.py @@ -28,15 +28,12 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAG +from __future__ import print_function + import json -import logging from elasticapm.transport.base import Transport -# TODO logging formatter for minimal formatting -logger = logging.getLogger("elasticapm.transport.serverless") -logger.setLevel(logging.INFO) - class ServerlessTransport(Transport): """ @@ -58,5 +55,4 @@ def queue(self, event_type, data, flush=False): Rather than queueing the data, just dump it to a log immediately """ # TODO definitely need to enrich and format this data - # TODO log level? - logger.info(json.dumps(data)) + print(json.dumps(data)) From a6bffda15740913d8004d331ca6128b7d31d1e76 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 14 Feb 2020 16:19:54 -0700 Subject: [PATCH 08/30] Add capture_serverless to top level import I also let black restructure this file. I don't see anything semantically different with black's version and it does look nicer. --- elasticapm/__init__.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/elasticapm/__init__.py b/elasticapm/__init__.py index fa26ac326..592f03501 100644 --- a/elasticapm/__init__.py +++ b/elasticapm/__init__.py @@ -29,6 +29,23 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE import sys +from elasticapm.base import Client, capture_serverless # noqa: F401 +from elasticapm.conf import setup_logging # noqa: F401 +from elasticapm.instrumentation.control import instrument, uninstrument # noqa: F401 +from elasticapm.traces import ( # noqa: F401 + capture_span, + get_span_id, + get_trace_id, + get_transaction_id, + label, + set_context, + set_custom_context, + set_transaction_name, + set_transaction_result, + set_user_context, + tag, +) + __all__ = ("VERSION", "Client") try: @@ -36,14 +53,6 @@ except Exception: VERSION = "unknown" -from elasticapm.base import Client -from elasticapm.conf import setup_logging # noqa: F401 -from elasticapm.instrumentation.control import instrument, uninstrument # noqa: F401 -from elasticapm.traces import capture_span, set_context, set_custom_context # noqa: F401 -from elasticapm.traces import set_transaction_name, set_user_context, tag, label # noqa: F401 -from elasticapm.traces import set_transaction_result # noqa: F401 -from elasticapm.traces import get_transaction_id, get_trace_id, get_span_id # noqa: F401 - if sys.version_info >= (3, 5): from elasticapm.contrib.asyncio.traces import async_capture_span # noqa: F401 From da19168e0da3a73e2139663d9c2f606d6caf3767 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 14 Feb 2020 16:46:57 -0700 Subject: [PATCH 09/30] Add processing --- elasticapm/transport/serverless.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/elasticapm/transport/serverless.py b/elasticapm/transport/serverless.py index 1a1aff48a..1a61ddc76 100644 --- a/elasticapm/transport/serverless.py +++ b/elasticapm/transport/serverless.py @@ -54,5 +54,6 @@ def queue(self, event_type, data, flush=False): """ Rather than queueing the data, just dump it to a log immediately """ - # TODO definitely need to enrich and format this data - print(json.dumps(data)) + # This does use processors right now, and we're not in a background + # thread. We could cause blocking. + print(json.dumps(self._process_event(event_type, data))) From c0356a56bfeafe03200a05d9d9936dde6152a1e4 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 14 Feb 2020 16:52:36 -0700 Subject: [PATCH 10/30] Add a prefix to our logs for identification --- elasticapm/transport/serverless.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elasticapm/transport/serverless.py b/elasticapm/transport/serverless.py index 1a61ddc76..873f008bf 100644 --- a/elasticapm/transport/serverless.py +++ b/elasticapm/transport/serverless.py @@ -56,4 +56,4 @@ def queue(self, event_type, data, flush=False): """ # This does use processors right now, and we're not in a background # thread. We could cause blocking. - print(json.dumps(self._process_event(event_type, data))) + print("ELASTICAPM " + json.dumps(self._process_event(event_type, data))) From 237c51df73b0767782b280e686dc8bb506b15628 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 14 Feb 2020 16:58:28 -0700 Subject: [PATCH 11/30] Move capture_serverless to contrib/serverless We're going to need context helpers and the like, makes sense for it to live in its own area --- elasticapm/__init__.py | 3 +- elasticapm/base.py | 39 +------------ elasticapm/contrib/serverless/__init__.py | 71 +++++++++++++++++++++++ 3 files changed, 74 insertions(+), 39 deletions(-) create mode 100644 elasticapm/contrib/serverless/__init__.py diff --git a/elasticapm/__init__.py b/elasticapm/__init__.py index 592f03501..386148f67 100644 --- a/elasticapm/__init__.py +++ b/elasticapm/__init__.py @@ -29,8 +29,9 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE import sys -from elasticapm.base import Client, capture_serverless # noqa: F401 +from elasticapm.base import Client # noqa: F401 from elasticapm.conf import setup_logging # noqa: F401 +from elasticapm.contrib.serverless import capture_serverless # noqa: F401 from elasticapm.instrumentation.control import instrument, uninstrument # noqa: F401 from elasticapm.traces import ( # noqa: F401 capture_span, diff --git a/elasticapm/base.py b/elasticapm/base.py index 3c114690c..6019f5d42 100644 --- a/elasticapm/base.py +++ b/elasticapm/base.py @@ -31,7 +31,6 @@ from __future__ import absolute_import -import functools import inspect import itertools import logging @@ -48,7 +47,7 @@ from elasticapm.conf.constants import ERROR from elasticapm.metrics.base_metrics import MetricsRegistry from elasticapm.traces import Tracer, execution_context -from elasticapm.utils import cgroup, compat, get_name_from_func, is_master_process, stacks, varmap +from elasticapm.utils import cgroup, compat, is_master_process, stacks, varmap from elasticapm.utils.encoding import enforce_label_format, keyword_field, shorten, transform from elasticapm.utils.logging import get_logger from elasticapm.utils.module_import import import_string @@ -546,39 +545,3 @@ def start_threads(self): No background threads for serverless """ pass - - -class capture_serverless(object): - """ - Context manager and decorator designed for instrumenting serverless - functions. - - Uses a logging-only version of the transport, and no background threads. - Begins and ends a single transaction. - """ - - # TODO save event information from API gateway in __call__, add to - # transaction in __exit__/__enter__ - - def __init__(self, **kwargs): - self.name = kwargs.get("name") - self.client = ServerlessClient(**kwargs) - elasticapm.instrument() - - def __call__(self, func): - self.name = self.name or get_name_from_func(func) - - @functools.wraps(func) - def decorated(*args, **kwds): - with self: - return func(*args, **kwds) - - return decorated - - def __enter__(self): - self.transaction = self.client.begin_transaction(self.name) - - def __exit__(self, exc_type, exc_val, exc_tb): - if exc_val: - self.client.capture_exception(exc_info=(exc_type, exc_val, exc_tb), handled=False) - self.client.end_transaction(self.name) diff --git a/elasticapm/contrib/serverless/__init__.py b/elasticapm/contrib/serverless/__init__.py new file mode 100644 index 000000000..94996d734 --- /dev/null +++ b/elasticapm/contrib/serverless/__init__.py @@ -0,0 +1,71 @@ +# BSD 3-Clause License +# +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import functools + +import elasticapm +from elasticapm.base import ServerlessClient +from elasticapm.utils import get_name_from_func + + +class capture_serverless(object): + """ + Context manager and decorator designed for instrumenting serverless + functions. + + Uses a logging-only version of the transport, and no background threads. + Begins and ends a single transaction. + """ + + # TODO save event information from API gateway in __call__, add to + # transaction in __exit__/__enter__ + + def __init__(self, **kwargs): + self.name = kwargs.get("name") + self.client = ServerlessClient(**kwargs) + elasticapm.instrument() + + def __call__(self, func): + self.name = self.name or get_name_from_func(func) + + @functools.wraps(func) + def decorated(*args, **kwds): + with self: + return func(*args, **kwds) + + return decorated + + def __enter__(self): + self.transaction = self.client.begin_transaction(self.name) + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val: + self.client.capture_exception(exc_info=(exc_type, exc_val, exc_tb), handled=False) + self.client.end_transaction(self.name) From baeb4038cc7e5e31d8863830ad1a8c59e2e06806 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Wed, 19 Feb 2020 13:58:35 -0700 Subject: [PATCH 12/30] Collect API Gateway request information + response --- elasticapm/contrib/serverless/__init__.py | 131 +++++++++++++++++++++- 1 file changed, 126 insertions(+), 5 deletions(-) diff --git a/elasticapm/contrib/serverless/__init__.py b/elasticapm/contrib/serverless/__init__.py index 94996d734..e8f27ea98 100644 --- a/elasticapm/contrib/serverless/__init__.py +++ b/elasticapm/contrib/serverless/__init__.py @@ -28,11 +28,15 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import base64 import functools +import json import elasticapm from elasticapm.base import ServerlessClient -from elasticapm.utils import get_name_from_func +from elasticapm.conf import constants +from elasticapm.utils import compat, encoding, get_name_from_func +from elasticapm.utils.disttracing import TraceParent class capture_serverless(object): @@ -49,23 +53,140 @@ class capture_serverless(object): def __init__(self, **kwargs): self.name = kwargs.get("name") + self.event = {} + self.context = {} + self.response = None self.client = ServerlessClient(**kwargs) - elasticapm.instrument() + if not self.client.config.debug and self.client.config.instrument: + elasticapm.instrument() def __call__(self, func): self.name = self.name or get_name_from_func(func) @functools.wraps(func) def decorated(*args, **kwds): - with self: + if len(args) == 2: + # Saving these for request context later + self.event, self.context = args + else: + self.event, self.context = {}, {} + if not self.client.config.debug and self.client.config.instrument: + with self: + self.response = func(*args, **kwds) + return self.response + else: return func(*args, **kwds) return decorated def __enter__(self): - self.transaction = self.client.begin_transaction(self.name) + """ + Transaction setup + """ + trace_parent = TraceParent.from_headers(self.event.get("headers")) + self.transaction = self.client.begin_transaction("request", trace_parent=trace_parent) + elasticapm.set_context( + lambda: get_data_from_request( + self.event, + capture_body=self.client.config.capture_body in ("transactions", "all"), + capture_headers=self.client.config.capture_headers, + ), + "request", + ) + if "httpMethod" in self.event and "resource" in self.event: + elasticapm.set_transaction_name("{} {}".format(self.event["httpMethod"], self.event["resource"])) + else: + elasticapm.set_transaction_name(self.name, override=False) def __exit__(self, exc_type, exc_val, exc_tb): + """ + Transaction teardown + """ if exc_val: self.client.capture_exception(exc_info=(exc_type, exc_val, exc_tb), handled=False) - self.client.end_transaction(self.name) + + if self.response and isinstance(self.response, dict): + elasticapm.set_context( + lambda: get_data_from_response(self.response, capture_headers=self.client.config.capture_headers), + "response", + ) + if "statusCode" in self.response: + result = "HTTP {}xx".format(int(self.response["statusCode"]) // 100) + elasticapm.set_transaction_result(result, override=False) + self.client.end_transaction() + + +def get_data_from_request(event, capture_body=False, capture_headers=True): + """ + Capture context data from API gateway event + """ + result = {} + if capture_headers and "headers" in event: + result["headers"] = event["headers"] + if "httpMethod" not in event: + # Not API Gateway + return result + + result["method"] = event["httpMethod"] + if event["httpMethod"] in constants.HTTP_WITH_BODY and "body" in event: + body = event["body"] + if event.get("isBase64Encoded"): + body = base64.b64decode(body) + else: + try: + jsonbody = json.loads(body) + body = jsonbody + except Exception: + pass + + if body is not None: + result["body"] = body if capture_body else "[REDACTED]" + + result["url"] = get_url_dict(event) + return result + + +def get_data_from_response(response, capture_headers=True): + """ + Capture response data from lambda return + """ + result = {} + + if "statusCode" in response: + result["status_code"] = response["statusCode"] + + if capture_headers and "headers" in response: + result["headers"] = response["headers"] + return result + + +def get_url_dict(event): + """ + Reconstruct URL from API Gateway + """ + headers = event.get("headers", {}) + proto = headers.get("X-Forwarded-Proto", "https") + host = headers.get("Host") + path = event.get("path") + port = headers.get("X-Forwarded-Port", "443") + query = "" + if event.get("queryStringParameters"): + query = "?" + for k, v in compat.iteritems(event["queryStringParameters"]): + query += "{}={}".format(k, v) + url = proto + "://" + host + path + query + + url_dict = { + "full": encoding.keyword_field(url), + "protocol": proto, + "hostname": encoding.keyword_field(host), + "pathname": encoding.keyword_field(path), + } + + port = None if port == "443" else str(port) + + if port: + url_dict["port"] = port + if query: + url_dict["search"] = encoding.keyword_field(query) + return url_dict From c6233eb455ee65bcb01875b0f1a5258401a1860f Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 20 Feb 2020 15:41:01 -0700 Subject: [PATCH 13/30] Always include the port --- elasticapm/contrib/serverless/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/elasticapm/contrib/serverless/__init__.py b/elasticapm/contrib/serverless/__init__.py index e8f27ea98..29244aed4 100644 --- a/elasticapm/contrib/serverless/__init__.py +++ b/elasticapm/contrib/serverless/__init__.py @@ -168,7 +168,7 @@ def get_url_dict(event): proto = headers.get("X-Forwarded-Proto", "https") host = headers.get("Host") path = event.get("path") - port = headers.get("X-Forwarded-Port", "443") + port = headers.get("X-Forwarded-Port") query = "" if event.get("queryStringParameters"): query = "?" @@ -183,8 +183,6 @@ def get_url_dict(event): "pathname": encoding.keyword_field(path), } - port = None if port == "443" else str(port) - if port: url_dict["port"] = port if query: From f7ca65193e6f6c08c970e33aa8741bbfc4075771 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 20 Feb 2020 16:06:17 -0700 Subject: [PATCH 14/30] Add requestContext to context dictionary --- elasticapm/contrib/serverless/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/elasticapm/contrib/serverless/__init__.py b/elasticapm/contrib/serverless/__init__.py index 29244aed4..4e2d9ddd7 100644 --- a/elasticapm/contrib/serverless/__init__.py +++ b/elasticapm/contrib/serverless/__init__.py @@ -123,6 +123,8 @@ def get_data_from_request(event, capture_body=False, capture_headers=True): result = {} if capture_headers and "headers" in event: result["headers"] = event["headers"] + if capture_headers and "requestContext" in event: + result["requestContext"] = event["requestContext"] if "httpMethod" not in event: # Not API Gateway return result From 4f3fcf5c534a35fc6910b6991e15469294769087 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 20 Feb 2020 16:06:34 -0700 Subject: [PATCH 15/30] Fix up URL generation --- elasticapm/contrib/serverless/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/elasticapm/contrib/serverless/__init__.py b/elasticapm/contrib/serverless/__init__.py index 4e2d9ddd7..60ac703ad 100644 --- a/elasticapm/contrib/serverless/__init__.py +++ b/elasticapm/contrib/serverless/__init__.py @@ -168,21 +168,22 @@ def get_url_dict(event): """ headers = event.get("headers", {}) proto = headers.get("X-Forwarded-Proto", "https") - host = headers.get("Host") - path = event.get("path") + host = headers.get("Host", "") + path = event.get("path", "") port = headers.get("X-Forwarded-Port") + stage = "/" + event.get("requestContext", {}).get("stage", "") query = "" if event.get("queryStringParameters"): query = "?" for k, v in compat.iteritems(event["queryStringParameters"]): query += "{}={}".format(k, v) - url = proto + "://" + host + path + query + url = proto + "://" + host + stage + path + query url_dict = { "full": encoding.keyword_field(url), "protocol": proto, "hostname": encoding.keyword_field(host), - "pathname": encoding.keyword_field(path), + "pathname": encoding.keyword_field(stage + path), } if port: From 3c14f8f3da9f5cffeb11a0958b1b9c98b3365a4c Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 20 Feb 2020 16:11:30 -0700 Subject: [PATCH 16/30] Remove TODO -- I did it! --- elasticapm/contrib/serverless/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/elasticapm/contrib/serverless/__init__.py b/elasticapm/contrib/serverless/__init__.py index 60ac703ad..ae5dcb329 100644 --- a/elasticapm/contrib/serverless/__init__.py +++ b/elasticapm/contrib/serverless/__init__.py @@ -48,9 +48,6 @@ class capture_serverless(object): Begins and ends a single transaction. """ - # TODO save event information from API gateway in __call__, add to - # transaction in __exit__/__enter__ - def __init__(self, **kwargs): self.name = kwargs.get("name") self.event = {} From ed69fddcddf08d22b7d11af590ed1a6a870496fd Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 11:38:10 -0700 Subject: [PATCH 17/30] Move capture_serverless into aws.py --- elasticapm/contrib/serverless/__init__.py | 167 +------------------ elasticapm/contrib/serverless/aws.py | 190 ++++++++++++++++++++++ 2 files changed, 199 insertions(+), 158 deletions(-) create mode 100644 elasticapm/contrib/serverless/aws.py diff --git a/elasticapm/contrib/serverless/__init__.py b/elasticapm/contrib/serverless/__init__.py index ae5dcb329..c64e5a5ee 100644 --- a/elasticapm/contrib/serverless/__init__.py +++ b/elasticapm/contrib/serverless/__init__.py @@ -28,163 +28,14 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import base64 -import functools -import json +import os -import elasticapm -from elasticapm.base import ServerlessClient -from elasticapm.conf import constants -from elasticapm.utils import compat, encoding, get_name_from_func -from elasticapm.utils.disttracing import TraceParent +# Future providers such as GCP and Azure will be added to this if/elif block +# This way you can use the same syntax for each of the providers from a user +# perspective +if os.environ.get("AWS_REGION"): + from elasticapm.contrib.serverless.aws import capture_serverless +else: + from elasticapm.contrib.serverless.aws import capture_serverless - -class capture_serverless(object): - """ - Context manager and decorator designed for instrumenting serverless - functions. - - Uses a logging-only version of the transport, and no background threads. - Begins and ends a single transaction. - """ - - def __init__(self, **kwargs): - self.name = kwargs.get("name") - self.event = {} - self.context = {} - self.response = None - self.client = ServerlessClient(**kwargs) - if not self.client.config.debug and self.client.config.instrument: - elasticapm.instrument() - - def __call__(self, func): - self.name = self.name or get_name_from_func(func) - - @functools.wraps(func) - def decorated(*args, **kwds): - if len(args) == 2: - # Saving these for request context later - self.event, self.context = args - else: - self.event, self.context = {}, {} - if not self.client.config.debug and self.client.config.instrument: - with self: - self.response = func(*args, **kwds) - return self.response - else: - return func(*args, **kwds) - - return decorated - - def __enter__(self): - """ - Transaction setup - """ - trace_parent = TraceParent.from_headers(self.event.get("headers")) - self.transaction = self.client.begin_transaction("request", trace_parent=trace_parent) - elasticapm.set_context( - lambda: get_data_from_request( - self.event, - capture_body=self.client.config.capture_body in ("transactions", "all"), - capture_headers=self.client.config.capture_headers, - ), - "request", - ) - if "httpMethod" in self.event and "resource" in self.event: - elasticapm.set_transaction_name("{} {}".format(self.event["httpMethod"], self.event["resource"])) - else: - elasticapm.set_transaction_name(self.name, override=False) - - def __exit__(self, exc_type, exc_val, exc_tb): - """ - Transaction teardown - """ - if exc_val: - self.client.capture_exception(exc_info=(exc_type, exc_val, exc_tb), handled=False) - - if self.response and isinstance(self.response, dict): - elasticapm.set_context( - lambda: get_data_from_response(self.response, capture_headers=self.client.config.capture_headers), - "response", - ) - if "statusCode" in self.response: - result = "HTTP {}xx".format(int(self.response["statusCode"]) // 100) - elasticapm.set_transaction_result(result, override=False) - self.client.end_transaction() - - -def get_data_from_request(event, capture_body=False, capture_headers=True): - """ - Capture context data from API gateway event - """ - result = {} - if capture_headers and "headers" in event: - result["headers"] = event["headers"] - if capture_headers and "requestContext" in event: - result["requestContext"] = event["requestContext"] - if "httpMethod" not in event: - # Not API Gateway - return result - - result["method"] = event["httpMethod"] - if event["httpMethod"] in constants.HTTP_WITH_BODY and "body" in event: - body = event["body"] - if event.get("isBase64Encoded"): - body = base64.b64decode(body) - else: - try: - jsonbody = json.loads(body) - body = jsonbody - except Exception: - pass - - if body is not None: - result["body"] = body if capture_body else "[REDACTED]" - - result["url"] = get_url_dict(event) - return result - - -def get_data_from_response(response, capture_headers=True): - """ - Capture response data from lambda return - """ - result = {} - - if "statusCode" in response: - result["status_code"] = response["statusCode"] - - if capture_headers and "headers" in response: - result["headers"] = response["headers"] - return result - - -def get_url_dict(event): - """ - Reconstruct URL from API Gateway - """ - headers = event.get("headers", {}) - proto = headers.get("X-Forwarded-Proto", "https") - host = headers.get("Host", "") - path = event.get("path", "") - port = headers.get("X-Forwarded-Port") - stage = "/" + event.get("requestContext", {}).get("stage", "") - query = "" - if event.get("queryStringParameters"): - query = "?" - for k, v in compat.iteritems(event["queryStringParameters"]): - query += "{}={}".format(k, v) - url = proto + "://" + host + stage + path + query - - url_dict = { - "full": encoding.keyword_field(url), - "protocol": proto, - "hostname": encoding.keyword_field(host), - "pathname": encoding.keyword_field(stage + path), - } - - if port: - url_dict["port"] = port - if query: - url_dict["search"] = encoding.keyword_field(query) - return url_dict +__all__ = ("capture_serverless",) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py new file mode 100644 index 000000000..ae5dcb329 --- /dev/null +++ b/elasticapm/contrib/serverless/aws.py @@ -0,0 +1,190 @@ +# BSD 3-Clause License +# +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import base64 +import functools +import json + +import elasticapm +from elasticapm.base import ServerlessClient +from elasticapm.conf import constants +from elasticapm.utils import compat, encoding, get_name_from_func +from elasticapm.utils.disttracing import TraceParent + + +class capture_serverless(object): + """ + Context manager and decorator designed for instrumenting serverless + functions. + + Uses a logging-only version of the transport, and no background threads. + Begins and ends a single transaction. + """ + + def __init__(self, **kwargs): + self.name = kwargs.get("name") + self.event = {} + self.context = {} + self.response = None + self.client = ServerlessClient(**kwargs) + if not self.client.config.debug and self.client.config.instrument: + elasticapm.instrument() + + def __call__(self, func): + self.name = self.name or get_name_from_func(func) + + @functools.wraps(func) + def decorated(*args, **kwds): + if len(args) == 2: + # Saving these for request context later + self.event, self.context = args + else: + self.event, self.context = {}, {} + if not self.client.config.debug and self.client.config.instrument: + with self: + self.response = func(*args, **kwds) + return self.response + else: + return func(*args, **kwds) + + return decorated + + def __enter__(self): + """ + Transaction setup + """ + trace_parent = TraceParent.from_headers(self.event.get("headers")) + self.transaction = self.client.begin_transaction("request", trace_parent=trace_parent) + elasticapm.set_context( + lambda: get_data_from_request( + self.event, + capture_body=self.client.config.capture_body in ("transactions", "all"), + capture_headers=self.client.config.capture_headers, + ), + "request", + ) + if "httpMethod" in self.event and "resource" in self.event: + elasticapm.set_transaction_name("{} {}".format(self.event["httpMethod"], self.event["resource"])) + else: + elasticapm.set_transaction_name(self.name, override=False) + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Transaction teardown + """ + if exc_val: + self.client.capture_exception(exc_info=(exc_type, exc_val, exc_tb), handled=False) + + if self.response and isinstance(self.response, dict): + elasticapm.set_context( + lambda: get_data_from_response(self.response, capture_headers=self.client.config.capture_headers), + "response", + ) + if "statusCode" in self.response: + result = "HTTP {}xx".format(int(self.response["statusCode"]) // 100) + elasticapm.set_transaction_result(result, override=False) + self.client.end_transaction() + + +def get_data_from_request(event, capture_body=False, capture_headers=True): + """ + Capture context data from API gateway event + """ + result = {} + if capture_headers and "headers" in event: + result["headers"] = event["headers"] + if capture_headers and "requestContext" in event: + result["requestContext"] = event["requestContext"] + if "httpMethod" not in event: + # Not API Gateway + return result + + result["method"] = event["httpMethod"] + if event["httpMethod"] in constants.HTTP_WITH_BODY and "body" in event: + body = event["body"] + if event.get("isBase64Encoded"): + body = base64.b64decode(body) + else: + try: + jsonbody = json.loads(body) + body = jsonbody + except Exception: + pass + + if body is not None: + result["body"] = body if capture_body else "[REDACTED]" + + result["url"] = get_url_dict(event) + return result + + +def get_data_from_response(response, capture_headers=True): + """ + Capture response data from lambda return + """ + result = {} + + if "statusCode" in response: + result["status_code"] = response["statusCode"] + + if capture_headers and "headers" in response: + result["headers"] = response["headers"] + return result + + +def get_url_dict(event): + """ + Reconstruct URL from API Gateway + """ + headers = event.get("headers", {}) + proto = headers.get("X-Forwarded-Proto", "https") + host = headers.get("Host", "") + path = event.get("path", "") + port = headers.get("X-Forwarded-Port") + stage = "/" + event.get("requestContext", {}).get("stage", "") + query = "" + if event.get("queryStringParameters"): + query = "?" + for k, v in compat.iteritems(event["queryStringParameters"]): + query += "{}={}".format(k, v) + url = proto + "://" + host + stage + path + query + + url_dict = { + "full": encoding.keyword_field(url), + "protocol": proto, + "hostname": encoding.keyword_field(host), + "pathname": encoding.keyword_field(stage + path), + } + + if port: + url_dict["port"] = port + if query: + url_dict["search"] = encoding.keyword_field(query) + return url_dict From a44d96e744ee36746f34566e39b7ef42c18044f4 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 11:41:22 -0700 Subject: [PATCH 18/30] Set the transaction type dynamically --- elasticapm/contrib/serverless/aws.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index ae5dcb329..cece753df 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -81,18 +81,22 @@ def __enter__(self): Transaction setup """ trace_parent = TraceParent.from_headers(self.event.get("headers")) - self.transaction = self.client.begin_transaction("request", trace_parent=trace_parent) - elasticapm.set_context( - lambda: get_data_from_request( - self.event, - capture_body=self.client.config.capture_body in ("transactions", "all"), - capture_headers=self.client.config.capture_headers, - ), - "request", - ) - if "httpMethod" in self.event and "resource" in self.event: - elasticapm.set_transaction_name("{} {}".format(self.event["httpMethod"], self.event["resource"])) + if "httpMethod" in self.event: + self.transaction = self.client.begin_transaction("request", trace_parent=trace_parent) + elasticapm.set_context( + lambda: get_data_from_request( + self.event, + capture_body=self.client.config.capture_body in ("transactions", "all"), + capture_headers=self.client.config.capture_headers, + ), + "request", + ) + if "resource" in self.event: + elasticapm.set_transaction_name("{} {}".format(self.event["httpMethod"], self.event["resource"])) + else: + elasticapm.set_transaction_name(self.name, override=False) else: + self.transaction = self.client.begin_transaction("function", trace_parent=trace_parent) elasticapm.set_transaction_name(self.name, override=False) def __exit__(self, exc_type, exc_val, exc_tb): From 782302edf040058ac44b97b40556ca1168a004ac Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 11:43:10 -0700 Subject: [PATCH 19/30] Minimize processing if capture_body is False --- elasticapm/contrib/serverless/aws.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index cece753df..3455fa4c3 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -133,14 +133,15 @@ def get_data_from_request(event, capture_body=False, capture_headers=True): result["method"] = event["httpMethod"] if event["httpMethod"] in constants.HTTP_WITH_BODY and "body" in event: body = event["body"] - if event.get("isBase64Encoded"): - body = base64.b64decode(body) - else: - try: - jsonbody = json.loads(body) - body = jsonbody - except Exception: - pass + if capture_body: + if event.get("isBase64Encoded"): + body = base64.b64decode(body) + else: + try: + jsonbody = json.loads(body) + body = jsonbody + except Exception: + pass if body is not None: result["body"] = body if capture_body else "[REDACTED]" From cb57a3b02586ed08165e0ab79f42dab7861018d9 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 11:59:49 -0700 Subject: [PATCH 20/30] Add additional context and naming from AWS env vars --- elasticapm/contrib/serverless/aws.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index 3455fa4c3..5238f1784 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -31,6 +31,7 @@ import base64 import functools import json +import os import elasticapm from elasticapm.base import ServerlessClient @@ -53,6 +54,9 @@ def __init__(self, **kwargs): self.event = {} self.context = {} self.response = None + if "framework_name" not in kwargs: + kwargs["framework_name"] = os.environ.get("AWS_EXECUTION_ENV", "AWS_Lambda_python") + self.client = ServerlessClient(**kwargs) if not self.client.config.debug and self.client.config.instrument: elasticapm.instrument() @@ -91,13 +95,17 @@ def __enter__(self): ), "request", ) - if "resource" in self.event: - elasticapm.set_transaction_name("{} {}".format(self.event["httpMethod"], self.event["resource"])) + if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"): + elasticapm.set_transaction_name( + "{} {}".format(self.event["httpMethod"], os.environ["AWS_LAMBDA_FUNCTION_NAME"]) + ) else: elasticapm.set_transaction_name(self.name, override=False) else: self.transaction = self.client.begin_transaction("function", trace_parent=trace_parent) - elasticapm.set_transaction_name(self.name, override=False) + elasticapm.set_transaction_name(os.environ.get("AWS_LAMBDA_FUNCTION_NAME", self.name), override=False) + if os.environ.get("AWS_LAMBDA_FUNCTION_VERSION"): + elasticapm.set_context({"version": os.environ["AWS_LAMBDA_FUNCTION_VERSION"]}, "service") def __exit__(self, exc_type, exc_val, exc_tb): """ From 1c0cf99e364ebde82379e77a52141f98aa869fee Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 12:06:07 -0700 Subject: [PATCH 21/30] Remove service.version Since it's (usually) just `$LATEST`, it's not useful for annotations --- elasticapm/contrib/serverless/aws.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index 5238f1784..e39b406b8 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -104,8 +104,6 @@ def __enter__(self): else: self.transaction = self.client.begin_transaction("function", trace_parent=trace_parent) elasticapm.set_transaction_name(os.environ.get("AWS_LAMBDA_FUNCTION_NAME", self.name), override=False) - if os.environ.get("AWS_LAMBDA_FUNCTION_VERSION"): - elasticapm.set_context({"version": os.environ["AWS_LAMBDA_FUNCTION_VERSION"]}, "service") def __exit__(self, exc_type, exc_val, exc_tb): """ From 3aef83d9f5cac99e30ed175975ba72adc373ac95 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 12:50:22 -0700 Subject: [PATCH 22/30] Fix framework_name --- .pre-commit-config.yaml | 2 +- elasticapm/contrib/serverless/aws.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8392448f0..82ffa1a73 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,6 +24,6 @@ repos: name: License header check description: Checks the existance of license headers in all Python files entry: ./tests/scripts/license_headers_check.sh - exclude: "(elasticapm/utils/wrapt/.*|tests/utils/stacks/linenos.py|tests/utils/stacks/linenos2.py)" + exclude: "(elasticapm/utils/wrapt/.*|tests/utils/stacks/linenos.py|tests/utils/stacks/linenos2.py|tests/contrib/serverless/.*json)" language: script types: [python] diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index e39b406b8..24ef8b8bc 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -54,8 +54,11 @@ def __init__(self, **kwargs): self.event = {} self.context = {} self.response = None - if "framework_name" not in kwargs: - kwargs["framework_name"] = os.environ.get("AWS_EXECUTION_ENV", "AWS_Lambda_python") + + config = kwargs.get("config", {}) + if "framework_name" not in config: + config["framework_name"] = os.environ.get("AWS_EXECUTION_ENV", "AWS_Lambda_python") + kwargs["config"] = config self.client = ServerlessClient(**kwargs) if not self.client.config.debug and self.client.config.instrument: From 53b6ca254a097bd33d570aead629299c29419502 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 12:52:38 -0700 Subject: [PATCH 23/30] Use empty dict for missing headers --- elasticapm/contrib/serverless/aws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index 24ef8b8bc..015f45f6d 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -87,7 +87,7 @@ def __enter__(self): """ Transaction setup """ - trace_parent = TraceParent.from_headers(self.event.get("headers")) + trace_parent = TraceParent.from_headers(self.event.get("headers", {})) if "httpMethod" in self.event: self.transaction = self.client.begin_transaction("request", trace_parent=trace_parent) elasticapm.set_context( From 7d8cb43b75d918cc842afdb992177127419605e5 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 13:00:53 -0700 Subject: [PATCH 24/30] Still no framework info in event --- elasticapm/contrib/serverless/aws.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index 015f45f6d..1219bc7d5 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -32,6 +32,7 @@ import functools import json import os +import sys import elasticapm from elasticapm.base import ServerlessClient @@ -55,12 +56,12 @@ def __init__(self, **kwargs): self.context = {} self.response = None - config = kwargs.get("config", {}) + config = kwargs.pop("config") if "config" in kwargs else {} if "framework_name" not in config: config["framework_name"] = os.environ.get("AWS_EXECUTION_ENV", "AWS_Lambda_python") - kwargs["config"] = config + config["framework_version"] = sys.version - self.client = ServerlessClient(**kwargs) + self.client = ServerlessClient(config=config, **kwargs) if not self.client.config.debug and self.client.config.instrument: elasticapm.instrument() From 25ed35a11b17b20286d636a4baea22b2fd651cfa Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 13:14:06 -0700 Subject: [PATCH 25/30] Print metadata for serverless --- elasticapm/transport/serverless.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/elasticapm/transport/serverless.py b/elasticapm/transport/serverless.py index 873f008bf..c552cdfe1 100644 --- a/elasticapm/transport/serverless.py +++ b/elasticapm/transport/serverless.py @@ -44,6 +44,10 @@ class ServerlessTransport(Transport): it anywhere. """ + def __init__(self, *args, **kwargs): + super(ServerlessTransport, self).__init__(*args, **kwargs) + self.printed_metadata = False + def start_thread(self): """ No background threads are needed, as we have no queueing needs @@ -56,4 +60,7 @@ def queue(self, event_type, data, flush=False): """ # This does use processors right now, and we're not in a background # thread. We could cause blocking. + if not self.printed_metadata: + self.printed_metadata = True + print("ELASTICAPM_METADATA " + json.dumps(self._metadata)) print("ELASTICAPM " + json.dumps(self._process_event(event_type, data))) From 15ee8f68a03a659d36159dbda19da4f868dcda04 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 13:27:04 -0700 Subject: [PATCH 26/30] Just pass framework info inline --- elasticapm/contrib/serverless/aws.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index 1219bc7d5..4f8c07cb1 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -56,12 +56,11 @@ def __init__(self, **kwargs): self.context = {} self.response = None - config = kwargs.pop("config") if "config" in kwargs else {} - if "framework_name" not in config: - config["framework_name"] = os.environ.get("AWS_EXECUTION_ENV", "AWS_Lambda_python") - config["framework_version"] = sys.version + if "framework_name" not in kwargs: + kwargs["framework_name"] = os.environ.get("AWS_EXECUTION_ENV", "AWS_Lambda_python") + kwargs["framework_version"] = sys.version - self.client = ServerlessClient(config=config, **kwargs) + self.client = ServerlessClient(**kwargs) if not self.client.config.debug and self.client.config.instrument: elasticapm.instrument() From ea47280884ddc0e8bfcc66318d4d98fdfdc9a1c0 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 13:31:31 -0700 Subject: [PATCH 27/30] Remove framework_version This is already included in the framework_name (and the runtime info) --- elasticapm/contrib/serverless/aws.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index 4f8c07cb1..36d0d6520 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -32,7 +32,6 @@ import functools import json import os -import sys import elasticapm from elasticapm.base import ServerlessClient @@ -58,7 +57,6 @@ def __init__(self, **kwargs): if "framework_name" not in kwargs: kwargs["framework_name"] = os.environ.get("AWS_EXECUTION_ENV", "AWS_Lambda_python") - kwargs["framework_version"] = sys.version self.client = ServerlessClient(**kwargs) if not self.client.config.debug and self.client.config.instrument: From 7e81a27c6e14083cc308e5c803933ed9a9338ad1 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Fri, 21 Feb 2020 13:34:18 -0700 Subject: [PATCH 28/30] Add serverless tests --- tests/contrib/serverless/__init__.py | 29 +++++ tests/contrib/serverless/aws_test_data.json | 117 +++++++++++++++++++ tests/contrib/serverless/aws_tests.py | 119 ++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 tests/contrib/serverless/__init__.py create mode 100644 tests/contrib/serverless/aws_test_data.json create mode 100644 tests/contrib/serverless/aws_tests.py diff --git a/tests/contrib/serverless/__init__.py b/tests/contrib/serverless/__init__.py new file mode 100644 index 000000000..7e2b340e6 --- /dev/null +++ b/tests/contrib/serverless/__init__.py @@ -0,0 +1,29 @@ +# BSD 3-Clause License +# +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/contrib/serverless/aws_test_data.json b/tests/contrib/serverless/aws_test_data.json new file mode 100644 index 000000000..f66f82491 --- /dev/null +++ b/tests/contrib/serverless/aws_test_data.json @@ -0,0 +1,117 @@ +{ + "resource": "/fetch_all", + "path": "/fetch_all", + "httpMethod": "GET", + "headers": { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "en-US,en;q=0.5", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "Host": "02plqthge2.execute-api.us-east-1.amazonaws.com", + "upgrade-insecure-requests": "1", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0", + "Via": "2.0 969f35f01b6eddd92239a3e818fc1e0d.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "eDbpfDwO-CRYymEFLkW6CBCsU_H_PS8R93_us53QWvXWLS45v3NvQw==", + "X-Amzn-Trace-Id": "Root=1-5e502af4-fd0c1c6fdc164e1d6361183b", + "X-Forwarded-For": "76.76.241.57, 52.46.47.139", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "multiValueHeaders": { + "Accept": [ + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8" + ], + "Accept-Encoding": [ + "gzip, deflate, br" + ], + "Accept-Language": [ + "en-US,en;q=0.5" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-Country": [ + "US" + ], + "Host": [ + "02plqthge2.execute-api.us-east-1.amazonaws.com" + ], + "upgrade-insecure-requests": [ + "1" + ], + "User-Agent": [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0" + ], + "Via": [ + "2.0 969f35f01b6eddd92239a3e818fc1e0d.cloudfront.net (CloudFront)" + ], + "X-Amz-Cf-Id": [ + "eDbpfDwO-CRYymEFLkW6CBCsU_H_PS8R93_us53QWvXWLS45v3NvQw==" + ], + "X-Amzn-Trace-Id": [ + "Root=1-5e502af4-fd0c1c6fdc164e1d6361183b" + ], + "X-Forwarded-For": [ + "76.76.241.57, 52.46.47.139" + ], + "X-Forwarded-Port": [ + "443" + ], + "X-Forwarded-Proto": [ + "https" + ] + }, + "queryStringParameters": null, + "multiValueQueryStringParameters": null, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "resourceId": "y3tkf7", + "resourcePath": "/fetch_all", + "httpMethod": "GET", + "extendedRequestId": "IQumRELJIAMF6fQ=", + "requestTime": "21/Feb/2020:19:09:40 +0000", + "path": "/dev/fetch_all", + "accountId": "571481734049", + "protocol": "HTTP/1.1", + "stage": "dev", + "domainPrefix": "02plqthge2", + "requestTimeEpoch": 1582312180890, + "requestId": "6f3dffca-46f8-4c8b-800b-6bc1ea2554ec", + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "sourceIp": "76.76.241.57", + "principalOrgId": null, + "accessKey": null, + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0", + "user": null + }, + "domainName": "02plqthge2.execute-api.us-east-1.amazonaws.com", + "apiId": "02plqthge2" + }, + "body": null, + "isBase64Encoded": false +} \ No newline at end of file diff --git a/tests/contrib/serverless/aws_tests.py b/tests/contrib/serverless/aws_tests.py new file mode 100644 index 000000000..e617a13ec --- /dev/null +++ b/tests/contrib/serverless/aws_tests.py @@ -0,0 +1,119 @@ +# BSD 3-Clause License +# +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import pytest # isort:skip + +import json +import os +import time + +from elasticapm import capture_span +from elasticapm.contrib.serverless.aws import capture_serverless, get_data_from_request, get_data_from_response + + +@pytest.fixture +def event(): + aws_data_file = os.path.join(os.path.dirname(__file__), "aws_test_data.json") + with open(aws_data_file) as f: + return json.load(f) + + +def test_request_data(event): + data = get_data_from_request(event, capture_body=True, capture_headers=True) + + assert data["method"] == "GET" + assert data["url"]["full"] == "https://02plqthge2.execute-api.us-east-1.amazonaws.com/dev/fetch_all" + assert data["requestContext"]["stage"] == "dev" + assert data["headers"]["Host"] == "02plqthge2.execute-api.us-east-1.amazonaws.com" + + data = get_data_from_request(event, capture_body=False, capture_headers=False) + + assert data["method"] == "GET" + assert data["url"]["full"] == "https://02plqthge2.execute-api.us-east-1.amazonaws.com/dev/fetch_all" + assert "requestContext" not in data + assert "headers" not in data + + +def test_response_data(): + response = {"statusCode": 200, "headers": {"foo": "bar"}} + + data = get_data_from_response(response, capture_headers=True) + + assert data["status_code"] == 200 + assert data["headers"]["foo"] == "bar" + + data = get_data_from_response(response, capture_headers=False) + + assert data["status_code"] == 200 + assert "headers" not in data + + data = get_data_from_response({}, capture_headers=False) + + assert not data + + +def test_capture_serverless(event, capsys): + + os.environ["AWS_LAMBDA_FUNCTION_NAME"] = "test_func" + + capture_object = capture_serverless() + capture_object.event = event + capture_object.name = "GET" + + with capture_object: + with capture_span(): + time.sleep(0.1) + capture_object.response = {"statusCode": 200, "headers": {"foo": "bar"}} + + stdout = capsys.readouterr().out.splitlines() + + assert len(stdout) == 3 + + metadata_line = stdout[0] + + assert metadata_line.startswith("ELASTICAPM_METADATA ") + + metadata_data = json.loads(metadata_line.split("ELASTICAPM_METADATA ")[1]) + + assert metadata_data["service"]["framework"]["name"] == "AWS_Lambda_python" + + transaction_line = stdout[2] + + assert transaction_line.startswith("ELASTICAPM ") + + transaction_data = json.loads(transaction_line.split("ELASTICAPM ")[1]) + + assert transaction_data["name"] == "GET test_func" + assert transaction_data["result"] == "HTTP 2xx" + assert transaction_data["span_count"]["started"] == 1 + assert transaction_data["context"]["request"]["method"] == "GET" + assert transaction_data["context"]["request"]["headers"] + assert transaction_data["context"]["request"]["requestContext"] + assert transaction_data["context"]["response"]["status_code"] == 200 From ca542aa8fea1e0d1d157fd95305161c9f9eb4214 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Tue, 25 Feb 2020 13:22:05 -0700 Subject: [PATCH 29/30] Don't collect requestContext If it's relevant to a given user they can collect it as custom context or we can add it (config-gated) later --- elasticapm/contrib/serverless/aws.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index 36d0d6520..6cc0d1b86 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -131,8 +131,6 @@ def get_data_from_request(event, capture_body=False, capture_headers=True): result = {} if capture_headers and "headers" in event: result["headers"] = event["headers"] - if capture_headers and "requestContext" in event: - result["requestContext"] = event["requestContext"] if "httpMethod" not in event: # Not API Gateway return result From 904e25fd72e700a54df1a635648014e7c11e562c Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 27 Feb 2020 09:27:53 -0700 Subject: [PATCH 30/30] Remove requestContext refs from tests --- tests/contrib/serverless/aws_tests.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/contrib/serverless/aws_tests.py b/tests/contrib/serverless/aws_tests.py index e617a13ec..d6805c9f9 100644 --- a/tests/contrib/serverless/aws_tests.py +++ b/tests/contrib/serverless/aws_tests.py @@ -50,14 +50,12 @@ def test_request_data(event): assert data["method"] == "GET" assert data["url"]["full"] == "https://02plqthge2.execute-api.us-east-1.amazonaws.com/dev/fetch_all" - assert data["requestContext"]["stage"] == "dev" assert data["headers"]["Host"] == "02plqthge2.execute-api.us-east-1.amazonaws.com" data = get_data_from_request(event, capture_body=False, capture_headers=False) assert data["method"] == "GET" assert data["url"]["full"] == "https://02plqthge2.execute-api.us-east-1.amazonaws.com/dev/fetch_all" - assert "requestContext" not in data assert "headers" not in data @@ -115,5 +113,4 @@ def test_capture_serverless(event, capsys): assert transaction_data["span_count"]["started"] == 1 assert transaction_data["context"]["request"]["method"] == "GET" assert transaction_data["context"]["request"]["headers"] - assert transaction_data["context"]["request"]["requestContext"] assert transaction_data["context"]["response"]["status_code"] == 200