Repository: elastic/apm-agent-python

Issue:
Sanic framework - Body being logged with ELASTIC_APM_CAPTURE_BODY=off
**Describe the bug**:
APM agent 6.8.0 is not removing the body from the transaction log and even after forcing the environment variable ELASTIC_APM_CAPTURE_BODY to "off" the body is still sent with the transaction log as you can see below at http.request.body.original:

<details>
<summary>Click to see the body at the transaction log</summary>

```
{
        "_index" : ".ds-traces-apm-default-2022.03.08-000001",
        "_id" : "D5IodH8BFh9pN-odBTn8",
        "_score" : null,
        "_source" : {
          "container" : {
            "id" : "REMOVED BY ME"
          },
          "kubernetes" : {
            "pod" : {
              "uid" : "REMOVED BY ME",
              "name" : "my-api-123"
            }
          },
          "agent" : {
            "name" : "python",
            "version" : "6.8.0"
          },
          "source" : {
            "ip" : "REMOVED BY ME"
          },
          "cloud" : {
            "availability_zone" : "us-east-1b",
            "instance" : {
              "id" : "<REMOVED BY ME>"
            },
            "provider" : "aws",
            "machine" : {
              "type" : "c5.large"
            },
            "region" : "us-east-1",
            "account" : {
              "id" : "<REMOVED BY ME>"
            }
          },
          "observer" : {
            "hostname" : "<REMOVED BY ME>",
            "id" : "<REMOVED BY ME>",
            "ephemeral_id" : "<REMOVED BY ME>",
            "type" : "apm-server",
            "version" : "8.1.0",
            "version_major" : 8
          },
          "trace" : {
            "id" : "a2d5bf6667f752ecf1c3009751140f2b"
          },
          "ecs" : {
            "version" : "1.12.0"
          },
          "host" : {
            "os" : {
              "platform" : "linux"
            },
            "ip" : [
              "<REMOVED BY ME>"
            ],
            "architecture" : "x86_64"
          },
          "client" : {
            "ip" : "<REMOVED BY ME>"
          },
          "event" : {
            "agent_id_status" : "missing",
            "ingested" : "2022-03-10T14:07:34Z",
            "outcome" : "success"
          },
          "timestamp" : {
            "us" : 1646921253507682
          },
          "process" : {
            "args" : [
              "/app/my_api/run.py"
            ],
            "pid" : 56,
            "ppid" : 6
          },
          "data_stream.namespace" : "default",
          "processor" : {
            "name" : "transaction",
            "event" : "transaction"
          },
          "data_stream.type" : "traces",
          "url" : {
            "path" : "/path",
            "scheme" : "http",
            "domain" : "my-api.my-domain.com",
            "full" : "http://my-api.my-domain.com/path"
          },
          "@timestamp" : "2022-03-10T14:07:33.507Z",
          "service" : {
            "node" : {
              "name" : "REMOVED BY ME"
            },
            "environment" : "production",
            "framework" : {
              "name" : "sanic",
              "version" : "19.03.1"
            },
            "name" : "my_api",
            "runtime" : {
              "name" : "CPython",
              "version" : "3.7.12"
            },
            "language" : {
              "name" : "python",
              "version" : "3.7.12"
            }
          },
          "data_stream.dataset" : "apm",
          "http" : {
            "request" : {
              "method" : "POST",
              "body" : {
                "original" : """<BODY BEING LOGGED HERE - REMOVED CONFIDENTIAL INFORMATION>"""
              },
              "env" : {
                "version" : "1.1"
              }
            },
            "response" : {
              "status_code" : 200,
              "finished" : true,
              "headers_sent" : true
            },
            "version" : "1.1"
          },
          "transaction" : {
            "duration" : {
              "us" : 4399
            },
            "result" : "HTTP 2xx",
            "name" : "POST /path",
            "id" : "74752893696998ff",
            "span_count" : {
              "dropped" : 1,
              "started" : 1
            },
            "type" : "request",
            "sampled" : true
          }
        },
        "sort" : [
          1646921253507
        ]
      }
```

</details>

**To Reproduce**

1. Using elastic-apm agent 6.8.0, python 3.7 and sanic framework version 19.3.1
2. The body will be sent with the transaction log


**Environment**
- OS: linux
- Python version: 3.7
- Framework and version: sanic 19.3.1
- APM Server version: 8.1.0
- Agent version: 6.8.0



Discussion:
After some local debbuging I find out that the variable `config.capture_body` [here](https://github.com/elastic/apm-agent-python/blob/a18d7a1c9dcc53e9a7a5446bec2d2ee9e33bed83/elasticapm/contrib/sanic/utils.py#L90) is a string with value "off" which evaluates to True in the if statement. 

Confuguring the ELASTIC_APM_CAPTURE_BODY with an empty string solved the problem for me :)
Glad you have an interim solution. It looks like that particular instance is assuming that `config.capture_body` is a boolean, but it's actually a string representing the type(s) of events on which to capture the body. I'll have a fix shortly.

Resolving patch:
diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py
--- a/elasticapm/contrib/sanic/__init__.py
+++ b/elasticapm/contrib/sanic/__init__.py
@@ -259,7 +259,9 @@ async def _instrument_request(request: Request):
                 trace_parent = TraceParent.from_headers(headers=request.headers)
                 self._client.begin_transaction("request", trace_parent=trace_parent)
                 await set_context(
-                    lambda: get_request_info(config=self._client.config, request=request),
+                    lambda: get_request_info(
+                        config=self._client.config, request=request, event_type=constants.TRANSACTION
+                    ),
                     "request",
                 )
                 self._setup_transaction_name(request=request)
@@ -278,8 +280,7 @@ async def _instrument_request(request: Request):
         async def _instrument_response(request: Request, response: HTTPResponse):
             await set_context(
                 lambda: get_response_info(
-                    config=self._client.config,
-                    response=response,
+                    config=self._client.config, response=response, event_type=constants.TRANSACTION
                 ),
                 "response",
             )
@@ -324,7 +325,9 @@ async def _handler(request: Request, exception: BaseException):
             self._client.capture_exception(
                 exc_info=sys.exc_info(),
                 context={
-                    "request": await get_request_info(config=self._client.config, request=request),
+                    "request": await get_request_info(
+                        config=self._client.config, request=request, event_type=constants.ERROR
+                    ),
                 },
                 handled=True,
             )
diff --git a/elasticapm/contrib/sanic/utils.py b/elasticapm/contrib/sanic/utils.py
--- a/elasticapm/contrib/sanic/utils.py
+++ b/elasticapm/contrib/sanic/utils.py
@@ -63,12 +63,13 @@ def get_env(request: Request) -> EnvInfoType:
 
 
 # noinspection PyBroadException
-async def get_request_info(config: Config, request: Request) -> Dict[str, str]:
+async def get_request_info(config: Config, request: Request, event_type: str) -> Dict[str, str]:
     """
     Generate a traceable context information from the inbound HTTP request
 
     :param config: Application Configuration used to tune the way the data is captured
     :param request: Inbound HTTP request
+    :param event_type: the event type (such as constants.TRANSACTION) for determing whether to capture the body
     :return: A dictionary containing the context information of the ongoing transaction
     """
     env = dict(get_env(request=request))
@@ -87,7 +88,7 @@ async def get_request_info(config: Config, request: Request) -> Dict[str, str]:
     if config.capture_headers:
         result["headers"] = dict(request.headers)
 
-    if request.method in constants.HTTP_WITH_BODY and config.capture_body:
+    if request.method in constants.HTTP_WITH_BODY and config.capture_body in ("all", event_type):
         if request.content_type.startswith("multipart") or "octet-stream" in request.content_type:
             result["body"] = "[DISCARDED]"
         try:
@@ -101,12 +102,13 @@ async def get_request_info(config: Config, request: Request) -> Dict[str, str]:
     return result
 
 
-async def get_response_info(config: Config, response: HTTPResponse) -> Dict[str, str]:
+async def get_response_info(config: Config, response: HTTPResponse, event_type: str) -> Dict[str, str]:
     """
     Generate a traceable context information from the inbound HTTP Response
 
     :param config: Application Configuration used to tune the way the data is captured
     :param response: outbound HTTP Response
+    :param event_type: the event type (such as constants.TRANSACTION) for determing whether to capture the body
     :return: A dictionary containing the context information of the ongoing transaction
     """
     result = {
@@ -120,7 +122,7 @@ async def get_response_info(config: Config, response: HTTPResponse) -> Dict[str,
     if config.capture_headers:
         result["headers"] = dict(response.headers)
 
-    if config.capture_body and "octet-stream" not in response.content_type:
+    if config.capture_body in ("all", event_type) and "octet-stream" not in response.content_type:
         result["body"] = response.body.decode("utf-8")
     else:
         result["body"] = "[REDACTED]"


Test patch:
diff --git a/tests/contrib/sanic/sanic_tests.py b/tests/contrib/sanic/sanic_tests.py
--- a/tests/contrib/sanic/sanic_tests.py
+++ b/tests/contrib/sanic/sanic_tests.py
@@ -32,6 +32,8 @@
 
 sanic = pytest.importorskip("sanic")  # isort:skip
 
+import json
+
 from elasticapm.conf import constants
 
 pytestmark = [pytest.mark.sanic]  # isort:skip
@@ -99,6 +101,35 @@ def test_capture_exception(sanic_elastic_app, elasticapm_client):
         assert transaction[field] == value
 
 
+@pytest.mark.parametrize(
+    "elasticapm_client", [{"capture_body": "errors"}, {"capture_body": "all"}, {"capture_body": "off"}], indirect=True
+)
+def test_capture_body(sanic_elastic_app, elasticapm_client):
+    sanic_app, apm = next(sanic_elastic_app(elastic_client=elasticapm_client))
+    _, resp = sanic_app.test_client.post(
+        "/v1/apm/unhandled-exception",
+        headers={
+            constants.TRACEPARENT_HEADER_NAME: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03",
+            constants.TRACESTATE_HEADER_NAME: "foo=bar,bar=baz",
+            "REMOTE_ADDR": "127.0.0.1",
+        },
+        data=json.dumps({"foo": "bar"}),
+    )
+    assert len(elasticapm_client.events[constants.ERROR]) == 1
+    transaction = elasticapm_client.events[constants.TRANSACTION][0]
+    for field, value in {
+        "name": "POST /v1/apm/unhandled-exception",
+        "result": "HTTP 5xx",
+        "outcome": "failure",
+        "type": "request",
+    }.items():
+        assert transaction[field] == value
+    if elasticapm_client.config.capture_body in ("all", "errors"):
+        assert transaction["context"]["request"]["body"] == '{"foo": "bar"}'
+    else:
+        assert transaction["context"]["request"]["body"] == "[REDACTED]"
+
+
 def test_unhandled_exception_capture(sanic_elastic_app, elasticapm_client):
     sanic_app, apm = next(sanic_elastic_app(elastic_client=elasticapm_client))
     _, resp = sanic_app.test_client.post(