Repository: DataDog/dd-trace-py

Issue:
DD_TAGS separator inconsistent with heroku-buildpack-datadog
https://github.com/DataDog/heroku-buildpack-datadog sets `DD_TAGS` separated by spaces.

However, dd-trace-py splits the tags by commas:

https://github.com/DataDog/dd-trace-py/blob/master/ddtrace/utils/formats.py#L87-L116
```
def parse_tags_str(tags_str):
    """Parse a string of tags typically provided via environment variables.
    The expected string is of the form::
        "key1:value1,key2:value2"
    :param tags_str: A string of the above form to parse tags from.
    :return: A dict containing the tags that were parsed.
    """
    parsed_tags = {}
    if not tags_str:
        return parsed_tags

    for tag in tags_str.split(","):
        try:
            key, value = tag.split(":", 1)

            # Validate the tag
            if key == "" or value == "" or value.endswith(":"):
                raise ValueError
        except ValueError:
            log.error(
                "Malformed tag in tag pair '%s' from tag string '%s'.",
                tag,
                tags_str,
            )
        else:
            parsed_tags[key] = value

    return parsed_tags
```

This results in all of the tags being set as the value of the first tag.

This looks to have been previously surfaced for the java tracer here:
https://github.com/DataDog/heroku-buildpack-datadog/issues/187

And was initially changed in the buildpack but was reverted and dd-trace-java updated to accommodate commas or spaces:
https://github.com/DataDog/dd-trace-java/pull/2011

### Which version of dd-trace-py are you using?
0.47.0

### How can we reproduce your problem?
Perform a trace with `DD_TAGS` set by https://github.com/DataDog/heroku-buildpack-datadog.

### What is the result that you get?
`dyno` tag contains all other tags.

### What is the result that you expected?
Tags to be properly separated.



Discussion:

Resolving patch:
diff --git a/ddtrace/utils/formats.py b/ddtrace/utils/formats.py
--- a/ddtrace/utils/formats.py
+++ b/ddtrace/utils/formats.py
@@ -1,9 +1,13 @@
 import logging
 import os
+import re
 
 from .deprecation import deprecation
 
 
+# Tags `key:value` must be separated by either comma or space
+_TAGS_NOT_SEPARATED = re.compile(r":[^,\s]+:")
+
 log = logging.getLogger(__name__)
 
 
@@ -89,6 +93,7 @@ def parse_tags_str(tags_str):
 
     The expected string is of the form::
         "key1:value1,key2:value2"
+        "key1:value1 key2:value2"
 
     :param tags_str: A string of the above form to parse tags from.
     :return: A dict containing the tags that were parsed.
@@ -97,7 +102,29 @@ def parse_tags_str(tags_str):
     if not tags_str:
         return parsed_tags
 
-    for tag in tags_str.split(","):
+    if _TAGS_NOT_SEPARATED.search(tags_str):
+        log.error("Malformed tag string with tags not separated by comma or space '%s'.", tags_str)
+        return parsed_tags
+
+    # Identify separator based on which successfully identifies the correct
+    # number of valid tags
+    numtagseps = tags_str.count(":")
+    for sep in [",", " "]:
+        if sum(":" in _ for _ in tags_str.split(sep)) == numtagseps:
+            break
+    else:
+        log.error(
+            (
+                "Failed to find separator for tag string: '%s'.\n"
+                "Tag strings must be comma or space separated:\n"
+                "  key1:value1,key2:value2\n"
+                "  key1:value1 key2:value2"
+            ),
+            tags_str,
+        )
+        return parsed_tags
+
+    for tag in tags_str.split(sep):
         try:
             key, value = tag.split(":", 1)
 


Test patch:
diff --git a/tests/tracer/test_utils.py b/tests/tracer/test_utils.py
--- a/tests/tracer/test_utils.py
+++ b/tests/tracer/test_utils.py
@@ -104,60 +104,111 @@ def fxn():
             self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
             self.assertIn("decorator", str(w[-1].message))
 
-    def test_parse_env_tags(self):
-        tags = parse_tags_str("key:val")
-        assert tags == dict(key="val")
-
-        tags = parse_tags_str("key:val,key2:val2")
-        assert tags == dict(key="val", key2="val2")
-
-        tags = parse_tags_str("key:val,key2:val2,key3:1234.23")
-        assert tags == dict(key="val", key2="val2", key3="1234.23")
-
-        with mock.patch("ddtrace.utils.formats.log") as log:
-            tags = parse_tags_str("key:,key3:val1,")
-        assert tags == dict(key3="val1")
-        assert log.error.call_count == 2
-
-        with mock.patch("ddtrace.utils.formats.log") as log:
-            tags = parse_tags_str("")
-        assert tags == dict()
-        assert log.error.call_count == 0
-
-        with mock.patch("ddtrace.utils.formats.log") as log:
-            tags = parse_tags_str(",")
-        assert tags == dict()
-        assert log.error.call_count == 2
-
-        with mock.patch("ddtrace.utils.formats.log") as log:
-            tags = parse_tags_str(":,:")
-        assert tags == dict()
-        assert log.error.call_count == 2
-
-        with mock.patch("ddtrace.utils.formats.log") as log:
-            tags = parse_tags_str("key,key2:val1")
-        assert tags == dict(key2="val1")
-        log.error.assert_called_once_with(
-            "Malformed tag in tag pair '%s' from tag string '%s'.", "key", "key,key2:val1"
-        )
 
-        with mock.patch("ddtrace.utils.formats.log") as log:
-            tags = parse_tags_str("key2:val1:")
-        assert tags == dict()
-        log.error.assert_called_once_with(
-            "Malformed tag in tag pair '%s' from tag string '%s'.", "key2:val1:", "key2:val1:"
-        )
+_LOG_ERROR_MALFORMED_TAG = "Malformed tag in tag pair '%s' from tag string '%s'."
+_LOG_ERROR_MALFORMED_TAG_STRING = "Malformed tag string with tags not separated by comma or space '%s'."
+_LOG_ERROR_FAIL_SEPARATOR = (
+    "Failed to find separator for tag string: '%s'.\n"
+    "Tag strings must be comma or space separated:\n"
+    "  key1:value1,key2:value2\n"
+    "  key1:value1 key2:value2"
+)
+
 
-        with mock.patch("ddtrace.utils.formats.log") as log:
-            tags = parse_tags_str("key,key2,key3")
-        assert tags == dict()
-        log.error.assert_has_calls(
+@pytest.mark.parametrize(
+    "tag_str,expected_tags,error_calls",
+    [
+        ("", dict(), None),
+        ("key:val", dict(key="val"), None),
+        ("key:val,key2:val2", dict(key="val", key2="val2"), None),
+        ("key:val,key2:val2,key3:1234.23", dict(key="val", key2="val2", key3="1234.23"), None),
+        ("key:val key2:val2 key3:1234.23", dict(key="val", key2="val2", key3="1234.23"), None),
+        ("key: val", dict(key=" val"), None),
+        ("key key: val", {"key key": " val"}, None),
+        ("key: val,key2:val2", dict(key=" val", key2="val2"), None),
+        (" key: val,key2:val2", {" key": " val", "key2": "val2"}, None),
+        ("key key2:val1", {"key key2": "val1"}, None),
+        (
+            "key:val key2:val:2",
+            dict(),
+            [mock.call(_LOG_ERROR_MALFORMED_TAG_STRING, "key:val key2:val:2")],
+        ),
+        (
+            "key:val,key2:val2 key3:1234.23",
+            dict(),
+            [mock.call(_LOG_ERROR_FAIL_SEPARATOR, "key:val,key2:val2 key3:1234.23")],
+        ),
+        (
+            "key:val key2:val2 key3: ",
+            dict(key="val", key2="val2"),
             [
-                mock.call("Malformed tag in tag pair '%s' from tag string '%s'.", "key", "key,key2,key3"),
-                mock.call("Malformed tag in tag pair '%s' from tag string '%s'.", "key2", "key,key2,key3"),
-                mock.call("Malformed tag in tag pair '%s' from tag string '%s'.", "key3", "key,key2,key3"),
-            ]
-        )
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "key3:", "key:val key2:val2 key3: "),
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "", "key:val key2:val2 key3: "),
+            ],
+        ),
+        (
+            "key:val key2:val 2",
+            dict(key="val", key2="val"),
+            [mock.call(_LOG_ERROR_MALFORMED_TAG, "2", "key:val key2:val 2")],
+        ),
+        (
+            "key: val key2:val2 key3:val3",
+            {"key2": "val2", "key3": "val3"},
+            [
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "key:", "key: val key2:val2 key3:val3"),
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "val", "key: val key2:val2 key3:val3"),
+            ],
+        ),
+        (
+            "key:,key3:val1,",
+            dict(key3="val1"),
+            [
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "key:", "key:,key3:val1,"),
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "", "key:,key3:val1,"),
+            ],
+        ),
+        (
+            ",",
+            dict(),
+            [
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "", ","),
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "", ","),
+            ],
+        ),
+        (
+            ":,:",
+            dict(),
+            [
+                mock.call(_LOG_ERROR_MALFORMED_TAG, ":", ":,:"),
+                mock.call(_LOG_ERROR_MALFORMED_TAG, ":", ":,:"),
+            ],
+        ),
+        (
+            "key,key2:val1",
+            dict(key2="val1"),
+            [mock.call(_LOG_ERROR_MALFORMED_TAG, "key", "key,key2:val1")],
+        ),
+        ("key2:val1:", dict(), [mock.call(_LOG_ERROR_MALFORMED_TAG_STRING, "key2:val1:")]),
+        (
+            "key,key2,key3",
+            dict(),
+            [
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "key", "key,key2,key3"),
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "key2", "key,key2,key3"),
+                mock.call(_LOG_ERROR_MALFORMED_TAG, "key3", "key,key2,key3"),
+            ],
+        ),
+    ],
+)
+def test_parse_env_tags(tag_str, expected_tags, error_calls):
+    with mock.patch("ddtrace.utils.formats.log") as log:
+        tags = parse_tags_str(tag_str)
+        assert tags == expected_tags
+        if error_calls:
+            assert log.error.call_count == len(error_calls)
+            log.error.assert_has_calls(error_calls)
+        else:
+            assert log.error.call_count == 0
 
 
 def test_no_states():