Repository: open-telemetry/opentelemetry-python

Issue:
Zipkin exporter should support Array attributes
As per spec: https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/sdk_exporters/zipkin.md#attribute


Discussion:
@codeboten will be happy to help with that :)

Resolving patch:
diff --git a/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/__init__.py b/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/__init__.py
--- a/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/__init__.py
+++ b/exporter/opentelemetry-exporter-zipkin/src/opentelemetry/exporter/zipkin/__init__.py
@@ -351,10 +351,15 @@ def _extract_tags_from_dict(self, tags_dict):
         if not tags_dict:
             return tags
         for attribute_key, attribute_value in tags_dict.items():
-            if isinstance(attribute_value, (int, bool, float)):
+            if isinstance(attribute_value, (int, bool, float, str)):
                 value = str(attribute_value)
-            elif isinstance(attribute_value, str):
-                value = attribute_value
+            elif isinstance(attribute_value, Sequence):
+                value = self._extract_tag_value_string_from_sequence(
+                    attribute_value
+                )
+                if not value:
+                    logger.warning("Could not serialize tag %s", attribute_key)
+                    continue
             else:
                 logger.warning("Could not serialize tag %s", attribute_key)
                 continue
@@ -364,6 +369,42 @@ def _extract_tags_from_dict(self, tags_dict):
             tags[attribute_key] = value
         return tags
 
+    def _extract_tag_value_string_from_sequence(self, sequence: Sequence):
+        if self.max_tag_value_length == 1:
+            return None
+
+        tag_value_elements = []
+        running_string_length = (
+            2  # accounts for array brackets in output string
+        )
+        defined_max_tag_value_length = self.max_tag_value_length > 0
+
+        for element in sequence:
+            if isinstance(element, (int, bool, float, str)):
+                tag_value_element = str(element)
+            elif element is None:
+                tag_value_element = None
+            else:
+                continue
+
+            if defined_max_tag_value_length:
+                if tag_value_element is None:
+                    running_string_length += 4  # null with no quotes
+                else:
+                    # + 2 accounts for string quotation marks
+                    running_string_length += len(tag_value_element) + 2
+
+                if tag_value_elements:
+                    # accounts for ',' item separator
+                    running_string_length += 1
+
+                if running_string_length > self.max_tag_value_length:
+                    break
+
+            tag_value_elements.append(tag_value_element)
+
+        return json.dumps(tag_value_elements, separators=(",", ":"))
+
     def _extract_tags_from_span(self, span: Span):
         tags = self._extract_tags_from_dict(getattr(span, "attributes", None))
         if span.resource:


Test patch:
diff --git a/exporter/opentelemetry-exporter-zipkin/tests/test_zipkin_exporter.py b/exporter/opentelemetry-exporter-zipkin/tests/test_zipkin_exporter.py
--- a/exporter/opentelemetry-exporter-zipkin/tests/test_zipkin_exporter.py
+++ b/exporter/opentelemetry-exporter-zipkin/tests/test_zipkin_exporter.py
@@ -425,8 +425,25 @@ def test_export_json_max_tag_length(self):
         span.start()
         span.resource = Resource({})
         # added here to preserve order
-        span.set_attribute("k1", "v" * 500)
-        span.set_attribute("k2", "v" * 50)
+        span.set_attribute("string1", "v" * 500)
+        span.set_attribute("string2", "v" * 50)
+        span.set_attribute("list1", ["a"] * 25)
+        span.set_attribute("list2", ["a"] * 10)
+        span.set_attribute("list3", [2] * 25)
+        span.set_attribute("list4", [2] * 10)
+        span.set_attribute("list5", [True] * 25)
+        span.set_attribute("list6", [True] * 10)
+        span.set_attribute("tuple1", ("a",) * 25)
+        span.set_attribute("tuple2", ("a",) * 10)
+        span.set_attribute("tuple3", (2,) * 25)
+        span.set_attribute("tuple4", (2,) * 10)
+        span.set_attribute("tuple5", (True,) * 25)
+        span.set_attribute("tuple6", (True,) * 10)
+        span.set_attribute("range1", range(0, 25))
+        span.set_attribute("range2", range(0, 10))
+        span.set_attribute("empty_list", [])
+        span.set_attribute("none_list", ["hello", None, "world"])
+
         span.set_status(Status(StatusCode.ERROR, "Example description"))
         span.end()
 
@@ -440,8 +457,66 @@ def test_export_json_max_tag_length(self):
         _, kwargs = mock_post.call_args  # pylint: disable=E0633
 
         tags = json.loads(kwargs["data"])[0]["tags"]
-        self.assertEqual(len(tags["k1"]), 128)
-        self.assertEqual(len(tags["k2"]), 50)
+
+        self.assertEqual(len(tags["string1"]), 128)
+        self.assertEqual(len(tags["string2"]), 50)
+        self.assertEqual(
+            tags["list1"],
+            '["a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a"]',
+        )
+        self.assertEqual(
+            tags["list2"], '["a","a","a","a","a","a","a","a","a","a"]',
+        )
+        self.assertEqual(
+            tags["list3"],
+            '["2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2"]',
+        )
+        self.assertEqual(
+            tags["list4"], '["2","2","2","2","2","2","2","2","2","2"]',
+        )
+        self.assertEqual(
+            tags["list5"],
+            '["True","True","True","True","True","True","True","True","True","True","True","True","True","True","True","True","True","True"]',
+        )
+        self.assertEqual(
+            tags["list6"],
+            '["True","True","True","True","True","True","True","True","True","True"]',
+        )
+        self.assertEqual(
+            tags["tuple1"],
+            '["a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a"]',
+        )
+        self.assertEqual(
+            tags["tuple2"], '["a","a","a","a","a","a","a","a","a","a"]',
+        )
+        self.assertEqual(
+            tags["tuple3"],
+            '["2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2","2"]',
+        )
+        self.assertEqual(
+            tags["tuple4"], '["2","2","2","2","2","2","2","2","2","2"]',
+        )
+        self.assertEqual(
+            tags["tuple5"],
+            '["True","True","True","True","True","True","True","True","True","True","True","True","True","True","True","True","True","True"]',
+        )
+        self.assertEqual(
+            tags["tuple6"],
+            '["True","True","True","True","True","True","True","True","True","True"]',
+        )
+        self.assertEqual(
+            tags["range1"],
+            '["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24"]',
+        )
+        self.assertEqual(
+            tags["range2"], '["0","1","2","3","4","5","6","7","8","9"]',
+        )
+        self.assertEqual(
+            tags["empty_list"], "[]",
+        )
+        self.assertEqual(
+            tags["none_list"], '["hello",null,"world"]',
+        )
 
         exporter = ZipkinSpanExporter(service_name, max_tag_value_length=2)
         mock_post = MagicMock()
@@ -452,8 +527,126 @@ def test_export_json_max_tag_length(self):
 
         _, kwargs = mock_post.call_args  # pylint: disable=E0633
         tags = json.loads(kwargs["data"])[0]["tags"]
-        self.assertEqual(len(tags["k1"]), 2)
-        self.assertEqual(len(tags["k2"]), 2)
+        self.assertEqual(len(tags["string1"]), 2)
+        self.assertEqual(len(tags["string2"]), 2)
+        self.assertEqual(tags["list1"], "[]")
+        self.assertEqual(tags["list2"], "[]")
+        self.assertEqual(tags["list3"], "[]")
+        self.assertEqual(tags["list4"], "[]")
+        self.assertEqual(tags["list5"], "[]")
+        self.assertEqual(tags["list6"], "[]")
+        self.assertEqual(tags["tuple1"], "[]")
+        self.assertEqual(tags["tuple2"], "[]")
+        self.assertEqual(tags["tuple3"], "[]")
+        self.assertEqual(tags["tuple4"], "[]")
+        self.assertEqual(tags["tuple5"], "[]")
+        self.assertEqual(tags["tuple6"], "[]")
+        self.assertEqual(tags["range1"], "[]")
+        self.assertEqual(tags["range2"], "[]")
+
+        exporter = ZipkinSpanExporter(service_name, max_tag_value_length=5)
+        mock_post = MagicMock()
+        with patch("requests.post", mock_post):
+            mock_post.return_value = MockResponse(200)
+            status = exporter.export([span])
+            self.assertEqual(SpanExportResult.SUCCESS, status)
+
+        _, kwargs = mock_post.call_args  # pylint: disable=E0633
+        tags = json.loads(kwargs["data"])[0]["tags"]
+        self.assertEqual(len(tags["string1"]), 5)
+        self.assertEqual(len(tags["string2"]), 5)
+        self.assertEqual(tags["list1"], '["a"]')
+        self.assertEqual(tags["list2"], '["a"]')
+        self.assertEqual(tags["list3"], '["2"]')
+        self.assertEqual(tags["list4"], '["2"]')
+        self.assertEqual(tags["list5"], "[]")
+        self.assertEqual(tags["list6"], "[]")
+        self.assertEqual(tags["tuple1"], '["a"]')
+        self.assertEqual(tags["tuple2"], '["a"]')
+        self.assertEqual(tags["tuple3"], '["2"]')
+        self.assertEqual(tags["tuple4"], '["2"]')
+        self.assertEqual(tags["tuple5"], "[]")
+        self.assertEqual(tags["tuple6"], "[]")
+        self.assertEqual(tags["range1"], '["0"]')
+        self.assertEqual(tags["range2"], '["0"]')
+
+        exporter = ZipkinSpanExporter(service_name, max_tag_value_length=9)
+        mock_post = MagicMock()
+        with patch("requests.post", mock_post):
+            mock_post.return_value = MockResponse(200)
+            status = exporter.export([span])
+            self.assertEqual(SpanExportResult.SUCCESS, status)
+
+        _, kwargs = mock_post.call_args  # pylint: disable=E0633
+        tags = json.loads(kwargs["data"])[0]["tags"]
+        self.assertEqual(len(tags["string1"]), 9)
+        self.assertEqual(len(tags["string2"]), 9)
+        self.assertEqual(tags["list1"], '["a","a"]')
+        self.assertEqual(tags["list2"], '["a","a"]')
+        self.assertEqual(tags["list3"], '["2","2"]')
+        self.assertEqual(tags["list4"], '["2","2"]')
+        self.assertEqual(tags["list5"], '["True"]')
+        self.assertEqual(tags["list6"], '["True"]')
+        self.assertEqual(tags["tuple1"], '["a","a"]')
+        self.assertEqual(tags["tuple2"], '["a","a"]')
+        self.assertEqual(tags["tuple3"], '["2","2"]')
+        self.assertEqual(tags["tuple4"], '["2","2"]')
+        self.assertEqual(tags["tuple5"], '["True"]')
+        self.assertEqual(tags["tuple6"], '["True"]')
+        self.assertEqual(tags["range1"], '["0","1"]')
+        self.assertEqual(tags["range2"], '["0","1"]')
+
+        exporter = ZipkinSpanExporter(service_name, max_tag_value_length=10)
+        mock_post = MagicMock()
+        with patch("requests.post", mock_post):
+            mock_post.return_value = MockResponse(200)
+            status = exporter.export([span])
+            self.assertEqual(SpanExportResult.SUCCESS, status)
+
+        _, kwargs = mock_post.call_args  # pylint: disable=E0633
+        tags = json.loads(kwargs["data"])[0]["tags"]
+        self.assertEqual(len(tags["string1"]), 10)
+        self.assertEqual(len(tags["string2"]), 10)
+        self.assertEqual(tags["list1"], '["a","a"]')
+        self.assertEqual(tags["list2"], '["a","a"]')
+        self.assertEqual(tags["list3"], '["2","2"]')
+        self.assertEqual(tags["list4"], '["2","2"]')
+        self.assertEqual(tags["list5"], '["True"]')
+        self.assertEqual(tags["list6"], '["True"]')
+        self.assertEqual(tags["tuple1"], '["a","a"]')
+        self.assertEqual(tags["tuple2"], '["a","a"]')
+        self.assertEqual(tags["tuple3"], '["2","2"]')
+        self.assertEqual(tags["tuple4"], '["2","2"]')
+        self.assertEqual(tags["tuple5"], '["True"]')
+        self.assertEqual(tags["tuple6"], '["True"]')
+        self.assertEqual(tags["range1"], '["0","1"]')
+        self.assertEqual(tags["range2"], '["0","1"]')
+
+        exporter = ZipkinSpanExporter(service_name, max_tag_value_length=11)
+        mock_post = MagicMock()
+        with patch("requests.post", mock_post):
+            mock_post.return_value = MockResponse(200)
+            status = exporter.export([span])
+            self.assertEqual(SpanExportResult.SUCCESS, status)
+
+        _, kwargs = mock_post.call_args  # pylint: disable=E0633
+        tags = json.loads(kwargs["data"])[0]["tags"]
+        self.assertEqual(len(tags["string1"]), 11)
+        self.assertEqual(len(tags["string2"]), 11)
+        self.assertEqual(tags["list1"], '["a","a"]')
+        self.assertEqual(tags["list2"], '["a","a"]')
+        self.assertEqual(tags["list3"], '["2","2"]')
+        self.assertEqual(tags["list4"], '["2","2"]')
+        self.assertEqual(tags["list5"], '["True"]')
+        self.assertEqual(tags["list6"], '["True"]')
+        self.assertEqual(tags["tuple1"], '["a","a"]')
+        self.assertEqual(tags["tuple2"], '["a","a"]')
+        self.assertEqual(tags["tuple3"], '["2","2"]')
+        self.assertEqual(tags["tuple4"], '["2","2"]')
+        self.assertEqual(tags["tuple5"], '["True"]')
+        self.assertEqual(tags["tuple6"], '["True"]')
+        self.assertEqual(tags["range1"], '["0","1"]')
+        self.assertEqual(tags["range2"], '["0","1"]')
 
     # pylint: disable=too-many-locals,too-many-statements
     def test_export_protobuf(self):