Repository: elastic/apm-agent-python

Issue:
Implement support for chained exceptions
Python 3 supports chained exceptions. Its purpose is to handle cases like this:

    try:
        something_that_breaks()
    except BreakingException:
        do_something_else_that_breaks()  # raises KaboomException

Without chained exceptions, only the `KaboomException` is recorded, the exception information of the `BreakingException` is lost. Python 3 stores the original exception in the `__context__` attribute of the exception value.

Python 3 also introduced an explicit way to raise a chained exception:

    try:
        something_that_breaks()
    except BreakingException as exc:
        raise RetryException('Retry later') from exc

In this case, the `BreakingException` is stored as `__cause__`.

An implementation has been [proposed](https://github.com/elastic/apm/issues/40#issuecomment-508619451) by @axw. 


Discussion:

Resolving patch:
diff --git a/elasticapm/conf/constants.py b/elasticapm/conf/constants.py
--- a/elasticapm/conf/constants.py
+++ b/elasticapm/conf/constants.py
@@ -45,6 +45,8 @@
 
 MASK = 8 * "*"
 
+EXCEPTION_CHAIN_MAX_DEPTH = 50
+
 ERROR = "error"
 TRANSACTION = "transaction"
 SPAN = "span"
diff --git a/elasticapm/events.py b/elasticapm/events.py
--- a/elasticapm/events.py
+++ b/elasticapm/events.py
@@ -33,7 +33,8 @@
 import random
 import sys
 
-from elasticapm.utils import varmap
+from elasticapm.conf.constants import EXCEPTION_CHAIN_MAX_DEPTH
+from elasticapm.utils import compat, varmap
 from elasticapm.utils.encoding import keyword_field, shorten, to_unicode
 from elasticapm.utils.stacks import get_culprit, get_stack_info, iter_traceback_frames
 
@@ -109,7 +110,9 @@ def capture(client, exc_info=None, **kwargs):
                 ),
             )
 
-            culprit = get_culprit(frames, client.config.include_paths, client.config.exclude_paths)
+            culprit = kwargs.get("culprit", None) or get_culprit(
+                frames, client.config.include_paths, client.config.exclude_paths
+            )
 
             if hasattr(exc_type, "__module__"):
                 exc_module = exc_type.__module__
@@ -129,7 +132,7 @@ def capture(client, exc_info=None, **kwargs):
         else:
             message = "%s: %s" % (exc_type, to_unicode(exc_value)) if exc_value else str(exc_type)
 
-        return {
+        data = {
             "id": "%032x" % random.getrandbits(128),
             "culprit": keyword_field(culprit),
             "exception": {
@@ -139,6 +142,30 @@ def capture(client, exc_info=None, **kwargs):
                 "stacktrace": frames,
             },
         }
+        if compat.PY3:
+            depth = kwargs.get("_exc_chain_depth", 0)
+            if depth > EXCEPTION_CHAIN_MAX_DEPTH:
+                return
+            cause = exc_value.__cause__
+            chained_context = exc_value.__context__
+
+            # we follow the pattern of Python itself here and only capture the chained exception
+            # if cause is not None and __suppress_context__ is False
+            if chained_context and not (exc_value.__suppress_context__ and cause is None):
+                if cause:
+                    chained_exc_type = type(cause)
+                    chained_exc_value = cause
+                else:
+                    chained_exc_type = type(chained_context)
+                    chained_exc_value = chained_context
+                chained_exc_info = chained_exc_type, chained_exc_value, chained_context.__traceback__
+
+                chained_cause = Exception.capture(
+                    client, exc_info=chained_exc_info, culprit="None", _exc_chain_depth=depth + 1
+                )
+                if chained_cause:
+                    data["exception"]["cause"] = [chained_cause["exception"]]
+        return data
 
 
 class Message(BaseEvent):


Test patch:
diff --git a/tests/client/client_tests.py b/tests/client/client_tests.py
--- a/tests/client/client_tests.py
+++ b/tests/client/client_tests.py
@@ -345,127 +345,6 @@ def test_client_shutdown_async(sending_elasticapm_client):
     assert len(sending_elasticapm_client.httpserver.requests) == 1
 
 
-def test_explicit_message_on_exception_event(elasticapm_client):
-    try:
-        raise ValueError("foo")
-    except ValueError:
-        elasticapm_client.capture("Exception", message="foobar")
-
-    assert len(elasticapm_client.events) == 1
-    event = elasticapm_client.events[ERROR][0]
-    assert event["exception"]["message"] == "foobar"
-
-
-@pytest.mark.parametrize(
-    "elasticapm_client",
-    [{"include_paths": ("tests",), "local_var_max_length": 20, "local_var_list_max_length": 10}],
-    indirect=True,
-)
-def test_exception_event(elasticapm_client):
-    try:
-        a_local_var = 1
-        a_long_local_var = 100 * "a"
-        a_long_local_list = list(range(100))
-        raise ValueError("foo")
-    except ValueError:
-        elasticapm_client.capture("Exception")
-
-    assert len(elasticapm_client.events) == 1
-    event = elasticapm_client.events[ERROR][0]
-    assert "exception" in event
-    exc = event["exception"]
-    assert exc["message"] == "ValueError: foo"
-    assert exc["type"] == "ValueError"
-    assert exc["module"] == ValueError.__module__  # this differs in some Python versions
-    assert "stacktrace" in exc
-    frames = exc["stacktrace"]
-    assert len(frames) == 1
-    frame = frames[0]
-    assert frame["abs_path"], __file__.replace(".pyc" == ".py")
-    assert frame["filename"] == os.path.join("tests", "client", "client_tests.py")
-    assert frame["module"] == __name__
-    assert frame["function"] == "test_exception_event"
-    assert not frame["library_frame"]
-    assert frame["vars"]["a_local_var"] == 1
-    assert len(frame["vars"]["a_long_local_var"]) == 20
-    assert len(frame["vars"]["a_long_local_list"]) == 12
-    assert frame["vars"]["a_long_local_list"][-1] == "(90 more elements)"
-    assert "timestamp" in event
-    assert "log" not in event
-    # check that only frames from `tests` module are not marked as library frames
-    assert all(
-        frame["library_frame"] or frame["module"].startswith("tests") for frame in event["exception"]["stacktrace"]
-    )
-
-
-def test_sending_exception(sending_elasticapm_client):
-    try:
-        1 / 0
-    except Exception:
-        sending_elasticapm_client.capture_exception()
-    sending_elasticapm_client.close()
-    assert (
-        sending_elasticapm_client.httpserver.responses[0]["code"] == 202
-    ), sending_elasticapm_client.httpserver.responses[0]
-
-
-@pytest.mark.parametrize(
-    "elasticapm_client",
-    [{"include_paths": ("*/tests/*",), "local_var_max_length": 20, "local_var_list_max_length": 10}],
-    indirect=True,
-)
-def test_message_event(elasticapm_client):
-    a_local_var = 1
-    a_long_local_var = 100 * "a"
-    a_long_local_list = list(range(100))
-    elasticapm_client.capture("Message", message="test")
-
-    assert len(elasticapm_client.events) == 1
-    event = elasticapm_client.events[ERROR][0]
-    assert event["log"]["message"] == "test"
-    assert "stacktrace" not in event
-    assert "timestamp" in event
-    assert "stacktrace" in event["log"]
-    # check that only frames from `tests` module are not marked as library frames
-    for frame in event["log"]["stacktrace"]:
-        assert frame["library_frame"] or frame["module"].startswith(("tests", "__main__")), (
-            frame["module"],
-            frame["abs_path"],
-        )
-    frame = event["log"]["stacktrace"][0]
-    assert frame["vars"]["a_local_var"] == 1
-    assert len(frame["vars"]["a_long_local_var"]) == 20
-    assert len(frame["vars"]["a_long_local_list"]) == 12
-    assert frame["vars"]["a_long_local_list"][-1] == "(90 more elements)"
-
-
-def test_param_message_event(elasticapm_client):
-    elasticapm_client.capture("Message", param_message={"message": "test %s %d", "params": ("x", 1)})
-
-    assert len(elasticapm_client.events) == 1
-    event = elasticapm_client.events[ERROR][0]
-    assert event["log"]["message"] == "test x 1"
-    assert event["log"]["param_message"] == "test %s %d"
-
-
-def test_message_with_percent(elasticapm_client):
-    elasticapm_client.capture("Message", message="This works 100% of the time")
-
-    assert len(elasticapm_client.events) == 1
-    event = elasticapm_client.events[ERROR][0]
-    assert event["log"]["message"] == "This works 100% of the time"
-    assert event["log"]["param_message"] == "This works 100% of the time"
-
-
-def test_logger(elasticapm_client):
-    elasticapm_client.capture("Message", message="test", logger_name="test")
-
-    assert len(elasticapm_client.events) == 1
-    event = elasticapm_client.events[ERROR][0]
-    assert event["log"]["logger_name"] == "test"
-    assert "timestamp" in event
-
-
 def test_call_end_twice(elasticapm_client):
     elasticapm_client.begin_transaction("celery")
 
@@ -535,74 +414,6 @@ def test_empty_transport_disables_send():
     client.close()
 
 
-@pytest.mark.parametrize(
-    "elasticapm_client",
-    [
-        {"collect_local_variables": "errors"},
-        {"collect_local_variables": "transactions"},
-        {"collect_local_variables": "all"},
-        {"collect_local_variables": "something"},
-    ],
-    indirect=True,
-)
-def test_collect_local_variables_errors(elasticapm_client):
-    mode = elasticapm_client.config.collect_local_variables
-    try:
-        1 / 0
-    except ZeroDivisionError:
-        elasticapm_client.capture_exception()
-    event = elasticapm_client.events[ERROR][0]
-    if mode in ("errors", "all"):
-        assert "vars" in event["exception"]["stacktrace"][0], mode
-    else:
-        assert "vars" not in event["exception"]["stacktrace"][0], mode
-
-
-@pytest.mark.parametrize(
-    "elasticapm_client",
-    [
-        {"source_lines_error_library_frames": 0, "source_lines_error_app_frames": 0},
-        {"source_lines_error_library_frames": 1, "source_lines_error_app_frames": 1},
-        {"source_lines_error_library_frames": 7, "source_lines_error_app_frames": 3},
-    ],
-    indirect=True,
-)
-def test_collect_source_errors(elasticapm_client):
-    library_frame_context = elasticapm_client.config.source_lines_error_library_frames
-    in_app_frame_context = elasticapm_client.config.source_lines_error_app_frames
-    try:
-        import json, datetime
-
-        json.dumps(datetime.datetime.now())
-    except TypeError:
-        elasticapm_client.capture_exception()
-    event = elasticapm_client.events[ERROR][0]
-    in_app_frame = event["exception"]["stacktrace"][0]
-    library_frame = event["exception"]["stacktrace"][1]
-    assert not in_app_frame["library_frame"]
-    assert library_frame["library_frame"]
-    if library_frame_context:
-        assert "context_line" in library_frame, library_frame_context
-        assert "pre_context" in library_frame, library_frame_context
-        assert "post_context" in library_frame, library_frame_context
-        lines = len([library_frame["context_line"]] + library_frame["pre_context"] + library_frame["post_context"])
-        assert lines == library_frame_context, library_frame_context
-    else:
-        assert "context_line" not in library_frame, library_frame_context
-        assert "pre_context" not in library_frame, library_frame_context
-        assert "post_context" not in library_frame, library_frame_context
-    if in_app_frame_context:
-        assert "context_line" in in_app_frame, in_app_frame_context
-        assert "pre_context" in in_app_frame, in_app_frame_context
-        assert "post_context" in in_app_frame, in_app_frame_context
-        lines = len([in_app_frame["context_line"]] + in_app_frame["pre_context"] + in_app_frame["post_context"])
-        assert lines == in_app_frame_context, (in_app_frame_context, in_app_frame["lineno"])
-    else:
-        assert "context_line" not in in_app_frame, in_app_frame_context
-        assert "pre_context" not in in_app_frame, in_app_frame_context
-        assert "post_context" not in in_app_frame, in_app_frame_context
-
-
 @pytest.mark.parametrize(
     "elasticapm_client",
     [
@@ -676,21 +487,6 @@ def test_collect_source_transactions(elasticapm_client):
         assert "post_context" not in in_app_frame, in_app_frame_context
 
 
-def test_transaction_data_is_attached_to_errors(elasticapm_client):
-    elasticapm_client.capture_message("noid")
-    elasticapm_client.begin_transaction("test")
-    elasticapm_client.capture_message("id")
-    transaction = elasticapm_client.end_transaction("test", "test")
-    elasticapm_client.capture_message("noid")
-
-    errors = elasticapm_client.events[ERROR]
-    assert "transaction_id" not in errors[0]
-    assert errors[1]["transaction_id"] == transaction.id
-    assert errors[1]["transaction"]["sampled"]
-    assert errors[1]["transaction"]["type"] == "test"
-    assert "transaction_id" not in errors[2]
-
-
 @pytest.mark.parametrize("elasticapm_client", [{"transaction_sample_rate": 0.4}], indirect=True)
 def test_transaction_sampling(elasticapm_client, not_so_random):
     for i in range(10):
@@ -823,21 +619,6 @@ def test_transaction_max_span_nested(elasticapm_client):
     assert transaction["span_count"] == {"dropped": 6, "started": 3}
 
 
-def test_transaction_context_is_used_in_errors(elasticapm_client):
-    elasticapm_client.begin_transaction("test")
-    elasticapm.tag(foo="baz")
-    elasticapm.set_custom_context({"a": "b"})
-    elasticapm.set_user_context(username="foo", email="foo@example.com", user_id=42)
-    elasticapm_client.capture_message("x", custom={"foo": "bar"})
-    transaction = elasticapm_client.end_transaction("test", "OK")
-    message = elasticapm_client.events[ERROR][0]
-    assert message["context"]["custom"] == {"a": "b", "foo": "bar"}
-    assert message["context"]["user"] == {"username": "foo", "email": "foo@example.com", "id": 42}
-    assert message["context"]["tags"] == {"foo": "baz"}
-    assert "a" in transaction.context["custom"]
-    assert "foo" not in transaction.context["custom"]
-
-
 def test_transaction_keyword_truncation(elasticapm_client):
     too_long = "x" * (KEYWORD_MAX_LENGTH + 1)
     expected = encoding.keyword_field(too_long)
@@ -869,42 +650,6 @@ def test_transaction_keyword_truncation(elasticapm_client):
     assert span["name"] == expected
 
 
-def test_error_keyword_truncation(sending_elasticapm_client):
-    too_long = "x" * (KEYWORD_MAX_LENGTH + 1)
-    expected = encoding.keyword_field(too_long)
-
-    # let's create a way too long Exception type with a way too long module name
-    WayTooLongException = type(too_long.upper(), (Exception,), {})
-    WayTooLongException.__module__ = too_long
-    try:
-        raise WayTooLongException()
-    except WayTooLongException:
-        with mock.patch("elasticapm.events.get_culprit") as mock_get_culprit:
-            mock_get_culprit.return_value = too_long
-            sending_elasticapm_client.capture_exception(handled=False)
-    sending_elasticapm_client.close()
-    error = sending_elasticapm_client.httpserver.payloads[0][1]["error"]
-
-    assert error["exception"]["type"] == expected.upper()
-    assert error["exception"]["module"] == expected
-    assert error["culprit"] == expected
-
-
-def test_message_keyword_truncation(sending_elasticapm_client):
-    too_long = "x" * (KEYWORD_MAX_LENGTH + 1)
-    expected = encoding.keyword_field(too_long)
-    sending_elasticapm_client.capture_message(
-        param_message={"message": too_long, "params": []}, logger_name=too_long, handled=False
-    )
-    sending_elasticapm_client.close()
-    error = sending_elasticapm_client.httpserver.payloads[0][1]["error"]
-
-    assert error["log"]["param_message"] == expected
-    assert error["log"]["message"] == too_long  # message is not truncated
-
-    assert error["log"]["logger_name"] == expected
-
-
 @pytest.mark.parametrize("sending_elasticapm_client", [{"service_name": "*"}], indirect=True)
 @mock.patch("elasticapm.transport.base.Transport.send")
 def test_config_error_stops_error_send(mock_send, sending_elasticapm_client):
diff --git a/tests/client/exception_tests.py b/tests/client/exception_tests.py
new file mode 100644
--- /dev/null
+++ b/tests/client/exception_tests.py
@@ -0,0 +1,293 @@
+#  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
+
+import mock
+import pytest
+
+import elasticapm
+from elasticapm.conf.constants import ERROR, KEYWORD_MAX_LENGTH
+from elasticapm.utils import compat, encoding
+
+
+def test_explicit_message_on_exception_event(elasticapm_client):
+    try:
+        raise ValueError("foo")
+    except ValueError:
+        elasticapm_client.capture("Exception", message="foobar")
+
+    assert len(elasticapm_client.events) == 1
+    event = elasticapm_client.events[ERROR][0]
+    assert event["exception"]["message"] == "foobar"
+
+
+@pytest.mark.parametrize(
+    "elasticapm_client",
+    [{"include_paths": ("tests",), "local_var_max_length": 20, "local_var_list_max_length": 10}],
+    indirect=True,
+)
+def test_exception_event(elasticapm_client):
+    try:
+        a_local_var = 1
+        a_long_local_var = 100 * "a"
+        a_long_local_list = list(range(100))
+        raise ValueError("foo")
+    except ValueError:
+        elasticapm_client.capture("Exception")
+
+    assert len(elasticapm_client.events) == 1
+    event = elasticapm_client.events[ERROR][0]
+    assert "exception" in event
+    exc = event["exception"]
+    assert exc["message"] == "ValueError: foo"
+    assert exc["type"] == "ValueError"
+    assert exc["module"] == ValueError.__module__  # this differs in some Python versions
+    assert "stacktrace" in exc
+    frames = exc["stacktrace"]
+    assert len(frames) == 1
+    frame = frames[0]
+    assert frame["abs_path"], __file__.replace(".pyc" == ".py")
+    assert frame["filename"] == os.path.join("tests", "client", "exception_tests.py")
+    assert frame["module"] == __name__
+    assert frame["function"] == "test_exception_event"
+    assert not frame["library_frame"]
+    assert frame["vars"]["a_local_var"] == 1
+    assert len(frame["vars"]["a_long_local_var"]) == 20
+    assert len(frame["vars"]["a_long_local_list"]) == 12
+    assert frame["vars"]["a_long_local_list"][-1] == "(90 more elements)"
+    assert "timestamp" in event
+    assert "log" not in event
+    # check that only frames from `tests` module are not marked as library frames
+    assert all(
+        frame["library_frame"] or frame["module"].startswith("tests") for frame in event["exception"]["stacktrace"]
+    )
+
+
+def test_sending_exception(sending_elasticapm_client):
+    try:
+        1 / 0
+    except Exception:
+        sending_elasticapm_client.capture_exception()
+    sending_elasticapm_client.close()
+    assert (
+        sending_elasticapm_client.httpserver.responses[0]["code"] == 202
+    ), sending_elasticapm_client.httpserver.responses[0]
+
+
+@pytest.mark.parametrize(
+    "elasticapm_client",
+    [{"include_paths": ("*/tests/*",), "local_var_max_length": 20, "local_var_list_max_length": 10}],
+    indirect=True,
+)
+def test_message_event(elasticapm_client):
+    a_local_var = 1
+    a_long_local_var = 100 * "a"
+    a_long_local_list = list(range(100))
+    elasticapm_client.capture("Message", message="test")
+
+    assert len(elasticapm_client.events) == 1
+    event = elasticapm_client.events[ERROR][0]
+    assert event["log"]["message"] == "test"
+    assert "stacktrace" not in event
+    assert "timestamp" in event
+    assert "stacktrace" in event["log"]
+    # check that only frames from `tests` module are not marked as library frames
+    for frame in event["log"]["stacktrace"]:
+        assert frame["library_frame"] or frame["module"].startswith(("tests", "__main__")), (
+            frame["module"],
+            frame["abs_path"],
+        )
+    frame = event["log"]["stacktrace"][0]
+    assert frame["vars"]["a_local_var"] == 1
+    assert len(frame["vars"]["a_long_local_var"]) == 20
+    assert len(frame["vars"]["a_long_local_list"]) == 12
+    assert frame["vars"]["a_long_local_list"][-1] == "(90 more elements)"
+
+
+def test_param_message_event(elasticapm_client):
+    elasticapm_client.capture("Message", param_message={"message": "test %s %d", "params": ("x", 1)})
+
+    assert len(elasticapm_client.events) == 1
+    event = elasticapm_client.events[ERROR][0]
+    assert event["log"]["message"] == "test x 1"
+    assert event["log"]["param_message"] == "test %s %d"
+
+
+def test_message_with_percent(elasticapm_client):
+    elasticapm_client.capture("Message", message="This works 100% of the time")
+
+    assert len(elasticapm_client.events) == 1
+    event = elasticapm_client.events[ERROR][0]
+    assert event["log"]["message"] == "This works 100% of the time"
+    assert event["log"]["param_message"] == "This works 100% of the time"
+
+
+def test_logger(elasticapm_client):
+    elasticapm_client.capture("Message", message="test", logger_name="test")
+
+    assert len(elasticapm_client.events) == 1
+    event = elasticapm_client.events[ERROR][0]
+    assert event["log"]["logger_name"] == "test"
+    assert "timestamp" in event
+
+
+@pytest.mark.parametrize(
+    "elasticapm_client",
+    [
+        {"collect_local_variables": "errors"},
+        {"collect_local_variables": "transactions"},
+        {"collect_local_variables": "all"},
+        {"collect_local_variables": "something"},
+    ],
+    indirect=True,
+)
+def test_collect_local_variables_errors(elasticapm_client):
+    mode = elasticapm_client.config.collect_local_variables
+    try:
+        1 / 0
+    except ZeroDivisionError:
+        elasticapm_client.capture_exception()
+    event = elasticapm_client.events[ERROR][0]
+    if mode in ("errors", "all"):
+        assert "vars" in event["exception"]["stacktrace"][0], mode
+    else:
+        assert "vars" not in event["exception"]["stacktrace"][0], mode
+
+
+@pytest.mark.parametrize(
+    "elasticapm_client",
+    [
+        {"source_lines_error_library_frames": 0, "source_lines_error_app_frames": 0},
+        {"source_lines_error_library_frames": 1, "source_lines_error_app_frames": 1},
+        {"source_lines_error_library_frames": 7, "source_lines_error_app_frames": 3},
+    ],
+    indirect=True,
+)
+def test_collect_source_errors(elasticapm_client):
+    library_frame_context = elasticapm_client.config.source_lines_error_library_frames
+    in_app_frame_context = elasticapm_client.config.source_lines_error_app_frames
+    try:
+        import json, datetime
+
+        json.dumps(datetime.datetime.now())
+    except TypeError:
+        elasticapm_client.capture_exception()
+    event = elasticapm_client.events[ERROR][0]
+    in_app_frame = event["exception"]["stacktrace"][0]
+    library_frame = event["exception"]["stacktrace"][1]
+    assert not in_app_frame["library_frame"]
+    assert library_frame["library_frame"]
+    if library_frame_context:
+        assert "context_line" in library_frame, library_frame_context
+        assert "pre_context" in library_frame, library_frame_context
+        assert "post_context" in library_frame, library_frame_context
+        lines = len([library_frame["context_line"]] + library_frame["pre_context"] + library_frame["post_context"])
+        assert lines == library_frame_context, library_frame_context
+    else:
+        assert "context_line" not in library_frame, library_frame_context
+        assert "pre_context" not in library_frame, library_frame_context
+        assert "post_context" not in library_frame, library_frame_context
+    if in_app_frame_context:
+        assert "context_line" in in_app_frame, in_app_frame_context
+        assert "pre_context" in in_app_frame, in_app_frame_context
+        assert "post_context" in in_app_frame, in_app_frame_context
+        lines = len([in_app_frame["context_line"]] + in_app_frame["pre_context"] + in_app_frame["post_context"])
+        assert lines == in_app_frame_context, (in_app_frame_context, in_app_frame["lineno"])
+    else:
+        assert "context_line" not in in_app_frame, in_app_frame_context
+        assert "pre_context" not in in_app_frame, in_app_frame_context
+        assert "post_context" not in in_app_frame, in_app_frame_context
+
+
+def test_transaction_data_is_attached_to_errors(elasticapm_client):
+    elasticapm_client.capture_message("noid")
+    elasticapm_client.begin_transaction("test")
+    elasticapm_client.capture_message("id")
+    transaction = elasticapm_client.end_transaction("test", "test")
+    elasticapm_client.capture_message("noid")
+
+    errors = elasticapm_client.events[ERROR]
+    assert "transaction_id" not in errors[0]
+    assert errors[1]["transaction_id"] == transaction.id
+    assert errors[1]["transaction"]["sampled"]
+    assert errors[1]["transaction"]["type"] == "test"
+    assert "transaction_id" not in errors[2]
+
+
+def test_transaction_context_is_used_in_errors(elasticapm_client):
+    elasticapm_client.begin_transaction("test")
+    elasticapm.tag(foo="baz")
+    elasticapm.set_custom_context({"a": "b"})
+    elasticapm.set_user_context(username="foo", email="foo@example.com", user_id=42)
+    elasticapm_client.capture_message("x", custom={"foo": "bar"})
+    transaction = elasticapm_client.end_transaction("test", "OK")
+    message = elasticapm_client.events[ERROR][0]
+    assert message["context"]["custom"] == {"a": "b", "foo": "bar"}
+    assert message["context"]["user"] == {"username": "foo", "email": "foo@example.com", "id": 42}
+    assert message["context"]["tags"] == {"foo": "baz"}
+    assert "a" in transaction.context["custom"]
+    assert "foo" not in transaction.context["custom"]
+
+
+def test_error_keyword_truncation(sending_elasticapm_client):
+    too_long = "x" * (KEYWORD_MAX_LENGTH + 1)
+    expected = encoding.keyword_field(too_long)
+
+    # let's create a way too long Exception type with a way too long module name
+    WayTooLongException = type(too_long.upper(), (Exception,), {})
+    WayTooLongException.__module__ = too_long
+    try:
+        raise WayTooLongException()
+    except WayTooLongException:
+        with mock.patch("elasticapm.events.get_culprit") as mock_get_culprit:
+            mock_get_culprit.return_value = too_long
+            sending_elasticapm_client.capture_exception(handled=False)
+    sending_elasticapm_client.close()
+    error = sending_elasticapm_client.httpserver.payloads[0][1]["error"]
+
+    assert error["exception"]["type"] == expected.upper()
+    assert error["exception"]["module"] == expected
+    assert error["culprit"] == expected
+
+
+def test_message_keyword_truncation(sending_elasticapm_client):
+    too_long = "x" * (KEYWORD_MAX_LENGTH + 1)
+    expected = encoding.keyword_field(too_long)
+    sending_elasticapm_client.capture_message(
+        param_message={"message": too_long, "params": []}, logger_name=too_long, handled=False
+    )
+    sending_elasticapm_client.close()
+    error = sending_elasticapm_client.httpserver.payloads[0][1]["error"]
+
+    assert error["log"]["param_message"] == expected
+    assert error["log"]["message"] == too_long  # message is not truncated
+
+    assert error["log"]["logger_name"] == expected
diff --git a/tests/client/py3_exception_tests.py b/tests/client/py3_exception_tests.py
new file mode 100644
--- /dev/null
+++ b/tests/client/py3_exception_tests.py
@@ -0,0 +1,100 @@
+#  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.
+
+#  BSD 3-Clause License
+#
+#
+#  Redistribution and use in source and binary forms, with or without
+#  modification, are permitted provided that the following conditions are met:
+#
+#
+#
+#
+
+import mock
+
+from elasticapm.conf.constants import ERROR
+
+
+def test_chained_cause_exception(elasticapm_client):
+    try:
+        try:
+            1 / 0
+        except ZeroDivisionError as zde:
+            raise ValueError("bla") from zde
+    except ValueError:
+        elasticapm_client.capture_exception()
+    error = elasticapm_client.events[ERROR][0]
+    assert error["exception"]["type"] == "ValueError"
+    assert error["exception"]["cause"][0]["type"] == "ZeroDivisionError"
+
+
+def test_chained_context_exception(elasticapm_client):
+    try:
+        try:
+            1 / 0
+        except ZeroDivisionError:
+            int("zero")
+    except ValueError:
+        elasticapm_client.capture_exception()
+    error = elasticapm_client.events[ERROR][0]
+    assert error["exception"]["type"] == "ValueError"
+    assert error["exception"]["cause"][0]["type"] == "ZeroDivisionError"
+
+
+def test_chained_context_exception_suppressed(elasticapm_client):
+    try:
+        try:
+            1 / 0
+        except ZeroDivisionError:
+            raise ValueError("bla") from None
+    except ValueError:
+        elasticapm_client.capture_exception()
+    error = elasticapm_client.events[ERROR][0]
+    assert error["exception"]["type"] == "ValueError"
+    assert "cause" not in error["exception"]
+
+
+def test_chained_context_exception_max(elasticapm_client):
+    with mock.patch("elasticapm.events.EXCEPTION_CHAIN_MAX_DEPTH", 1):
+        try:
+            try:
+                1 / 0
+            except ZeroDivisionError:
+                try:
+                    1 + "a"
+                except TypeError as e:
+                    raise ValueError("bla") from e
+        except ValueError:
+            elasticapm_client.capture_exception()
+        error = elasticapm_client.events[ERROR][0]
+        assert error["exception"]["type"] == "ValueError"
+        assert error["exception"]["cause"][0]["type"] == "TypeError"
+        assert "cause" not in error["exception"]["cause"][0]
diff --git a/tests/fixtures.py b/tests/fixtures.py
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -46,6 +46,7 @@
 import elasticapm
 from elasticapm.base import Client
 from elasticapm.conf.constants import SPAN
+from elasticapm.traces import execution_context
 from elasticapm.transport.http_base import HTTPTransportBase
 from elasticapm.utils import compat
 
@@ -155,6 +156,9 @@ def elasticapm_client(request):
     client = TempStoreClient(**client_config)
     yield client
     client.close()
+    # clear any execution context that might linger around
+    execution_context.set_transaction(None)
+    execution_context.set_span(None)
 
 
 @pytest.fixture()
@@ -213,6 +217,9 @@ def sending_elasticapm_client(request, validating_httpserver):
     client.httpserver = validating_httpserver
     yield client
     client.close()
+    # clear any execution context that might linger around
+    execution_context.set_transaction(None)
+    execution_context.set_span(None)
 
 
 class DummyTransport(HTTPTransportBase):