Skip to content

Commit 92353c0

Browse files
authored
Implementing W3C TraceContext (fixes #116) (#180)
* Implementing TraceContext (fixes #116) This introduces a w3c TraceContext propagator, primarily inspired by opencensus.
1 parent d0946cd commit 92353c0

File tree

5 files changed

+346
-8
lines changed

5 files changed

+346
-8
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919

2020
_T = typing.TypeVar("_T")
2121

22-
Setter = typing.Callable[[typing.Type[_T], str, str], None]
23-
Getter = typing.Callable[[typing.Type[_T], str], typing.List[str]]
22+
Setter = typing.Callable[[_T, str, str], None]
23+
Getter = typing.Callable[[_T, str], typing.List[str]]
2424

2525

2626
class HTTPTextFormat(abc.ABC):

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

Lines changed: 129 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,151 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
#
15-
15+
import re
1616
import typing
1717

1818
import opentelemetry.trace as trace
1919
from opentelemetry.context.propagation import httptextformat
2020

2121
_T = typing.TypeVar("_T")
2222

23+
# Keys and values are strings of up to 256 printable US-ASCII characters.
24+
# Implementations should conform to the the `W3C Trace Context - Tracestate`_
25+
# spec, which describes additional restrictions on valid field values.
26+
#
27+
# .. _W3C Trace Context - Tracestate:
28+
# https://www.w3.org/TR/trace-context/#tracestate-field
29+
30+
31+
_KEY_WITHOUT_VENDOR_FORMAT = r"[a-z][_0-9a-z\-\*\/]{0,255}"
32+
_KEY_WITH_VENDOR_FORMAT = (
33+
r"[a-z][_0-9a-z\-\*\/]{0,240}@[a-z][_0-9a-z\-\*\/]{0,13}"
34+
)
35+
36+
_KEY_FORMAT = _KEY_WITHOUT_VENDOR_FORMAT + "|" + _KEY_WITH_VENDOR_FORMAT
37+
_VALUE_FORMAT = (
38+
r"[\x20-\x2b\x2d-\x3c\x3e-\x7e]{0,255}[\x21-\x2b\x2d-\x3c\x3e-\x7e]"
39+
)
40+
41+
_DELIMITER_FORMAT = "[ \t]*,[ \t]*"
42+
_MEMBER_FORMAT = "({})(=)({})".format(_KEY_FORMAT, _VALUE_FORMAT)
43+
44+
_DELIMITER_FORMAT_RE = re.compile(_DELIMITER_FORMAT)
45+
_MEMBER_FORMAT_RE = re.compile(_MEMBER_FORMAT)
46+
2347

2448
class TraceContextHTTPTextFormat(httptextformat.HTTPTextFormat):
25-
"""TODO: extracts and injects using w3c TraceContext's headers.
49+
"""Extracts and injects using w3c TraceContext's headers.
2650
"""
2751

52+
_TRACEPARENT_HEADER_NAME = "traceparent"
53+
_TRACESTATE_HEADER_NAME = "tracestate"
54+
_TRACEPARENT_HEADER_FORMAT = (
55+
"^[ \t]*([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})"
56+
+ "(-.*)?[ \t]*$"
57+
)
58+
_TRACEPARENT_HEADER_FORMAT_RE = re.compile(_TRACEPARENT_HEADER_FORMAT)
59+
60+
@classmethod
2861
def extract(
29-
self, _get_from_carrier: httptextformat.Getter[_T], _carrier: _T
62+
cls, get_from_carrier: httptextformat.Getter[_T], carrier: _T
3063
) -> trace.SpanContext:
31-
return trace.INVALID_SPAN_CONTEXT
64+
"""Extracts a valid SpanContext from the carrier.
65+
"""
66+
header = get_from_carrier(carrier, cls._TRACEPARENT_HEADER_NAME)
67+
68+
if not header:
69+
return trace.INVALID_SPAN_CONTEXT
70+
71+
match = re.search(cls._TRACEPARENT_HEADER_FORMAT_RE, header[0])
72+
if not match:
73+
return trace.INVALID_SPAN_CONTEXT
74+
75+
version = match.group(1)
76+
trace_id = match.group(2)
77+
span_id = match.group(3)
78+
trace_options = match.group(4)
79+
80+
if trace_id == "0" * 32 or span_id == "0" * 16:
81+
return trace.INVALID_SPAN_CONTEXT
82+
83+
if version == "00":
84+
if match.group(5):
85+
return trace.INVALID_SPAN_CONTEXT
86+
if version == "ff":
87+
return trace.INVALID_SPAN_CONTEXT
3288

89+
tracestate = trace.TraceState()
90+
for tracestate_header in get_from_carrier(
91+
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+
)
98+
99+
span_context = trace.SpanContext(
100+
trace_id=int(trace_id, 16),
101+
span_id=int(span_id, 16),
102+
trace_options=trace.TraceOptions(trace_options),
103+
trace_state=tracestate,
104+
)
105+
106+
return span_context
107+
108+
@classmethod
33109
def inject(
34-
self,
110+
cls,
35111
context: trace.SpanContext,
36112
set_in_carrier: httptextformat.Setter[_T],
37113
carrier: _T,
38114
) -> None:
39-
pass
115+
if context == trace.INVALID_SPAN_CONTEXT:
116+
return
117+
traceparent_string = "00-{:032x}-{:016x}-{:02x}".format(
118+
context.trace_id, context.span_id, context.trace_options
119+
)
120+
set_in_carrier(
121+
carrier, cls._TRACEPARENT_HEADER_NAME, traceparent_string
122+
)
123+
if context.trace_state:
124+
tracestate_string = _format_tracestate(context.trace_state)
125+
set_in_carrier(
126+
carrier, cls._TRACESTATE_HEADER_NAME, tracestate_string
127+
)
128+
129+
130+
def _parse_tracestate(string: str) -> trace.TraceState:
131+
"""Parse a w3c tracestate header into a TraceState.
132+
133+
Args:
134+
string: the value of the tracestate header.
135+
136+
Returns:
137+
A valid TraceState that contains values extracted from
138+
the tracestate header.
139+
"""
140+
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
149+
return tracestate
150+
151+
152+
def _format_tracestate(tracestate: trace.TraceState) -> str:
153+
"""Parse a w3c tracestate header into a TraceState.
154+
155+
Args:
156+
tracestate: the tracestate header to write
157+
158+
Returns:
159+
A string that adheres to the w3c tracestate
160+
header format.
161+
"""
162+
return ",".join(key + "=" + value for key, value in tracestate.items())

opentelemetry-api/tests/context/__init__.py

Whitespace-only changes.

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

Whitespace-only changes.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Copyright 2019, OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import typing
16+
import unittest
17+
18+
from opentelemetry import trace
19+
from opentelemetry.context.propagation import tracecontexthttptextformat
20+
21+
FORMAT = tracecontexthttptextformat.TraceContextHTTPTextFormat()
22+
23+
24+
def get_as_list(
25+
dict_object: typing.Dict[str, str], key: str
26+
) -> typing.List[str]:
27+
value = dict_object.get(key)
28+
return [value] if value is not None else []
29+
30+
31+
class TestTraceContextFormat(unittest.TestCase):
32+
TRACE_ID = int("12345678901234567890123456789012", 16) # type:int
33+
SPAN_ID = int("1234567890123456", 16) # type:int
34+
35+
def test_no_traceparent_header(self):
36+
"""When tracecontext headers are not present, a new SpanContext
37+
should be created.
38+
39+
RFC 4.2.2:
40+
41+
If no traceparent header is received, the vendor creates a new trace-id and parent-id that represents the current request.
42+
"""
43+
output = {} # type:typing.Dict[str, str]
44+
span_context = FORMAT.extract(get_as_list, output)
45+
self.assertTrue(isinstance(span_context, trace.SpanContext))
46+
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+
101+
def test_headers_with_tracestate(self):
102+
"""When there is a traceparent and tracestate header, data from
103+
both should be addded to the SpanContext.
104+
"""
105+
traceparent_value = "00-{trace_id}-{span_id}-00".format(
106+
trace_id=format(self.TRACE_ID, "032x"),
107+
span_id=format(self.SPAN_ID, "016x"),
108+
)
109+
tracestate_value = "foo=1,bar=2,baz=3"
110+
span_context = FORMAT.extract(
111+
get_as_list,
112+
{"traceparent": traceparent_value, "tracestate": tracestate_value},
113+
)
114+
self.assertEqual(span_context.trace_id, self.TRACE_ID)
115+
self.assertEqual(span_context.span_id, self.SPAN_ID)
116+
self.assertEqual(
117+
span_context.trace_state, {"foo": "1", "bar": "2", "baz": "3"}
118+
)
119+
120+
output = {} # type:typing.Dict[str, str]
121+
FORMAT.inject(span_context, dict.__setitem__, output)
122+
self.assertEqual(output["traceparent"], traceparent_value)
123+
for pair in ["foo=1", "bar=2", "baz=3"]:
124+
self.assertIn(pair, output["tracestate"])
125+
self.assertEqual(output["tracestate"].count(","), 2)
126+
127+
def test_invalid_trace_id(self):
128+
"""If the trace id is invalid, we must ignore the full traceparent header.
129+
130+
Also ignore any tracestate.
131+
132+
RFC 3.2.2.3
133+
134+
If the trace-id value is invalid (for example if it contains non-allowed characters or all
135+
zeros), vendors MUST ignore the traceparent.
136+
137+
RFC 3.3
138+
139+
If the vendor failed to parse traceparent, it MUST NOT attempt to parse tracestate.
140+
Note that the opposite is not true: failure to parse tracestate MUST NOT affect the parsing of traceparent.
141+
"""
142+
span_context = FORMAT.extract(
143+
get_as_list,
144+
{
145+
"traceparent": "00-00000000000000000000000000000000-1234567890123456-00",
146+
"tracestate": "foo=1,bar=2,foo=3",
147+
},
148+
)
149+
self.assertEqual(span_context, trace.INVALID_SPAN_CONTEXT)
150+
151+
def test_invalid_parent_id(self):
152+
"""If the parent id is invalid, we must ignore the full traceparent header.
153+
154+
Also ignore any tracestate.
155+
156+
RFC 3.2.2.3
157+
158+
Vendors MUST ignore the traceparent when the parent-id is invalid (for example,
159+
if it contains non-lowercase hex characters).
160+
161+
RFC 3.3
162+
163+
If the vendor failed to parse traceparent, it MUST NOT attempt to parse tracestate.
164+
Note that the opposite is not true: failure to parse tracestate MUST NOT affect the parsing of traceparent.
165+
"""
166+
span_context = FORMAT.extract(
167+
get_as_list,
168+
{
169+
"traceparent": "00-00000000000000000000000000000000-0000000000000000-00",
170+
"tracestate": "foo=1,bar=2,foo=3",
171+
},
172+
)
173+
self.assertEqual(span_context, trace.INVALID_SPAN_CONTEXT)
174+
175+
def test_no_send_empty_tracestate(self):
176+
"""If the tracestate is empty, do not set the header.
177+
178+
RFC 3.3.1.1
179+
180+
Empty and whitespace-only list members are allowed. Vendors MUST accept empty
181+
tracestate headers but SHOULD avoid sending them.
182+
"""
183+
output = {} # type:typing.Dict[str, str]
184+
FORMAT.inject(
185+
trace.SpanContext(self.TRACE_ID, self.SPAN_ID),
186+
dict.__setitem__,
187+
output,
188+
)
189+
self.assertTrue("traceparent" in output)
190+
self.assertFalse("tracestate" in output)
191+
192+
def test_format_not_supported(self):
193+
"""If the traceparent does not adhere to the supported format, discard it and
194+
create a new tracecontext.
195+
196+
RFC 4.3
197+
198+
If the version cannot be parsed, the vendor creates a new traceparent header and
199+
deletes tracestate.
200+
"""
201+
span_context = FORMAT.extract(
202+
get_as_list,
203+
{
204+
"traceparent": "00-12345678901234567890123456789012-1234567890123456-00-residue",
205+
"tracestate": "foo=1,bar=2,foo=3",
206+
},
207+
)
208+
self.assertEqual(span_context, trace.INVALID_SPAN_CONTEXT)
209+
210+
def test_propagate_invalid_context(self):
211+
"""Do not propagate invalid trace context.
212+
"""
213+
output = {} # type:typing.Dict[str, str]
214+
FORMAT.inject(trace.INVALID_SPAN_CONTEXT, dict.__setitem__, output)
215+
self.assertFalse("traceparent" in output)

0 commit comments

Comments
 (0)