Repository: elastic/apm-agent-python

Issue:
[GRPC] Handle the null span case
**Describe the bug**: ...

**To Reproduce**

1. Setup ElasticAPM in Django
2. Integrate with Google PubSub client
3. Try to publish message from your script
4. Stacktrace:
```
Exception in thread Thread-CommitBatchPublisher:
Traceback (most recent call last):
  File "/usr/local/lib/python3.8/threading.py", line 932, in _bootstrap_inner
    self.run()
  File "/usr/local/lib/python3.8/threading.py", line 870, in run
    self._target(*self._args, **self._kwargs)
  File "/usr/local/lib/python3.8/site-packages/google/cloud/pubsub_v1/publisher/_batch/thread.py", line 274, in _commit
    response = self._client._gapic_publish(
  File "/usr/local/lib/python3.8/site-packages/google/cloud/pubsub_v1/publisher/client.py", line 272, in _gapic_publish
    return super().publish(*args, **kwargs)
  File "/usr/local/lib/python3.8/site-packages/google/pubsub_v1/services/publisher/client.py", line 781, in publish
    response = rpc(
  File "/usr/local/lib/python3.8/site-packages/google/api_core/gapic_v1/method.py", line 113, in __call__
    return wrapped_func(*args, **kwargs)
  File "/usr/local/lib/python3.8/site-packages/google/api_core/retry.py", line 349, in retry_wrapped_func
    return retry_target(
  File "/usr/local/lib/python3.8/site-packages/google/api_core/retry.py", line 191, in retry_target
    return target()
  File "/usr/local/lib/python3.8/site-packages/google/api_core/timeout.py", line 120, in func_with_timeout
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.8/site-packages/google/api_core/grpc_helpers.py", line 72, in error_remapped_callable
    return callable_(*args, **kwargs)
  File "/usr/local/lib/python3.8/site-packages/grpc/_interceptor.py", line 247, in __call__
    response, ignored_call = self._with_call(request,
  File "/usr/local/lib/python3.8/site-packages/grpc/_interceptor.py", line 287, in _with_call
    call = self._interceptor.intercept_unary_unary(continuation,
  File "/usr/local/lib/python3.8/site-packages/elasticapm/contrib/grpc/client_interceptor.py", line 97, in intercept_unary_unary
    client_call_details = self.attach_traceparent(client_call_details, span)
  File "/usr/local/lib/python3.8/site-packages/elasticapm/contrib/grpc/client_interceptor.py", line 195, in attach_traceparent
    if not span.transaction:
AttributeError: 'NoneType' object has no attribute 'transaction'
```

**Environment (please complete the following information)**
- OS: Linux
- Python version: 3.8
- Framework and version [e.g. Django 2.1]: Django 3.8
- APM Server version: 7.15
- Agent version: 6.14.0


**Additional context**

Add any other context about the problem here.
- `requirements.txt`:
  <details>
    <summary>Click to expand</summary>

    ```
    google-cloud-pubsub==2.13.11
    ```
  </details>

**Initial digging**
Looks like the span is not being returned from the context manager, but the grpc code is not handling the `None` case. 
* [context manager](https://github.com/elastic/apm-agent-python/blob/main/elasticapm/traces.py#L1078)
* [grpc code](https://github.com/elastic/apm-agent-python/blob/main/elasticapm/contrib/grpc/client_interceptor.py#L195)

I can make a patch to handle the `None` case (and just return the client call details), but not sure if that is the right call here. Any guidance would be appreciated!



Discussion:

Resolving patch:
diff --git a/elasticapm/contrib/grpc/client_interceptor.py b/elasticapm/contrib/grpc/client_interceptor.py
--- a/elasticapm/contrib/grpc/client_interceptor.py
+++ b/elasticapm/contrib/grpc/client_interceptor.py
@@ -94,6 +94,8 @@ def intercept_unary_unary(self, continuation, client_call_details, request):
         with elasticapm.capture_span(
             client_call_details.method, span_type="external", span_subtype="grpc", extra=self._context.copy(), leaf=True
         ) as span:
+            if not span:
+                return continuation(client_call_details, request)
             client_call_details = self.attach_traceparent(client_call_details, span)
             try:
                 response = continuation(client_call_details, request)


Test patch:
diff --git a/tests/contrib/grpc/grpc_client_tests.py b/tests/contrib/grpc/grpc_client_tests.py
--- a/tests/contrib/grpc/grpc_client_tests.py
+++ b/tests/contrib/grpc/grpc_client_tests.py
@@ -178,6 +178,24 @@ def test_grpc_client_server_exception(instrument, sending_elasticapm_client, grp
     assert error["transaction_id"] == server_transactions[0]["id"]
 
 
+def test_grpc_client_unsampled_transaction(instrument, sending_elasticapm_client, grpc_client_and_server_url):
+    grpc_client, grpc_server_url = grpc_client_and_server_url
+    grpc_server_host, grpc_server_port = grpc_server_url.split(":")
+    transaction = sending_elasticapm_client.begin_transaction("request")
+    transaction.is_sampled = False
+    parsed_url = urllib.parse.urlparse(sending_elasticapm_client.httpserver.url)
+    response = grpc_client.GetServerResponse(Message(message="foo"))
+    transaction.is_sampled = True
+    sending_elasticapm_client.end_transaction("grpc-test")
+    payloads = sending_elasticapm_client.httpserver.payloads
+    for i in range(1000):
+        if len(sending_elasticapm_client.httpserver.payloads) > 1:
+            break
+        time.sleep(0.01)
+    transaction_data = payloads[1][1]["transaction"]
+    assert transaction_data["span_count"]["started"] == 0
+
+
 def extract_events_from_payload(service_name, payloads):
     payloads = [
         payload for payload in payloads if payload and payload[0]["metadata"]["service"]["name"] == service_name