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/__init__.py b/elasticapm/__init__.py index fa26ac326..386148f67 100644 --- a/elasticapm/__init__.py +++ b/elasticapm/__init__.py @@ -29,6 +29,24 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE import sys +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, + 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 +54,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 diff --git a/elasticapm/base.py b/elasticapm/base.py index d1e6a6d33..4f0aae468 100644 --- a/elasticapm/base.py +++ b/elasticapm/base.py @@ -144,7 +144,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 @@ -527,8 +527,21 @@ 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""" +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 send(self, url, **kwargs): - return None + def start_threads(self): + """ + No background threads for serverless + """ + pass 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): """ diff --git a/elasticapm/contrib/serverless/__init__.py b/elasticapm/contrib/serverless/__init__.py new file mode 100644 index 000000000..c64e5a5ee --- /dev/null +++ b/elasticapm/contrib/serverless/__init__.py @@ -0,0 +1,41 @@ +# 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 os + +# 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 + +__all__ = ("capture_serverless",) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py new file mode 100644 index 000000000..6cc0d1b86 --- /dev/null +++ b/elasticapm/contrib/serverless/aws.py @@ -0,0 +1,200 @@ +# 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 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 + + +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 + + 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() + + 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", {})) + 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 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(os.environ.get("AWS_LAMBDA_FUNCTION_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 "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 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]" + + 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 diff --git a/elasticapm/transport/serverless.py b/elasticapm/transport/serverless.py new file mode 100644 index 000000000..c552cdfe1 --- /dev/null +++ b/elasticapm/transport/serverless.py @@ -0,0 +1,66 @@ +# 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 + +from __future__ import print_function + +import json + +from elasticapm.transport.base import Transport + + +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 __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 + """ + pass + + def queue(self, event_type, data, flush=False): + """ + Rather than queueing the data, just dump it to a log immediately + """ + # 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))) 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..d6805c9f9 --- /dev/null +++ b/tests/contrib/serverless/aws_tests.py @@ -0,0 +1,116 @@ +# 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["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 "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"]["response"]["status_code"] == 200