Skip to content

Commit 9cf7425

Browse files
committed
Add support for Trace Context headers
Closes #79
1 parent 719e21a commit 9cf7425

File tree

5 files changed

+440
-2
lines changed

5 files changed

+440
-2
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ dist/*
1212
.idea
1313
.pytest_cache/
1414
.python-version
15+
tests/trace-context/trace-context

lightstep/trace_context.py

+311
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
from re import escape, compile as re_compile
2+
from random import getrandbits
3+
from warnings import warn
4+
from logging import getLogger
5+
from collections import OrderedDict
6+
7+
from basictracer.propagator import Propagator
8+
from basictracer.context import SpanContext
9+
10+
_LOG = getLogger(__name__)
11+
12+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#header-name
13+
_TRACEPARENT = "traceparent"
14+
_TRACESTATE = "tracestate"
15+
16+
# https://www.w3.org/TR/trace-context/#a-traceparent-is-received
17+
_HEXDIGITLOWER = r"[abcdef\d]"
18+
19+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#version
20+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#version-format
21+
_VERSION = re_compile(r"\s*(?P<version>{0}{{2}})-".format(_HEXDIGITLOWER))
22+
23+
# https://www.w3.org/TR/trace-context/#trace-id
24+
# https://www.w3.org/TR/trace-context/#parent-id
25+
# https://www.w3.org/TR/trace-context/#trace-flags
26+
_REMAINDER_MATCH_RE = (
27+
r"{0}{{2}}-"
28+
r"(?P<trace_id>{0}{{32}})-"
29+
r"(?P<parent_id>{0}{{16}})-"
30+
r"(?P<trace_flags>{0}{{2}})"
31+
).format(_HEXDIGITLOWER)
32+
33+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#versioning-of-traceparent
34+
_00_VERSION_REMAINDER = re_compile("".join([_REMAINDER_MATCH_RE, r"\s*$"]))
35+
_FUTURE_VERSION_REMAINDER = re_compile(
36+
"".join([_REMAINDER_MATCH_RE, r"(\s*$|-[^\s]+)"])
37+
)
38+
39+
_KEY_VALUE = re_compile(
40+
r"^\s*({0})=({1})\s*$".format(
41+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#tracestate-header
42+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#key
43+
(
44+
r"[{0}][-{0}\d_*/]{{0,255}}|"
45+
r"[{0}\d][-{0}\d_*/]{{0,240}}@[{0}][-{0}\d_*/]{{0,13}}"
46+
).format("a-z"),
47+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#value
48+
r"[ {0}]{{0,255}}[{0}]".format(
49+
escape(
50+
r"".join(
51+
[
52+
chr(character) for character in range(33, 127)
53+
if character not in [44, 61]
54+
]
55+
)
56+
)
57+
)
58+
)
59+
)
60+
61+
_BLANK = re_compile(r"^\s*$")
62+
63+
64+
class TraceContextPropagator(Propagator):
65+
"""
66+
Propagator for the W3C Trace Context format
67+
68+
The latest version of this Candidate Recommendation can be found here:
69+
https://www.w3.org/TR/trace-context/
70+
71+
This is an implementation for this specific version:
72+
https://www.w3.org/TR/2019/CR-trace-context-20190813/
73+
74+
The Candidate Recommendation will be referred to as "the document" in the
75+
comments of this implementation. This implementation adds comments which
76+
are URLs that point to the specific part of the document that explain the
77+
rationale of the code that follows the comment.
78+
79+
There is a set of test cases defined for testing any implementation of the
80+
document. This set of test cases can be found here:
81+
https://github.com/w3c/trace-context/tree/master/test
82+
83+
This set of test cases will be referred to as "the test suite" in the
84+
comments of this implementation.
85+
"""
86+
87+
def inject(self, span_context, carrier):
88+
89+
carrier[_TRACEPARENT] = "00-{}-{}-{}".format(
90+
format(span_context.trace_id, "032x"),
91+
format(span_context.span_id, "016x"),
92+
format(span_context.baggage.pop("trace-flags", 0), "02x")
93+
)
94+
95+
carrier.update(span_context.baggage)
96+
97+
def extract(self, carrier):
98+
traceparent = None
99+
tracestate = None
100+
101+
multiple_header_template = "Found more than one header value for {}"
102+
103+
# FIXME Define more specific exceptions and use them instead of the
104+
# generic ones that are being raised below
105+
106+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#header-name
107+
108+
trace_context_free_carrier = {}
109+
110+
for key, value in carrier.items():
111+
112+
lower_key = key.lower()
113+
114+
# The document requires that the headers be accepted regardless of
115+
# the case of their characters. This means that the keys of carrier
116+
# may contain 2 or more strings that match traceparent or
117+
# tracestate when the case of the characters of such strings is
118+
# ignored. The document does not specify what is to be done in such
119+
# a situation, this implementation will raise an exception.
120+
if lower_key == _TRACEPARENT:
121+
122+
if traceparent is None:
123+
traceparent = value
124+
125+
else:
126+
raise Exception(
127+
multiple_header_template.format(_TRACEPARENT)
128+
)
129+
130+
elif lower_key == _TRACESTATE:
131+
132+
if tracestate is None:
133+
tracestate = value
134+
135+
else:
136+
raise Exception(
137+
multiple_header_template.format(_TRACEPARENT)
138+
)
139+
140+
else:
141+
trace_context_free_carrier[key] = value
142+
143+
if traceparent is None:
144+
# https://www.w3.org/TR/trace-context/#no-traceparent-received
145+
_LOG.warning("No traceparent was received")
146+
return SpanContext(
147+
trace_id=getrandbits(128), span_id=getrandbits(64)
148+
)
149+
150+
else:
151+
152+
version_match = _VERSION.match(traceparent)
153+
154+
if version_match is None:
155+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#versioning-of-traceparent
156+
_LOG.warning(
157+
"Unable to parse version from traceparent"
158+
)
159+
return SpanContext(
160+
trace_id=getrandbits(128), span_id=getrandbits(64)
161+
)
162+
163+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#version
164+
version = version_match.group("version")
165+
if version == "ff":
166+
_LOG.warning(
167+
"Forbidden value of 255 found in version"
168+
)
169+
return SpanContext(
170+
trace_id=getrandbits(128), span_id=getrandbits(64)
171+
)
172+
173+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#versioning-of-traceparent
174+
if int(version, 16) > 0:
175+
176+
if len(traceparent) < 55:
177+
_LOG.warning(
178+
"traceparent shorter than 55 characters found"
179+
)
180+
return SpanContext(
181+
trace_id=getrandbits(128), span_id=getrandbits(64)
182+
)
183+
184+
remainder_match = _FUTURE_VERSION_REMAINDER.match(traceparent)
185+
186+
else:
187+
remainder_match = _00_VERSION_REMAINDER.match(traceparent)
188+
189+
if remainder_match is None:
190+
# This will happen if any of the trace-id, parent-id or
191+
# trace-flags fields contains non-allowed characters.
192+
# The document specifies that traceparent must be ignored if
193+
# these kind of characters appear in trace-id and parent-id,
194+
# but it does not specify what to do if they appear in
195+
# trace-flags.
196+
# Here it is assumed that traceparent is to be ignored also if
197+
# non-allowed characters are present in trace-flags too.
198+
_LOG.warning(
199+
"Received an invalid traceparent: {}".format(traceparent)
200+
)
201+
return SpanContext(
202+
trace_id=getrandbits(128), span_id=getrandbits(64)
203+
)
204+
205+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#trace-id
206+
trace_id = remainder_match.group("trace_id")
207+
if trace_id == 32 * "0":
208+
_LOG.warning(
209+
"Forbidden value of {} found in trace-id".format(trace_id)
210+
)
211+
return SpanContext(
212+
trace_id=getrandbits(128), span_id=getrandbits(64)
213+
)
214+
215+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#parent-id
216+
parent_id = remainder_match.group("parent_id")
217+
if parent_id == 16 * "0":
218+
_LOG.warning(
219+
"Forbidden value of {}"
220+
" found in parent-id".format(parent_id)
221+
)
222+
return SpanContext(
223+
trace_id=getrandbits(128), span_id=getrandbits(64)
224+
)
225+
226+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#trace-flags
227+
raw_trace_flags = remainder_match.group("trace_flags")
228+
229+
trace_flags = []
230+
231+
for index, bit_flag in enumerate(
232+
zip(
233+
bin(int(raw_trace_flags, 16))[2:].zfill(8),
234+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#other-flags
235+
# Flags can be added in the next list as the document
236+
# progresses and they get defined. This list represents the
237+
# 8 bits that are available in trace-flags and their
238+
# respective meaning.
239+
[None, None, None, None, None, None, None, "sampled"]
240+
)
241+
):
242+
bit, flag = bit_flag
243+
244+
if int(bit):
245+
if flag is None:
246+
warn("Invalid flag set at bit {}".format(index))
247+
trace_flags.append("0")
248+
249+
else:
250+
trace_flags.append("1")
251+
252+
else:
253+
trace_flags.append("0")
254+
255+
trace_context_free_carrier["trace-flags"] = int(
256+
"".join(trace_flags), 2
257+
)
258+
259+
if tracestate is not None:
260+
261+
tracestate_dictionary = OrderedDict()
262+
263+
for counter, list_member in enumerate(tracestate.split(",")):
264+
# https://www.w3.org/TR/trace-context/#tracestate-header-field-values
265+
if counter > 31:
266+
_LOG.warning(
267+
"More than 32 list-members "
268+
"found in tracestate {}".format(tracestate)
269+
)
270+
break
271+
272+
# https://www.w3.org/TR/trace-context/#tracestate-header-field-values
273+
if _BLANK.match(list_member):
274+
_LOG.debug(
275+
"Ignoring empty tracestate list-member"
276+
)
277+
continue
278+
279+
key_value = _KEY_VALUE.match(list_member)
280+
281+
if key_value is None:
282+
_LOG.warning(
283+
"Invalid key/value pair found: {}".format(
284+
key_value
285+
)
286+
)
287+
break
288+
289+
key, value = key_value.groups()
290+
291+
if key in tracestate_dictionary.keys():
292+
_LOG.warning(
293+
"Duplicate tracestate key found: {}".format(key)
294+
)
295+
break
296+
297+
tracestate_dictionary[key] = value
298+
299+
else:
300+
trace_context_free_carrier[_TRACESTATE] = ",".join(
301+
[
302+
"{0}={1}".format(key, value)
303+
for key, value in tracestate_dictionary.items()
304+
]
305+
)
306+
307+
return SpanContext(
308+
trace_id=int(trace_id, 16),
309+
span_id=int(parent_id, 16),
310+
baggage=trace_context_free_carrier
311+
)

tests/trace-context/conftest.py

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from os import mkdir, environ
2+
from os.path import abspath, dirname, join
3+
from subprocess import Popen
4+
from shlex import split
5+
from time import sleep
6+
7+
from git import Repo
8+
from git.exc import InvalidGitRepositoryError
9+
10+
_TEST_SERVICE = None
11+
12+
13+
def pytest_sessionstart(session):
14+
global _TEST_SERVICE
15+
trace_context_path = join(dirname(abspath(__file__)), "trace-context")
16+
17+
try:
18+
mkdir(trace_context_path)
19+
20+
except FileExistsError:
21+
pass
22+
23+
try:
24+
trace_context_repo = Repo(trace_context_path)
25+
26+
except InvalidGitRepositoryError:
27+
trace_context_repo = Repo.clone_from(
28+
"[email protected]:w3c/trace-context.git",
29+
trace_context_path
30+
)
31+
32+
trace_context_repo.heads.master.checkout()
33+
trace_context_repo.head.reset(
34+
"98f210efd89c63593dce90e2bae0a1bdcb986f51"
35+
)
36+
37+
environ["SERVICE_ENDPOINT"] = "http://127.0.0.1:5000/test"
38+
39+
_TEST_SERVICE = Popen(
40+
split(
41+
"python3 {}".format(
42+
join(
43+
dirname(abspath(__file__)),
44+
"trace_context_test_service.py"
45+
)
46+
)
47+
)
48+
)
49+
# This seems to be necessary, if not the first few test cases will fail
50+
# since they won't find the test service running.
51+
sleep(1)
52+
53+
54+
def pytest_sessionfinish(session, exitstatus):
55+
_TEST_SERVICE.terminate()

0 commit comments

Comments
 (0)