Repository: getsentry/sentry-python

Issue:
Add `trace_propagation_targets` option.
### Problem Statement

Add an option `trace_propagation_targets` that defines to what targets the trace headers (`sentry-trace` and `baggage`) are added in outgoing HTTP requests. 

Similar to the same feature in JS: https://docs.sentry.io/platforms/node/guides/serverless-cloud/configuration/options/#trace-propagation-targets

### Solution Brainstorm

Implement like it is implemented in Javascript.

Discussion:
cleptric: Semantics for this option are defined here https://docs.sentry.io/platforms/node/guides/serverless-cloud/configuration/options/#trace-propagation-targets 
antonpirker: Now I changed the behavior to have a `[MATCH_ALL]` default that matches everything so traces are propagated everywhere. If it is set to `[]` or `None` then traces will not be propagated anywhere.

Matching is also done by substring like described in the linked develop docs.
antonpirker: I have now implemented @sl0thentr0py suggestion to fetch the options in the function. Now calling the function looks nicer.

Patch:
diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py
index d5c9b19a..5dad0af5 100644
--- a/sentry_sdk/consts.py
+++ b/sentry_sdk/consts.py
@@ -42,6 +42,8 @@ if MYPY:
 DEFAULT_QUEUE_SIZE = 100
 DEFAULT_MAX_BREADCRUMBS = 100
 
+MATCH_ALL = r".*"
+
 
 class INSTRUMENTER:
     SENTRY = "sentry"
@@ -123,6 +125,9 @@ class ClientConstructor(object):
         before_send_transaction=None,  # type: Optional[TransactionProcessor]
         project_root=None,  # type: Optional[str]
         enable_tracing=None,  # type: Optional[bool]
+        trace_propagation_targets=[  # noqa: B006
+            MATCH_ALL
+        ],  # type: Optional[Sequence[str]]
     ):
         # type: (...) -> None
         pass
diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py
index 963fb647..961ef25b 100644
--- a/sentry_sdk/integrations/httpx.py
+++ b/sentry_sdk/integrations/httpx.py
@@ -1,6 +1,7 @@
 from sentry_sdk import Hub
 from sentry_sdk.consts import OP
 from sentry_sdk.integrations import Integration, DidNotEnable
+from sentry_sdk.tracing_utils import should_propagate_trace
 from sentry_sdk.utils import logger, parse_url
 
 from sentry_sdk._types import MYPY
@@ -52,13 +53,15 @@ def _install_httpx_client():
             span.set_data("http.query", parsed_url.query)
             span.set_data("http.fragment", parsed_url.fragment)
 
-            for key, value in hub.iter_trace_propagation_headers():
-                logger.debug(
-                    "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
-                        key=key, value=value, url=request.url
+            if should_propagate_trace(hub, str(request.url)):
+                for key, value in hub.iter_trace_propagation_headers():
+                    logger.debug(
+                        "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
+                            key=key, value=value, url=request.url
+                        )
                     )
-                )
-                request.headers[key] = value
+                    request.headers[key] = value
+
             rv = real_send(self, request, **kwargs)
 
             span.set_data("status_code", rv.status_code)
@@ -91,13 +94,15 @@ def _install_httpx_async_client():
             span.set_data("http.query", parsed_url.query)
             span.set_data("http.fragment", parsed_url.fragment)
 
-            for key, value in hub.iter_trace_propagation_headers():
-                logger.debug(
-                    "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
-                        key=key, value=value, url=request.url
+            if should_propagate_trace(hub, str(request.url)):
+                for key, value in hub.iter_trace_propagation_headers():
+                    logger.debug(
+                        "[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
+                            key=key, value=value, url=request.url
+                        )
                     )
-                )
-                request.headers[key] = value
+                    request.headers[key] = value
+
             rv = await real_send(self, request, **kwargs)
 
             span.set_data("status_code", rv.status_code)
diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py
index 8da3b95d..280f7ced 100644
--- a/sentry_sdk/integrations/stdlib.py
+++ b/sentry_sdk/integrations/stdlib.py
@@ -7,7 +7,7 @@ from sentry_sdk.consts import OP
 from sentry_sdk.hub import Hub
 from sentry_sdk.integrations import Integration
 from sentry_sdk.scope import add_global_event_processor
-from sentry_sdk.tracing_utils import EnvironHeaders
+from sentry_sdk.tracing_utils import EnvironHeaders, should_propagate_trace
 from sentry_sdk.utils import (
     capture_internal_exceptions,
     logger,
@@ -98,13 +98,14 @@ def _install_httplib():
 
         rv = real_putrequest(self, method, url, *args, **kwargs)
 
-        for key, value in hub.iter_trace_propagation_headers(span):
-            logger.debug(
-                "[Tracing] Adding `{key}` header {value} to outgoing request to {real_url}.".format(
-                    key=key, value=value, real_url=real_url
+        if should_propagate_trace(hub, real_url):
+            for key, value in hub.iter_trace_propagation_headers(span):
+                logger.debug(
+                    "[Tracing] Adding `{key}` header {value} to outgoing request to {real_url}.".format(
+                        key=key, value=value, real_url=real_url
+                    )
                 )
-            )
-            self.putheader(key, value)
+                self.putheader(key, value)
 
         self._sentrysdk_span = span
 
diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py
index 9aec355d..50d684c3 100644
--- a/sentry_sdk/tracing_utils.py
+++ b/sentry_sdk/tracing_utils.py
@@ -27,10 +27,10 @@ else:
 if MYPY:
     import typing
 
-    from typing import Generator
-    from typing import Optional
     from typing import Any
     from typing import Dict
+    from typing import Generator
+    from typing import Optional
     from typing import Union
 
 
@@ -376,6 +376,25 @@ class Baggage(object):
         return ",".join(items)
 
 
+def should_propagate_trace(hub, url):
+    # type: (sentry_sdk.Hub, str) -> bool
+    """
+    Returns True if url matches trace_propagation_targets configured in the given hub. Otherwise, returns False.
+    """
+    client = hub.client  # type: Any
+    trace_propagation_targets = client.options["trace_propagation_targets"]
+
+    if trace_propagation_targets is None:
+        return False
+
+    for target in trace_propagation_targets:
+        matched = re.search(target, url)
+        if matched:
+            return True
+
+    return False
+
+
 # Circular imports
 from sentry_sdk.tracing import LOW_QUALITY_TRANSACTION_SOURCES
 


Test patch:
diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py
index 9945440c..74b15b89 100644
--- a/tests/integrations/httpx/test_httpx.py
+++ b/tests/integrations/httpx/test_httpx.py
@@ -5,6 +5,7 @@ import httpx
 import responses
 
 from sentry_sdk import capture_message, start_transaction
+from sentry_sdk.consts import MATCH_ALL
 from sentry_sdk.integrations.httpx import HttpxIntegration
 
 
@@ -81,3 +82,146 @@ def test_outgoing_trace_headers(sentry_init, httpx_client):
             parent_span_id=request_span.span_id,
             sampled=1,
         )
+
+
+@pytest.mark.parametrize(
+    "httpx_client,trace_propagation_targets,url,trace_propagated",
+    [
+        [
+            httpx.Client(),
+            None,
+            "https://example.com/",
+            False,
+        ],
+        [
+            httpx.Client(),
+            [],
+            "https://example.com/",
+            False,
+        ],
+        [
+            httpx.Client(),
+            [MATCH_ALL],
+            "https://example.com/",
+            True,
+        ],
+        [
+            httpx.Client(),
+            ["https://example.com/"],
+            "https://example.com/",
+            True,
+        ],
+        [
+            httpx.Client(),
+            ["https://example.com/"],
+            "https://example.com",
+            False,
+        ],
+        [
+            httpx.Client(),
+            ["https://example.com"],
+            "https://example.com",
+            True,
+        ],
+        [
+            httpx.Client(),
+            ["https://example.com", r"https?:\/\/[\w\-]+(\.[\w\-]+)+\.net"],
+            "https://example.net",
+            False,
+        ],
+        [
+            httpx.Client(),
+            ["https://example.com", r"https?:\/\/[\w\-]+(\.[\w\-]+)+\.net"],
+            "https://good.example.net",
+            True,
+        ],
+        [
+            httpx.Client(),
+            ["https://example.com", r"https?:\/\/[\w\-]+(\.[\w\-]+)+\.net"],
+            "https://good.example.net/some/thing",
+            True,
+        ],
+        [
+            httpx.AsyncClient(),
+            None,
+            "https://example.com/",
+            False,
+        ],
+        [
+            httpx.AsyncClient(),
+            [],
+            "https://example.com/",
+            False,
+        ],
+        [
+            httpx.AsyncClient(),
+            [MATCH_ALL],
+            "https://example.com/",
+            True,
+        ],
+        [
+            httpx.AsyncClient(),
+            ["https://example.com/"],
+            "https://example.com/",
+            True,
+        ],
+        [
+            httpx.AsyncClient(),
+            ["https://example.com/"],
+            "https://example.com",
+            False,
+        ],
+        [
+            httpx.AsyncClient(),
+            ["https://example.com"],
+            "https://example.com",
+            True,
+        ],
+        [
+            httpx.AsyncClient(),
+            ["https://example.com", r"https?:\/\/[\w\-]+(\.[\w\-]+)+\.net"],
+            "https://example.net",
+            False,
+        ],
+        [
+            httpx.AsyncClient(),
+            ["https://example.com", r"https?:\/\/[\w\-]+(\.[\w\-]+)+\.net"],
+            "https://good.example.net",
+            True,
+        ],
+        [
+            httpx.AsyncClient(),
+            ["https://example.com", r"https?:\/\/[\w\-]+(\.[\w\-]+)+\.net"],
+            "https://good.example.net/some/thing",
+            True,
+        ],
+    ],
+)
+def test_option_trace_propagation_targets(
+    sentry_init,
+    httpx_client,
+    httpx_mock,  # this comes from pytest-httpx
+    trace_propagation_targets,
+    url,
+    trace_propagated,
+):
+    httpx_mock.add_response()
+
+    sentry_init(
+        release="test",
+        trace_propagation_targets=trace_propagation_targets,
+        traces_sample_rate=1.0,
+        integrations=[HttpxIntegration()],
+    )
+
+    if asyncio.iscoroutinefunction(httpx_client.get):
+        asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
+    else:
+        httpx_client.get(url)
+
+    request_headers = httpx_mock.get_request().headers
+
+    if trace_propagated:
+        assert "sentry-trace" in request_headers
+    else:
+        assert "sentry-trace" not in request_headers
diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py
index a66a20c4..bca247f2 100644
--- a/tests/integrations/stdlib/test_httplib.py
+++ b/tests/integrations/stdlib/test_httplib.py
@@ -4,6 +4,8 @@ import random
 import responses
 import pytest
 
+from sentry_sdk.consts import MATCH_ALL
+
 try:
     # py3
     from urllib.request import urlopen
@@ -240,3 +242,109 @@ def test_outgoing_trace_headers_head_sdk(sentry_init, monkeypatch):
         assert sorted(request_headers["baggage"].split(",")) == sorted(
             expected_outgoing_baggage_items
         )
+
+
+@pytest.mark.parametrize(
+    "trace_propagation_targets,host,path,trace_propagated",
+    [
+        [
+            [],
+            "example.com",
+            "/",
+            False,
+        ],
+        [
+            None,
+            "example.com",
+            "/",
+            False,
+        ],
+        [
+            [MATCH_ALL],
+            "example.com",
+            "/",
+            True,
+        ],
+        [
+            ["https://example.com/"],
+            "example.com",
+            "/",
+            True,
+        ],
+        [
+            ["https://example.com/"],
+            "example.com",
+            "",
+            False,
+        ],
+        [
+            ["https://example.com"],
+            "example.com",
+            "",
+            True,
+        ],
+        [
+            ["https://example.com", r"https?:\/\/[\w\-]+(\.[\w\-]+)+\.net"],
+            "example.net",
+            "",
+            False,
+        ],
+        [
+            ["https://example.com", r"https?:\/\/[\w\-]+(\.[\w\-]+)+\.net"],
+            "good.example.net",
+            "",
+            True,
+        ],
+        [
+            ["https://example.com", r"https?:\/\/[\w\-]+(\.[\w\-]+)+\.net"],
+            "good.example.net",
+            "/some/thing",
+            True,
+        ],
+    ],
+)
+def test_option_trace_propagation_targets(
+    sentry_init, monkeypatch, trace_propagation_targets, host, path, trace_propagated
+):
+    # HTTPSConnection.send is passed a string containing (among other things)
+    # the headers on the request. Mock it so we can check the headers, and also
+    # so it doesn't try to actually talk to the internet.
+    mock_send = mock.Mock()
+    monkeypatch.setattr(HTTPSConnection, "send", mock_send)
+
+    sentry_init(
+        trace_propagation_targets=trace_propagation_targets,
+        traces_sample_rate=1.0,
+    )
+
+    headers = {
+        "baggage": (
+            "sentry-trace_id=771a43a4192642f0b136d5159a501700, "
+            "sentry-public_key=49d0f7386ad645858ae85020e393bef3, sentry-sample_rate=0.01337, "
+        )
+    }
+
+    transaction = Transaction.continue_from_headers(headers)
+
+    with start_transaction(
+        transaction=transaction,
+        name="/interactions/other-dogs/new-dog",
+        op="greeting.sniff",
+        trace_id="12312012123120121231201212312012",
+    ) as transaction:
+
+        HTTPSConnection(host).request("GET", path)
+
+        (request_str,) = mock_send.call_args[0]
+        request_headers = {}
+        for line in request_str.decode("utf-8").split("\r\n")[1:]:
+            if line:
+                key, val = line.split(": ")
+                request_headers[key] = val
+
+        if trace_propagated:
+            assert "sentry-trace" in request_headers
+            assert "baggage" in request_headers
+        else:
+            assert "sentry-trace" not in request_headers
+            assert "baggage" not in request_headers
diff --git a/tests/test_basics.py b/tests/test_basics.py
index 60c1822b..2f3a6b61 100644
--- a/tests/test_basics.py
+++ b/tests/test_basics.py
@@ -1,6 +1,6 @@
+import logging
 import os
 import sys
-import logging
 
 import pytest
 
@@ -16,7 +16,6 @@ from sentry_sdk import (
     last_event_id,
     Hub,
 )
-
 from sentry_sdk._compat import reraise
 from sentry_sdk.integrations import _AUTO_ENABLING_INTEGRATIONS
 from sentry_sdk.integrations.logging import LoggingIntegration
diff --git a/tests/tracing/test_misc.py b/tests/tracing/test_misc.py
index d67643fe..007dcb91 100644
--- a/tests/tracing/test_misc.py
+++ b/tests/tracing/test_misc.py
@@ -1,3 +1,4 @@
+from mock import MagicMock
 import pytest
 import gc
 import uuid
@@ -5,7 +6,9 @@ import os
 
 import sentry_sdk
 from sentry_sdk import Hub, start_span, start_transaction, set_measurement
+from sentry_sdk.consts import MATCH_ALL
 from sentry_sdk.tracing import Span, Transaction
+from sentry_sdk.tracing_utils import should_propagate_trace
 
 try:
     from unittest import mock  # python 3.3 and above
@@ -271,3 +274,35 @@ def test_set_meaurement_public_api(sentry_init, capture_events):
     (event,) = events
     assert event["measurements"]["metric.foo"] == {"value": 123, "unit": ""}
     assert event["measurements"]["metric.bar"] == {"value": 456, "unit": "second"}
+
+
+@pytest.mark.parametrize(
+    "trace_propagation_targets,url,expected_propagation_decision",
+    [
+        (None, "http://example.com", False),
+        ([], "http://example.com", False),
+        ([MATCH_ALL], "http://example.com", True),
+        (["localhost"], "localhost:8443/api/users", True),
+        (["localhost"], "http://localhost:8443/api/users", True),
+        (["localhost"], "mylocalhost:8080/api/users", True),
+        ([r"^/api"], "/api/envelopes", True),
+        ([r"^/api"], "/backend/api/envelopes", False),
+        ([r"myApi.com/v[2-4]"], "myApi.com/v2/projects", True),
+        ([r"myApi.com/v[2-4]"], "myApi.com/v1/projects", False),
+        ([r"https:\/\/.*"], "https://example.com", True),
+        (
+            [r"https://.*"],
+            "https://example.com",
+            True,
+        ),  # to show escaping is not needed
+        ([r"https://.*"], "http://example.com/insecure/", False),
+    ],
+)
+def test_should_propagate_trace(
+    trace_propagation_targets, url, expected_propagation_decision
+):
+    hub = MagicMock()
+    hub.client = MagicMock()
+    hub.client.options = {"trace_propagation_targets": trace_propagation_targets}
+
+    assert should_propagate_trace(hub, url) == expected_propagation_decision