diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dafc64d07f..354b52a512 100644 --- 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/CHANGELOG.md b/CHANGELOG.md index 5e8290dbe2..df7e779684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#1535](https://github.com/open-telemetry/opentelemetry-python/pull/1535)) - `opentelemetry-sdk` Remove rate property setter from TraceIdRatioBasedSampler ([#1536](https://github.com/open-telemetry/opentelemetry-python/pull/1536)) +- Fix TraceState to adhere to specs + ([#1502](https://github.com/open-telemetry/opentelemetry-python/pull/1502)) ### Removed - `opentelemetry-api` Remove ThreadLocalRuntimeContext since python3.4 is not supported. diff --git a/exporter/opentelemetry-exporter-opencensus/tests/test_otcollector_trace_exporter.py b/exporter/opentelemetry-exporter-opencensus/tests/test_otcollector_trace_exporter.py index b61cf333cc..97b1a37912 100644 --- 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/src/opentelemetry/trace/propagation/tracecontext.py b/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py index 146ba47772..c18cde9ee6 100644 --- 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 index 507b051368..baeed76202 100644 --- 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 index 0000000000..c352c8caa2 --- /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) diff --git a/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py b/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py index 1e5c820243..0b20fbff4b 100644 --- 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 index 0000000000..6665dd612d --- /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 index 0d026de01d..e2b20a5c88 100644 --- 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 index ebe538d56d..df96703d9a 100644 --- 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": {}}', )