Repository: open-telemetry/opentelemetry-python

Issue:
Fix TraceState to adhere to specs
From the [specs](https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/api.md#tracestate):

1. All mutating operations MUST return a new TraceState with the modifications applied
2. TraceState MUST at all times be valid according to rules specified in W3C Trace Context [specs](https://www.w3.org/TR/trace-context/#mutating-the-tracestate-field)
3. Error handling -> must not return null or throw an exception (should log)


Discussion:
@codeboten as discussed in SIG meeting, assign this to me. I will start work on this weekend.
Done, thanks @lonewolf3739!

Resolving patch:
diff --git a/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py b/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py
--- a/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py
+++ b/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py
@@ -18,31 +18,7 @@
 import opentelemetry.trace as trace
 from opentelemetry.context.context import Context
 from opentelemetry.trace.propagation import textmap
-
-#    Keys and values are strings of up to 256 printable US-ASCII characters.
-#    Implementations should conform to the `W3C Trace Context - Tracestate`_
-#    spec, which describes additional restrictions on valid field values.
-#
-#    .. _W3C Trace Context - Tracestate:
-#        https://www.w3.org/TR/trace-context/#tracestate-field
-
-_KEY_WITHOUT_VENDOR_FORMAT = r"[a-z][_0-9a-z\-\*\/]{0,255}"
-_KEY_WITH_VENDOR_FORMAT = (
-    r"[a-z0-9][_0-9a-z\-\*\/]{0,240}@[a-z][_0-9a-z\-\*\/]{0,13}"
-)
-
-_KEY_FORMAT = _KEY_WITHOUT_VENDOR_FORMAT + "|" + _KEY_WITH_VENDOR_FORMAT
-_VALUE_FORMAT = (
-    r"[\x20-\x2b\x2d-\x3c\x3e-\x7e]{0,255}[\x21-\x2b\x2d-\x3c\x3e-\x7e]"
-)
-
-_DELIMITER_FORMAT = "[ \t]*,[ \t]*"
-_MEMBER_FORMAT = "({})(=)({})[ \t]*".format(_KEY_FORMAT, _VALUE_FORMAT)
-
-_DELIMITER_FORMAT_RE = re.compile(_DELIMITER_FORMAT)
-_MEMBER_FORMAT_RE = re.compile(_MEMBER_FORMAT)
-
-_TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS = 32
+from opentelemetry.trace.span import TraceState
 
 
 class TraceContextTextMapPropagator(textmap.TextMapPropagator):
@@ -94,7 +70,7 @@ def extract(
         if tracestate_headers is None:
             tracestate = None
         else:
-            tracestate = _parse_tracestate(tracestate_headers)
+            tracestate = TraceState.from_header(tracestate_headers)
 
         span_context = trace.SpanContext(
             trace_id=int(trace_id, 16),
@@ -130,7 +106,7 @@ def inject(
             carrier, self._TRACEPARENT_HEADER_NAME, traceparent_string
         )
         if span_context.trace_state:
-            tracestate_string = _format_tracestate(span_context.trace_state)
+            tracestate_string = span_context.trace_state.to_header()
             set_in_carrier(
                 carrier, self._TRACESTATE_HEADER_NAME, tracestate_string
             )
@@ -143,57 +119,3 @@ def fields(self) -> typing.Set[str]:
         `opentelemetry.trace.propagation.textmap.TextMapPropagator.fields`
         """
         return {self._TRACEPARENT_HEADER_NAME, self._TRACESTATE_HEADER_NAME}
-
-
-def _parse_tracestate(header_list: typing.List[str]) -> trace.TraceState:
-    """Parse one or more w3c tracestate header into a TraceState.
-
-    Args:
-        string: the value of the tracestate header.
-
-    Returns:
-        A valid TraceState that contains values extracted from
-        the tracestate header.
-
-        If the format of one headers is illegal, all values will
-        be discarded and an empty tracestate will be returned.
-
-        If the number of keys is beyond the maximum, all values
-        will be discarded and an empty tracestate will be returned.
-    """
-    tracestate = trace.TraceState()
-    value_count = 0
-    for header in header_list:
-        for member in re.split(_DELIMITER_FORMAT_RE, header):
-            # empty members are valid, but no need to process further.
-            if not member:
-                continue
-            match = _MEMBER_FORMAT_RE.fullmatch(member)
-            if not match:
-                # TODO: log this?
-                return trace.TraceState()
-            key, _eq, value = match.groups()
-            if key in tracestate:  # pylint:disable=E1135
-                # duplicate keys are not legal in
-                # the header, so we will remove
-                return trace.TraceState()
-            # typing.Dict's update is not recognized by pylint:
-            # https://github.com/PyCQA/pylint/issues/2420
-            tracestate[key] = value  # pylint:disable=E1137
-            value_count += 1
-            if value_count > _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS:
-                return trace.TraceState()
-    return tracestate
-
-
-def _format_tracestate(tracestate: trace.TraceState) -> str:
-    """Parse a w3c tracestate header into a TraceState.
-
-    Args:
-        tracestate: the tracestate header to write
-
-    Returns:
-        A string that adheres to the w3c tracestate
-        header format.
-    """
-    return ",".join(key + "=" + value for key, value in tracestate.items())
diff --git a/opentelemetry-api/src/opentelemetry/trace/span.py b/opentelemetry-api/src/opentelemetry/trace/span.py
--- a/opentelemetry-api/src/opentelemetry/trace/span.py
+++ b/opentelemetry-api/src/opentelemetry/trace/span.py
@@ -1,10 +1,18 @@
 import abc
 import logging
+import re
 import types as python_types
 import typing
+from collections import OrderedDict
 
 from opentelemetry.trace.status import Status
 from opentelemetry.util import types
+from opentelemetry.util.tracestate import (
+    _DELIMITER_PATTERN,
+    _MEMBER_PATTERN,
+    _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS,
+    _is_valid_pair,
+)
 
 _logger = logging.getLogger(__name__)
 
@@ -135,7 +143,7 @@ def sampled(self) -> bool:
 DEFAULT_TRACE_OPTIONS = TraceFlags.get_default()
 
 
-class TraceState(typing.Dict[str, str]):
+class TraceState(typing.Mapping[str, str]):
     """A list of key-value pairs representing vendor-specific trace info.
 
     Keys and values are strings of up to 256 printable US-ASCII characters.
@@ -146,10 +154,186 @@ class TraceState(typing.Dict[str, str]):
         https://www.w3.org/TR/trace-context/#tracestate-field
     """
 
+    def __init__(
+        self,
+        entries: typing.Optional[
+            typing.Sequence[typing.Tuple[str, str]]
+        ] = None,
+    ) -> None:
+        self._dict = OrderedDict()  # type: OrderedDict[str, str]
+        if entries is None:
+            return
+        if len(entries) > _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS:
+            _logger.warning(
+                "There can't be more than %s key/value pairs.",
+                _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS,
+            )
+            return
+
+        for key, value in entries:
+            if _is_valid_pair(key, value):
+                if key in self._dict:
+                    _logger.warning("Duplicate key: %s found.", key)
+                    continue
+                self._dict[key] = value
+            else:
+                _logger.warning(
+                    "Invalid key/value pair (%s, %s) found.", key, value
+                )
+
+    def __getitem__(self, key: str) -> typing.Optional[str]:  # type: ignore
+        return self._dict.get(key)
+
+    def __iter__(self) -> typing.Iterator[str]:
+        return iter(self._dict)
+
+    def __len__(self) -> int:
+        return len(self._dict)
+
+    def __repr__(self) -> str:
+        pairs = [
+            "{key=%s, value=%s}" % (key, value)
+            for key, value in self._dict.items()
+        ]
+        return str(pairs)
+
+    def add(self, key: str, value: str) -> "TraceState":
+        """Adds a key-value pair to tracestate. The provided pair should
+        adhere to w3c tracestate identifiers format.
+
+        Args:
+            key: A valid tracestate key to add
+            value: A valid tracestate value to add
+
+        Returns:
+            A new TraceState with the modifications applied.
+
+            If the provided key-value pair is invalid or results in tracestate
+            that violates tracecontext specification, they are discarded and
+            same tracestate will be returned.
+        """
+        if not _is_valid_pair(key, value):
+            _logger.warning(
+                "Invalid key/value pair (%s, %s) found.", key, value
+            )
+            return self
+        # There can be a maximum of 32 pairs
+        if len(self) >= _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS:
+            _logger.warning("There can't be more 32 key/value pairs.")
+            return self
+        # Duplicate entries are not allowed
+        if key in self._dict:
+            _logger.warning("The provided key %s already exists.", key)
+            return self
+        new_state = [(key, value)] + list(self._dict.items())
+        return TraceState(new_state)
+
+    def update(self, key: str, value: str) -> "TraceState":
+        """Updates a key-value pair in tracestate. The provided pair should
+        adhere to w3c tracestate identifiers format.
+
+        Args:
+            key: A valid tracestate key to update
+            value: A valid tracestate value to update for key
+
+        Returns:
+            A new TraceState with the modifications applied.
+
+            If the provided key-value pair is invalid or results in tracestate
+            that violates tracecontext specification, they are discarded and
+            same tracestate will be returned.
+        """
+        if not _is_valid_pair(key, value):
+            _logger.warning(
+                "Invalid key/value pair (%s, %s) found.", key, value
+            )
+            return self
+        prev_state = self._dict.copy()
+        prev_state[key] = value
+        prev_state.move_to_end(key, last=False)
+        new_state = list(prev_state.items())
+        return TraceState(new_state)
+
+    def delete(self, key: str) -> "TraceState":
+        """Deletes a key-value from tracestate.
+
+        Args:
+            key: A valid tracestate key to remove key-value pair from tracestate
+
+        Returns:
+            A new TraceState with the modifications applied.
+
+            If the provided key-value pair is invalid or results in tracestate
+            that violates tracecontext specification, they are discarded and
+            same tracestate will be returned.
+        """
+        if key not in self._dict:
+            _logger.warning("The provided key %s doesn't exist.", key)
+            return self
+        prev_state = self._dict.copy()
+        prev_state.pop(key)
+        new_state = list(prev_state.items())
+        return TraceState(new_state)
+
+    def to_header(self) -> str:
+        """Creates a w3c tracestate header from a TraceState.
+
+        Returns:
+            A string that adheres to the w3c tracestate
+            header format.
+        """
+        return ",".join(key + "=" + value for key, value in self._dict.items())
+
+    @classmethod
+    def from_header(cls, header_list: typing.List[str]) -> "TraceState":
+        """Parses one or more w3c tracestate header into a TraceState.
+
+        Args:
+            header_list: one or more w3c tracestate headers.
+
+        Returns:
+            A valid TraceState that contains values extracted from
+            the tracestate header.
+
+            If the format of one headers is illegal, all values will
+            be discarded and an empty tracestate will be returned.
+
+            If the number of keys is beyond the maximum, all values
+            will be discarded and an empty tracestate will be returned.
+        """
+        pairs = OrderedDict()
+        for header in header_list:
+            for member in re.split(_DELIMITER_PATTERN, header):
+                # empty members are valid, but no need to process further.
+                if not member:
+                    continue
+                match = _MEMBER_PATTERN.fullmatch(member)
+                if not match:
+                    _logger.warning(
+                        "Member doesn't match the w3c identifiers format %s",
+                        member,
+                    )
+                    return cls()
+                key, _eq, value = match.groups()
+                # duplicate keys are not legal in header
+                if key in pairs:
+                    return cls()
+                pairs[key] = value
+        return cls(list(pairs.items()))
+
     @classmethod
     def get_default(cls) -> "TraceState":
         return cls()
 
+    def keys(self) -> typing.KeysView[str]:
+        return self._dict.keys()
+
+    def items(self) -> typing.ItemsView[str, str]:
+        return self._dict.items()
+
+    def values(self) -> typing.ValuesView[str]:
+        return self._dict.values()
+
 
 DEFAULT_TRACE_STATE = TraceState.get_default()
 
diff --git a/opentelemetry-api/src/opentelemetry/util/tracestate.py b/opentelemetry-api/src/opentelemetry/util/tracestate.py
new file mode 100644
--- /dev/null
+++ b/opentelemetry-api/src/opentelemetry/util/tracestate.py
@@ -0,0 +1,73 @@
+# Copyright The OpenTelemetry Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import re
+from logging import getLogger
+
+_logger = getLogger(__name__)
+
+# The key MUST begin with a lowercase letter or a digit,
+# and can only contain lowercase letters (a-z), digits (0-9),
+# underscores (_), dashes (-), asterisks (*), and forward slashes (/).
+# For multi-tenant vendor scenarios, an at sign (@) can be used to
+# prefix the vendor name. Vendors SHOULD set the tenant ID
+# at the beginning of the key.
+
+# key = ( lcalpha ) 0*255( lcalpha / DIGIT / "_" / "-"/ "*" / "/" )
+# key = ( lcalpha / DIGIT ) 0*240( lcalpha / DIGIT / "_" / "-"/ "*" / "/" ) "@" lcalpha 0*13( lcalpha / DIGIT / "_" / "-"/ "*" / "/" )
+# lcalpha = %x61-7A ; a-z
+
+_KEY_WITHOUT_VENDOR_FORMAT = r"[a-z][_0-9a-z\-\*\/]{0,255}"
+_KEY_WITH_VENDOR_FORMAT = (
+    r"[a-z0-9][_0-9a-z\-\*\/]{0,240}@[a-z][_0-9a-z\-\*\/]{0,13}"
+)
+
+_KEY_FORMAT = _KEY_WITHOUT_VENDOR_FORMAT + "|" + _KEY_WITH_VENDOR_FORMAT
+_KEY_PATTERN = re.compile(_KEY_FORMAT)
+
+# The value is an opaque string containing up to 256 printable
+# ASCII [RFC0020] characters (i.e., the range 0x20 to 0x7E)
+# except comma (,) and (=).
+# value    = 0*255(chr) nblk-chr
+# nblk-chr = %x21-2B / %x2D-3C / %x3E-7E
+# chr      = %x20 / nblk-chr
+
+_VALUE_FORMAT = (
+    r"[\x20-\x2b\x2d-\x3c\x3e-\x7e]{0,255}[\x21-\x2b\x2d-\x3c\x3e-\x7e]"
+)
+_VALUE_PATTERN = re.compile(_VALUE_FORMAT)
+
+_DELIMITER_FORMAT = "[ \t]*,[ \t]*"
+_MEMBER_FORMAT = "({})(=)({})[ \t]*".format(_KEY_FORMAT, _VALUE_FORMAT)
+
+_DELIMITER_PATTERN = re.compile(_DELIMITER_FORMAT)
+_MEMBER_PATTERN = re.compile(_MEMBER_FORMAT)
+
+_TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS = 32
+
+
+def _is_valid_key(key: str) -> bool:
+    if not isinstance(key, str):
+        return False
+    return _KEY_PATTERN.fullmatch(key) is not None
+
+
+def _is_valid_value(value: str) -> bool:
+    if not isinstance(value, str):
+        return False
+    return _VALUE_PATTERN.fullmatch(value) is not None
+
+
+def _is_valid_pair(key: str, value: str) -> bool:
+    return _is_valid_key(key) and _is_valid_value(value)


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -10,7 +10,7 @@ env:
   # Otherwise, set variable to the commit of your branch on
   # opentelemetry-python-contrib which is compatible with these Core repo
   # changes.
-  CONTRIB_REPO_SHA: 32cac7a9ff6c831aa0e9514bb38c430fce819141
+  CONTRIB_REPO_SHA: 1e319dbaf21df7573f15f35773b8272579dd1030
 
 jobs:
   build:
diff --git a/exporter/opentelemetry-exporter-opencensus/tests/test_otcollector_trace_exporter.py b/exporter/opentelemetry-exporter-opencensus/tests/test_otcollector_trace_exporter.py
--- a/exporter/opentelemetry-exporter-opencensus/tests/test_otcollector_trace_exporter.py
+++ b/exporter/opentelemetry-exporter-opencensus/tests/test_otcollector_trace_exporter.py
@@ -94,7 +94,7 @@ def test_translate_to_collector(self):
             span_id,
             is_remote=False,
             trace_flags=TraceFlags(TraceFlags.SAMPLED),
-            trace_state=trace_api.TraceState({"testKey": "testValue"}),
+            trace_state=trace_api.TraceState([("testkey", "testvalue")]),
         )
         parent_span_context = trace_api.SpanContext(
             trace_id, parent_id, is_remote=False
@@ -200,9 +200,9 @@ def test_translate_to_collector(self):
         )
         self.assertEqual(output_spans[0].status.message, "test description")
         self.assertEqual(len(output_spans[0].tracestate.entries), 1)
-        self.assertEqual(output_spans[0].tracestate.entries[0].key, "testKey")
+        self.assertEqual(output_spans[0].tracestate.entries[0].key, "testkey")
         self.assertEqual(
-            output_spans[0].tracestate.entries[0].value, "testValue"
+            output_spans[0].tracestate.entries[0].value, "testvalue"
         )
 
         self.assertEqual(
diff --git a/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py b/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py
--- a/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py
+++ b/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py
@@ -19,6 +19,7 @@
 from opentelemetry import trace
 from opentelemetry.trace.propagation import tracecontext
 from opentelemetry.trace.propagation.textmap import DictGetter
+from opentelemetry.trace.span import TraceState
 
 FORMAT = tracecontext.TraceContextTextMapPropagator()
 
@@ -263,7 +264,7 @@ def test_fields(self, mock_get_current_span, mock_invalid_span_context):
                             "trace_id": 1,
                             "span_id": 2,
                             "trace_flags": 3,
-                            "trace_state": {"a": "b"},
+                            "trace_state": TraceState([("a", "b")]),
                         }
                     )
                 }
diff --git a/opentelemetry-api/tests/trace/test_tracestate.py b/opentelemetry-api/tests/trace/test_tracestate.py
new file mode 100644
--- /dev/null
+++ b/opentelemetry-api/tests/trace/test_tracestate.py
@@ -0,0 +1,98 @@
+# Copyright The OpenTelemetry Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# pylint: disable=no-member
+
+import unittest
+
+from opentelemetry.trace.span import TraceState
+
+
+class TestTraceContextFormat(unittest.TestCase):
+    def test_empty_tracestate(self):
+        state = TraceState()
+        self.assertEqual(len(state), 0)
+        self.assertEqual(state.to_header(), "")
+
+    def test_tracestate_valid_pairs(self):
+        pairs = [("1a-2f@foo", "bar1"), ("foo-_*/bar", "bar4")]
+        state = TraceState(pairs)
+        self.assertEqual(len(state), 2)
+        self.assertIsNotNone(state.get("foo-_*/bar"))
+        self.assertEqual(state.get("foo-_*/bar"), "bar4")
+        self.assertEqual(state.to_header(), "1a-2f@foo=bar1,foo-_*/bar=bar4")
+        self.assertIsNone(state.get("random"))
+
+    def test_tracestate_add_valid(self):
+        state = TraceState()
+        new_state = state.add("1a-2f@foo", "bar4")
+        self.assertEqual(len(new_state), 1)
+        self.assertEqual(new_state.get("1a-2f@foo"), "bar4")
+
+    def test_tracestate_add_invalid(self):
+        state = TraceState()
+        new_state = state.add("%%%nsasa", "val")
+        self.assertEqual(len(new_state), 0)
+        new_state = new_state.add("key", "====val====")
+        self.assertEqual(len(new_state), 0)
+        self.assertEqual(new_state.to_header(), "")
+
+    def test_tracestate_update_valid(self):
+        state = TraceState([("a", "1")])
+        new_state = state.update("a", "2")
+        self.assertEqual(new_state.get("a"), "2")
+        new_state = new_state.add("b", "3")
+        self.assertNotEqual(state, new_state)
+
+    def test_tracestate_update_invalid(self):
+        state = TraceState([("a", "1")])
+        new_state = state.update("a", "2=/")
+        self.assertNotEqual(new_state.get("a"), "2=/")
+        new_state = new_state.update("a", ",,2,,f")
+        self.assertNotEqual(new_state.get("a"), ",,2,,f")
+        self.assertEqual(new_state.get("a"), "1")
+
+    def test_tracestate_delete_preserved(self):
+        state = TraceState([("a", "1"), ("b", "2"), ("c", "3")])
+        new_state = state.delete("b")
+        self.assertIsNone(new_state.get("b"))
+        entries = list(new_state.items())
+        a_place = entries.index(("a", "1"))
+        c_place = entries.index(("c", "3"))
+        self.assertLessEqual(a_place, c_place)
+
+    def test_tracestate_from_header(self):
+        entries = [
+            "1a-2f@foo=bar1",
+            "1a-_*/2b@foo=bar2",
+            "foo=bar3",
+            "foo-_*/bar=bar4",
+        ]
+        header_list = [",".join(entries)]
+        state = TraceState.from_header(header_list)
+        self.assertEqual(state.to_header(), ",".join(entries))
+
+    def test_tracestate_order_changed(self):
+        entries = [
+            "1a-2f@foo=bar1",
+            "1a-_*/2b@foo=bar2",
+            "foo=bar3",
+            "foo-_*/bar=bar4",
+        ]
+        header_list = [",".join(entries)]
+        state = TraceState.from_header(header_list)
+        new_state = state.update("foo", "bar33")
+        entries = list(new_state.items())  # type: ignore
+        foo_place = entries.index(("foo", "bar33"))  # type: ignore
+        prev_first_place = entries.index(("1a-2f@foo", "bar1"))  # type: ignore
+        self.assertLessEqual(foo_place, prev_first_place)
diff --git a/opentelemetry-sdk/tests/trace/test_sampling.py b/opentelemetry-sdk/tests/trace/test_sampling.py
--- a/opentelemetry-sdk/tests/trace/test_sampling.py
+++ b/opentelemetry-sdk/tests/trace/test_sampling.py
@@ -88,7 +88,7 @@ def _create_parent_span(
         )
 
     def test_always_on(self):
-        trace_state = trace.TraceState({"key": "value"})
+        trace_state = trace.TraceState([("key", "value")])
         test_data = (TO_DEFAULT, TO_SAMPLED, None)
 
         for trace_flags in test_data:
@@ -109,7 +109,7 @@ def test_always_on(self):
                 self.assertEqual(sample_result.trace_state, trace_state)
 
     def test_always_off(self):
-        trace_state = trace.TraceState({"key": "value"})
+        trace_state = trace.TraceState([("key", "value")])
         test_data = (TO_DEFAULT, TO_SAMPLED, None)
         for trace_flags in test_data:
             with self.subTest(trace_flags=trace_flags):
@@ -126,7 +126,7 @@ def test_always_off(self):
                 self.assertEqual(sample_result.trace_state, trace_state)
 
     def test_default_on(self):
-        trace_state = trace.TraceState({"key": "value"})
+        trace_state = trace.TraceState([("key", "value")])
         context = self._create_parent(trace_flags=TO_DEFAULT)
         sample_result = sampling.DEFAULT_ON.should_sample(
             context,
@@ -163,7 +163,7 @@ def test_default_on(self):
         self.assertEqual(sample_result.trace_state, trace_state)
 
     def test_default_off(self):
-        trace_state = trace.TraceState({"key": "value"})
+        trace_state = trace.TraceState([("key", "value")])
         context = self._create_parent(trace_flags=TO_DEFAULT)
         sample_result = sampling.DEFAULT_OFF.should_sample(
             context,
@@ -200,7 +200,7 @@ def test_default_off(self):
         self.assertEqual(default_off.trace_state, trace_state)
 
     def test_probability_sampler(self):
-        trace_state = trace.TraceState({"key": "value"})
+        trace_state = trace.TraceState([("key", "value")])
         sampler = sampling.TraceIdRatioBased(0.5)
 
         # Check that we sample based on the trace ID if the parent context is
@@ -313,7 +313,7 @@ def test_probability_sampler_limits(self):
 
     # pylint:disable=too-many-statements
     def exec_parent_based(self, parent_sampling_context):
-        trace_state = trace.TraceState({"key": "value"})
+        trace_state = trace.TraceState([("key", "value")])
         sampler = sampling.ParentBased(sampling.ALWAYS_ON)
         # Check that the sampling decision matches the parent context if given
         with parent_sampling_context(
diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py
--- a/opentelemetry-sdk/tests/trace/test_trace.py
+++ b/opentelemetry-sdk/tests/trace/test_trace.py
@@ -1219,7 +1219,7 @@ def test_to_json(self):
     "context": {
         "trace_id": "0x000000000000000000000000deadbeef",
         "span_id": "0x00000000deadbef0",
-        "trace_state": "{}"
+        "trace_state": "[]"
     },
     "kind": "SpanKind.INTERNAL",
     "parent_id": null,
@@ -1236,7 +1236,7 @@ def test_to_json(self):
         )
         self.assertEqual(
             span.to_json(indent=None),
-            '{"name": "span-name", "context": {"trace_id": "0x000000000000000000000000deadbeef", "span_id": "0x00000000deadbef0", "trace_state": "{}"}, "kind": "SpanKind.INTERNAL", "parent_id": null, "start_time": null, "end_time": null, "status": {"status_code": "UNSET"}, "attributes": {}, "events": [], "links": [], "resource": {}}',
+            '{"name": "span-name", "context": {"trace_id": "0x000000000000000000000000deadbeef", "span_id": "0x00000000deadbef0", "trace_state": "[]"}, "kind": "SpanKind.INTERNAL", "parent_id": null, "start_time": null, "end_time": null, "status": {"status_code": "UNSET"}, "attributes": {}, "events": [], "links": [], "resource": {}}',
         )
 
     def test_attributes_to_json(self):
@@ -1253,7 +1253,7 @@ def test_attributes_to_json(self):
         date_str = ns_to_iso_str(123)
         self.assertEqual(
             span.to_json(indent=None),
-            '{"name": "span-name", "context": {"trace_id": "0x000000000000000000000000deadbeef", "span_id": "0x00000000deadbef0", "trace_state": "{}"}, "kind": "SpanKind.INTERNAL", "parent_id": null, "start_time": null, "end_time": null, "status": {"status_code": "UNSET"}, "attributes": {"key": "value"}, "events": [{"name": "event", "timestamp": "'
+            '{"name": "span-name", "context": {"trace_id": "0x000000000000000000000000deadbeef", "span_id": "0x00000000deadbef0", "trace_state": "[]"}, "kind": "SpanKind.INTERNAL", "parent_id": null, "start_time": null, "end_time": null, "status": {"status_code": "UNSET"}, "attributes": {"key": "value"}, "events": [{"name": "event", "timestamp": "'
             + date_str
             + '", "attributes": {"key2": "value2"}}], "links": [], "resource": {}}',
         )