Skip to content

Commit 602d42a

Browse files
authored
Adding w3c tracecontext integration test (#228)
Verifying that our tracecontext is compliant with the w3c tracecontext reference is valuable. Adding a tox command to verify that the TraceContext propagator adheres to the w3c spec. The tracecontexthttptextformat is now completely compliant with the w3c tracecontext test suite.
1 parent 14ce513 commit 602d42a

File tree

16 files changed

+224
-99
lines changed

16 files changed

+224
-99
lines changed

.flake8

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ exclude =
1010
.svn
1111
.tox
1212
CVS
13+
target
1314
__pycache__
1415
ext/opentelemetry-ext-jaeger/src/opentelemetry/ext/jaeger/gen/
1516
ext/opentelemetry-ext-jaeger/build/*

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,4 @@ _build/
5353

5454
# mypy
5555
.mypy_cache/
56+
target

.isort.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ line_length=79
1212
; )
1313
; docs: https://github.com/timothycrosley/isort#multi-line-output-modes
1414
multi_line_output=3
15+
skip=target
1516
skip_glob=ext/opentelemetry-ext-jaeger/src/opentelemetry/ext/jaeger/gen/*

ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ def test_request_attributes_with_partial_raw_uri(self):
210210
self.validate_url("http://127.0.0.1/#top")
211211

212212
def test_request_attributes_with_partial_raw_uri_and_nonstandard_port(
213-
self
213+
self,
214214
):
215215
self.environ["RAW_URI"] = "/?"
216216
del self.environ["HTTP_HOST"]

opentelemetry-api/src/opentelemetry/context/propagation/tracecontexthttptextformat.py

+35-19
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@
3939
)
4040

4141
_DELIMITER_FORMAT = "[ \t]*,[ \t]*"
42-
_MEMBER_FORMAT = "({})(=)({})".format(_KEY_FORMAT, _VALUE_FORMAT)
42+
_MEMBER_FORMAT = "({})(=)({})[ \t]*".format(_KEY_FORMAT, _VALUE_FORMAT)
4343

4444
_DELIMITER_FORMAT_RE = re.compile(_DELIMITER_FORMAT)
4545
_MEMBER_FORMAT_RE = re.compile(_MEMBER_FORMAT)
4646

47+
_TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS = 32
48+
4749

4850
class TraceContextHTTPTextFormat(httptextformat.HTTPTextFormat):
4951
"""Extracts and injects using w3c TraceContext's headers.
@@ -86,15 +88,10 @@ def extract(
8688
if version == "ff":
8789
return trace.INVALID_SPAN_CONTEXT
8890

89-
tracestate = trace.TraceState()
90-
for tracestate_header in get_from_carrier(
91+
tracestate_headers = get_from_carrier(
9192
carrier, cls._TRACESTATE_HEADER_NAME
92-
):
93-
# typing.Dict's update is not recognized by pylint:
94-
# https://github.com/PyCQA/pylint/issues/2420
95-
tracestate.update( # pylint:disable=E1101
96-
_parse_tracestate(tracestate_header)
97-
)
93+
)
94+
tracestate = _parse_tracestate(tracestate_headers)
9895

9996
span_context = trace.SpanContext(
10097
trace_id=int(trace_id, 16),
@@ -127,25 +124,44 @@ def inject(
127124
)
128125

129126

130-
def _parse_tracestate(string: str) -> trace.TraceState:
131-
"""Parse a w3c tracestate header into a TraceState.
127+
def _parse_tracestate(header_list: typing.List[str]) -> trace.TraceState:
128+
"""Parse one or more w3c tracestate header into a TraceState.
132129
133130
Args:
134131
string: the value of the tracestate header.
135132
136133
Returns:
137134
A valid TraceState that contains values extracted from
138135
the tracestate header.
136+
137+
If the format of one headers is illegal, all values will
138+
be discarded and an empty tracestate will be returned.
139+
140+
If the number of keys is beyond the maximum, all values
141+
will be discarded and an empty tracestate will be returned.
139142
"""
140143
tracestate = trace.TraceState()
141-
for member in re.split(_DELIMITER_FORMAT_RE, string):
142-
match = _MEMBER_FORMAT_RE.match(member)
143-
if not match:
144-
raise ValueError("illegal key-value format %r" % (member))
145-
key, _eq, value = match.groups()
146-
# typing.Dict's update is not recognized by pylint:
147-
# https://github.com/PyCQA/pylint/issues/2420
148-
tracestate[key] = value # pylint:disable=E1137
144+
value_count = 0
145+
for header in header_list:
146+
for member in re.split(_DELIMITER_FORMAT_RE, header):
147+
# empty members are valid, but no need to process further.
148+
if not member:
149+
continue
150+
match = _MEMBER_FORMAT_RE.fullmatch(member)
151+
if not match:
152+
# TODO: log this?
153+
return trace.TraceState()
154+
key, _eq, value = match.groups()
155+
if key in tracestate: # pylint:disable=E1135
156+
# duplicate keys are not legal in
157+
# the header, so we will remove
158+
return trace.TraceState()
159+
# typing.Dict's update is not recognized by pylint:
160+
# https://github.com/PyCQA/pylint/issues/2420
161+
tracestate[key] = value # pylint:disable=E1137
162+
value_count += 1
163+
if value_count > _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS:
164+
return trace.TraceState()
149165
return tracestate
150166

151167

opentelemetry-api/src/opentelemetry/distributedcontext/propagation/binaryformat.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def to_bytes(context: DistributedContext) -> bytes:
4444
@staticmethod
4545
@abc.abstractmethod
4646
def from_bytes(
47-
byte_representation: bytes
47+
byte_representation: bytes,
4848
) -> typing.Optional[DistributedContext]:
4949
"""Return a DistributedContext that was represented by bytes.
5050

opentelemetry-api/src/opentelemetry/propagators/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def get_global_httptextformat() -> httptextformat.HTTPTextFormat:
7878

7979

8080
def set_global_httptextformat(
81-
http_text_format: httptextformat.HTTPTextFormat
81+
http_text_format: httptextformat.HTTPTextFormat,
8282
) -> None:
8383
global _HTTP_TEXT_FORMAT # pylint:disable=global-statement
8484
_HTTP_TEXT_FORMAT = http_text_format

opentelemetry-api/src/opentelemetry/trace/__init__.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -338,10 +338,11 @@ def __init__(
338338
self.trace_state = trace_state
339339

340340
def __repr__(self) -> str:
341-
return "{}(trace_id={}, span_id={})".format(
341+
return "{}(trace_id={}, span_id={}, trace_state={!r})".format(
342342
type(self).__name__,
343343
format_trace_id(self.trace_id),
344344
format_span_id(self.span_id),
345+
self.trace_state,
345346
)
346347

347348
def is_valid(self) -> bool:
@@ -589,7 +590,7 @@ def tracer() -> Tracer:
589590

590591

591592
def set_preferred_tracer_implementation(
592-
factory: ImplementationFactory
593+
factory: ImplementationFactory,
593594
) -> None:
594595
"""Set the factory to be used to create the tracer.
595596

opentelemetry-api/src/opentelemetry/util/loader.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ def _load_impl(
173173

174174

175175
def set_preferred_default_implementation(
176-
implementation_factory: _UntrustedImplFactory[_T]
176+
implementation_factory: _UntrustedImplFactory[_T],
177177
) -> None:
178178
"""Sets a factory function that may be called for any implementation
179179
object. See the :ref:`module docs <loader-factory>` for more details."""

opentelemetry-api/tests/context/propagation/test_tracecontexthttptextformat.py

+49-67
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222

2323

2424
def get_as_list(
25-
dict_object: typing.Dict[str, str], key: str
25+
dict_object: typing.Dict[str, typing.List[str]], key: str
2626
) -> typing.List[str]:
2727
value = dict_object.get(key)
28-
return [value] if value is not None else []
28+
return value if value is not None else []
2929

3030

3131
class TestTraceContextFormat(unittest.TestCase):
@@ -40,64 +40,10 @@ def test_no_traceparent_header(self):
4040
4141
If no traceparent header is received, the vendor creates a new trace-id and parent-id that represents the current request.
4242
"""
43-
output = {} # type:typing.Dict[str, str]
43+
output = {} # type:typing.Dict[str, typing.List[str]]
4444
span_context = FORMAT.extract(get_as_list, output)
4545
self.assertTrue(isinstance(span_context, trace.SpanContext))
4646

47-
def test_from_headers_tracestate_entry_limit(self):
48-
"""If more than 33 entries are passed, allow them.
49-
50-
We are explicitly choosing not to limit the list members
51-
as outlined in RFC 3.3.1.1
52-
53-
RFC 3.3.1.1
54-
55-
There can be a maximum of 32 list-members in a list.
56-
"""
57-
58-
span_context = FORMAT.extract(
59-
get_as_list,
60-
{
61-
"traceparent": "00-12345678901234567890123456789012-1234567890123456-00",
62-
"tracestate": ",".join(
63-
[
64-
"a00=0,a01=1,a02=2,a03=3,a04=4,a05=5,a06=6,a07=7,a08=8,a09=9",
65-
"b00=0,b01=1,b02=2,b03=3,b04=4,b05=5,b06=6,b07=7,b08=8,b09=9",
66-
"c00=0,c01=1,c02=2,c03=3,c04=4,c05=5,c06=6,c07=7,c08=8,c09=9",
67-
"d00=0,d01=1,d02=2",
68-
]
69-
),
70-
},
71-
)
72-
self.assertEqual(len(span_context.trace_state), 33)
73-
74-
def test_from_headers_tracestate_duplicated_keys(self):
75-
"""If a duplicate tracestate header is present, the most recent entry
76-
is used.
77-
78-
RFC 3.3.1.4
79-
80-
Only one entry per key is allowed because the entry represents that last position in the trace.
81-
Hence vendors must overwrite their entry upon reentry to their tracing system.
82-
83-
For example, if a vendor name is Congo and a trace started in their system and then went through
84-
a system named Rojo and later returned to Congo, the tracestate value would not be:
85-
86-
congo=congosFirstPosition,rojo=rojosFirstPosition,congo=congosSecondPosition
87-
88-
Instead, the entry would be rewritten to only include the most recent position:
89-
90-
congo=congosSecondPosition,rojo=rojosFirstPosition
91-
"""
92-
span_context = FORMAT.extract(
93-
get_as_list,
94-
{
95-
"traceparent": "00-12345678901234567890123456789012-1234567890123456-00",
96-
"tracestate": "foo=1,bar=2,foo=3",
97-
},
98-
)
99-
self.assertEqual(span_context.trace_state, {"foo": "3", "bar": "2"})
100-
10147
def test_headers_with_tracestate(self):
10248
"""When there is a traceparent and tracestate header, data from
10349
both should be addded to the SpanContext.
@@ -109,7 +55,10 @@ def test_headers_with_tracestate(self):
10955
tracestate_value = "foo=1,bar=2,baz=3"
11056
span_context = FORMAT.extract(
11157
get_as_list,
112-
{"traceparent": traceparent_value, "tracestate": tracestate_value},
58+
{
59+
"traceparent": [traceparent_value],
60+
"tracestate": [tracestate_value],
61+
},
11362
)
11463
self.assertEqual(span_context.trace_id, self.TRACE_ID)
11564
self.assertEqual(span_context.span_id, self.SPAN_ID)
@@ -125,7 +74,8 @@ def test_headers_with_tracestate(self):
12574
self.assertEqual(output["tracestate"].count(","), 2)
12675

12776
def test_invalid_trace_id(self):
128-
"""If the trace id is invalid, we must ignore the full traceparent header.
77+
"""If the trace id is invalid, we must ignore the full traceparent header,
78+
and return a random, valid trace.
12979
13080
Also ignore any tracestate.
13181
@@ -142,8 +92,10 @@ def test_invalid_trace_id(self):
14292
span_context = FORMAT.extract(
14393
get_as_list,
14494
{
145-
"traceparent": "00-00000000000000000000000000000000-1234567890123456-00",
146-
"tracestate": "foo=1,bar=2,foo=3",
95+
"traceparent": [
96+
"00-00000000000000000000000000000000-1234567890123456-00"
97+
],
98+
"tracestate": ["foo=1,bar=2,foo=3"],
14799
},
148100
)
149101
self.assertEqual(span_context, trace.INVALID_SPAN_CONTEXT)
@@ -166,8 +118,10 @@ def test_invalid_parent_id(self):
166118
span_context = FORMAT.extract(
167119
get_as_list,
168120
{
169-
"traceparent": "00-00000000000000000000000000000000-0000000000000000-00",
170-
"tracestate": "foo=1,bar=2,foo=3",
121+
"traceparent": [
122+
"00-00000000000000000000000000000000-0000000000000000-00"
123+
],
124+
"tracestate": ["foo=1,bar=2,foo=3"],
171125
},
172126
)
173127
self.assertEqual(span_context, trace.INVALID_SPAN_CONTEXT)
@@ -195,14 +149,15 @@ def test_format_not_supported(self):
195149
196150
RFC 4.3
197151
198-
If the version cannot be parsed, the vendor creates a new traceparent header and
199-
deletes tracestate.
152+
If the version cannot be parsed, return an invalid trace header.
200153
"""
201154
span_context = FORMAT.extract(
202155
get_as_list,
203156
{
204-
"traceparent": "00-12345678901234567890123456789012-1234567890123456-00-residue",
205-
"tracestate": "foo=1,bar=2,foo=3",
157+
"traceparent": [
158+
"00-12345678901234567890123456789012-1234567890123456-00-residue"
159+
],
160+
"tracestate": ["foo=1,bar=2,foo=3"],
206161
},
207162
)
208163
self.assertEqual(span_context, trace.INVALID_SPAN_CONTEXT)
@@ -213,3 +168,30 @@ def test_propagate_invalid_context(self):
213168
output = {} # type:typing.Dict[str, str]
214169
FORMAT.inject(trace.INVALID_SPAN_CONTEXT, dict.__setitem__, output)
215170
self.assertFalse("traceparent" in output)
171+
172+
def test_tracestate_empty_header(self):
173+
"""Test tracestate with an additional empty header (should be ignored)"""
174+
span_context = FORMAT.extract(
175+
get_as_list,
176+
{
177+
"traceparent": [
178+
"00-12345678901234567890123456789012-1234567890123456-00"
179+
],
180+
"tracestate": ["foo=1", ""],
181+
},
182+
)
183+
self.assertEqual(span_context.trace_state["foo"], "1")
184+
185+
def test_tracestate_header_with_trailing_comma(self):
186+
"""Do not propagate invalid trace context.
187+
"""
188+
span_context = FORMAT.extract(
189+
get_as_list,
190+
{
191+
"traceparent": [
192+
"00-12345678901234567890123456789012-1234567890123456-00"
193+
],
194+
"tracestate": ["foo=1,"],
195+
},
196+
)
197+
self.assertEqual(span_context.trace_state["foo"], "1")

opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py

-2
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,6 @@ def set_status(self, status: trace_api.Status) -> None:
301301

302302
def generate_span_id() -> int:
303303
"""Get a new random span ID.
304-
305304
Returns:
306305
A random 64-bit int for use as a span ID
307306
"""
@@ -310,7 +309,6 @@ def generate_span_id() -> int:
310309

311310
def generate_trace_id() -> int:
312311
"""Get a new random trace ID.
313-
314312
Returns:
315313
A random 128-bit int for use as a trace ID
316314
"""

opentelemetry-sdk/tests/context/propagation/test_b3_format.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import opentelemetry.sdk.context.propagation.b3_format as b3_format
1818
import opentelemetry.sdk.trace as trace
19-
import opentelemetry.trace as api_trace
19+
import opentelemetry.trace as trace_api
2020

2121
FORMAT = b3_format.B3Format()
2222

@@ -163,8 +163,8 @@ def test_invalid_single_header(self):
163163
"""
164164
carrier = {FORMAT.SINGLE_HEADER_KEY: "0-1-2-3-4-5-6-7"}
165165
span_context = FORMAT.extract(get_as_list, carrier)
166-
self.assertEqual(span_context.trace_id, api_trace.INVALID_TRACE_ID)
167-
self.assertEqual(span_context.span_id, api_trace.INVALID_SPAN_ID)
166+
self.assertEqual(span_context.trace_id, trace_api.INVALID_TRACE_ID)
167+
self.assertEqual(span_context.span_id, trace_api.INVALID_SPAN_ID)
168168

169169
def test_missing_trace_id(self):
170170
"""If a trace id is missing, populate an invalid trace id."""
@@ -173,7 +173,7 @@ def test_missing_trace_id(self):
173173
FORMAT.FLAGS_KEY: "1",
174174
}
175175
span_context = FORMAT.extract(get_as_list, carrier)
176-
self.assertEqual(span_context.trace_id, api_trace.INVALID_TRACE_ID)
176+
self.assertEqual(span_context.trace_id, trace_api.INVALID_TRACE_ID)
177177

178178
def test_missing_span_id(self):
179179
"""If a trace id is missing, populate an invalid trace id."""
@@ -182,4 +182,4 @@ def test_missing_span_id(self):
182182
FORMAT.FLAGS_KEY: "1",
183183
}
184184
span_context = FORMAT.extract(get_as_list, carrier)
185-
self.assertEqual(span_context.span_id, api_trace.INVALID_SPAN_ID)
185+
self.assertEqual(span_context.span_id, trace_api.INVALID_SPAN_ID)

0 commit comments

Comments
 (0)