Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3163616
Add skeleton for serverless transport and decorator
basepi Feb 12, 2020
76a0ed5
Remove references to Client.send()
basepi Feb 13, 2020
4e5bcc1
Add ServerlessClient class
basepi Feb 13, 2020
eee211e
Use kwargs for all transport __init__ parameters
basepi Feb 13, 2020
342e31f
Make sure `self.name` is present
basepi Feb 13, 2020
f5aa82c
Explicitly set the logLevel
basepi Feb 13, 2020
6b67194
Just use print to avoid logging formatter issues
basepi Feb 14, 2020
a6bffda
Add capture_serverless to top level import
basepi Feb 14, 2020
da19168
Add processing
basepi Feb 14, 2020
c0356a5
Add a prefix to our logs for identification
basepi Feb 14, 2020
237c51d
Move capture_serverless to contrib/serverless
basepi Feb 14, 2020
baeb403
Collect API Gateway request information + response
basepi Feb 19, 2020
c6233eb
Always include the port
basepi Feb 20, 2020
f7ca651
Add requestContext to context dictionary
basepi Feb 20, 2020
4f3fcf5
Fix up URL generation
basepi Feb 20, 2020
abdf639
Merge remote-tracking branch 'upstream/master' into serverless
basepi Feb 20, 2020
3c14f8f
Remove TODO -- I did it!
basepi Feb 20, 2020
ed69fdd
Move capture_serverless into aws.py
basepi Feb 21, 2020
a44d96e
Set the transaction type dynamically
basepi Feb 21, 2020
782302e
Minimize processing if capture_body is False
basepi Feb 21, 2020
ed17b78
Merge remote-tracking branch 'upstream/master' into serverless
basepi Feb 21, 2020
cb57a3b
Add additional context and naming from AWS env vars
basepi Feb 21, 2020
1c0cf99
Remove service.version
basepi Feb 21, 2020
3aef83d
Fix framework_name
basepi Feb 21, 2020
53b6ca2
Use empty dict for missing headers
basepi Feb 21, 2020
7d8cb43
Still no framework info in event
basepi Feb 21, 2020
25ed35a
Print metadata for serverless
basepi Feb 21, 2020
15ee8f6
Just pass framework info inline
basepi Feb 21, 2020
ea47280
Remove framework_version
basepi Feb 21, 2020
7e81a27
Add serverless tests
basepi Feb 21, 2020
cddc51c
Merge remote-tracking branch 'upstream/master' into serverless
basepi Feb 25, 2020
ca542aa
Don't collect requestContext
basepi Feb 25, 2020
904e25f
Remove requestContext refs from tests
basepi Feb 27, 2020
eaa12bc
Merge remote-tracking branch 'upstream/master' into serverless
basepi Feb 27, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
26 changes: 18 additions & 8 deletions elasticapm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,31 @@
# 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, last time I did this, all our linters got into a kerfuffle. I think it was black and flake8 duking it out

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, both are running on that file in my IDE and both are happy. Took some experimentation as to the location of the # noqa: F401 line (which is why I know both were running).

Could you try checking it out and see if your IDE gives you problems?

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:
VERSION = __import__("pkg_resources").get_distribution("elastic-apm").version
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
23 changes: 18 additions & 5 deletions elasticapm/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
13 changes: 0 additions & 13 deletions elasticapm/contrib/django/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
41 changes: 41 additions & 0 deletions elasticapm/contrib/serverless/__init__.py
Original file line number Diff line number Diff line change
@@ -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",)
200 changes: 200 additions & 0 deletions elasticapm/contrib/serverless/aws.py
Original file line number Diff line number Diff line change
@@ -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
Loading