Skip to content

Commit 736c2e1

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

File tree

3 files changed

+343
-0
lines changed

3 files changed

+343
-0
lines changed

lightstep/propagation.py

+5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
from __future__ import absolute_import
66

77

8+
# The TRACE_CONTEXT format represents SpanContexts in Trace Context format.
9+
# https://www.w3.org/TR/trace-context/
10+
TRACE_CONTEXT = "trace_context"
11+
12+
813
class LightStepFormat(object):
914
"""A namespace for lightstep supported carrier formats.
1015

lightstep/trace_context.py

+299
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
from re import match, findall, escape
2+
from random import choice
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+
from opentracing import SpanContextCorruptedException
10+
11+
_LOG = getLogger(__name__)
12+
13+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#header-name
14+
_TRACEPARENT = "traceparent"
15+
_TRACESTATE = "tracestate"
16+
17+
18+
class TraceContextPropagator(Propagator):
19+
"""
20+
Propagator for the W3C Trace Context format
21+
22+
The latest version of this Candidate Recommendation can be found here:
23+
https://www.w3.org/TR/trace-context/
24+
25+
This is an implementation for this specific version:
26+
https://www.w3.org/TR/2019/CR-trace-context-20190813/
27+
28+
The Candidate Recommendation will be referred to as "the document" in the
29+
comments of this implementation. This implementation adds comments which
30+
are URLs that point to the specific part of the document that explain the
31+
rationale of the code that follows the comment.
32+
"""
33+
34+
def inject(self, span_context, carrier):
35+
36+
baggage = span_context.baggage
37+
carrier.update(baggage)
38+
39+
carrier[_TRACEPARENT] = "00-{}-{}-{}".format(
40+
span_context.trace_id,
41+
span_context.span_id,
42+
"01" if baggage.get("trace_flags", False) else "00"
43+
)
44+
45+
if hasattr(span_context, "_tracestate"):
46+
47+
carrier[_TRACESTATE] = ",".join(
48+
[
49+
"=".join(
50+
[key, value] for key, value in
51+
span_context._tracestate.items()
52+
)
53+
]
54+
)
55+
56+
def extract(self, carrier):
57+
58+
traceparent = None
59+
tracestate = None
60+
61+
multiple_header_template = "Found more than one header value for {}"
62+
63+
# FIXME Define more specific exceptions and use them instead of the
64+
# generic ones that are being raised below
65+
66+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#header-name
67+
68+
trace_context_free_carrier = {}
69+
70+
for key, value in carrier.items():
71+
72+
lower_key = key.lower()
73+
74+
# The document requires that the headers be accepted regardless of
75+
# the case of their characters. This means that the keys of carrier
76+
# may contain 2 or more strings that match traceparent or
77+
# tracestate when the case of the characters of such strings is
78+
# ignored. The document does not specify what is to be done in such
79+
# a situation, this implementation will raise an exception.
80+
# FIXME should this be reported to the W3C Trace Context team?
81+
if lower_key == _TRACEPARENT:
82+
83+
if traceparent is None:
84+
traceparent = value
85+
86+
else:
87+
raise Exception(
88+
multiple_header_template.format(_TRACEPARENT)
89+
)
90+
91+
if lower_key == _TRACESTATE:
92+
93+
if tracestate is None:
94+
tracestate = value
95+
96+
else:
97+
raise Exception(
98+
multiple_header_template.format(_TRACEPARENT)
99+
)
100+
101+
trace_context_free_carrier[key] = value
102+
103+
if traceparent is None:
104+
# https://www.w3.org/TR/trace-context/#no-traceparent-received
105+
version = "00"
106+
trace_id = "".join([choice("abcdef0123456789") for _ in range(16)])
107+
parent_id = "".join(
108+
[choice("abcdef0123456789") for _ in range(16)]
109+
)
110+
trace_flags = None
111+
112+
tracestate = None
113+
114+
else:
115+
# https://www.w3.org/TR/trace-context/#a-traceparent-is-received
116+
117+
hexdigitlower = r"[abcdef\d]"
118+
119+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#version
120+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#version-format
121+
version_remainder_match = match(
122+
r"(?P<version>{}{{2}})-"
123+
r"(?P<remainder>.*)"
124+
.format(hexdigitlower),
125+
traceparent
126+
)
127+
128+
if version_remainder_match is None:
129+
_LOG.warning(
130+
"Unable to parse version from traceparent,"
131+
" restarting trace"
132+
)
133+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#versioning-of-traceparent
134+
return SpanContext(
135+
trace_id=int(
136+
"".join(
137+
[choice("abcdef0123456789") for _ in range(16)]
138+
),
139+
16
140+
),
141+
span_id=int(
142+
"".join(
143+
[choice("abcdef0123456789") for _ in range(16)]
144+
),
145+
16
146+
),
147+
)
148+
149+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#version
150+
version = version_remainder_match.group("version")
151+
if version == "ff":
152+
raise SpanContextCorruptedException(
153+
"Forbidden value of 255 found in version"
154+
)
155+
156+
remainder_match = match(
157+
(
158+
r"(?P<trace_id>{0}{{1,32}})-"
159+
r"(?P<parent_id>{0}{{1,16}})-"
160+
r"(?P<trace_flags>{0}{{2}})"
161+
).format(hexdigitlower),
162+
version_remainder_match.group("remainder")
163+
)
164+
165+
if remainder_match is None:
166+
# This will happen if any of the trace-id, parent-id or
167+
# trace-flags fields contains non-allowed characters.
168+
# The document specifies that traceparent must be ignored if
169+
# these kind of characters appear in trace-id and parent-id,
170+
# but it does not specify what to do if they appear in
171+
# trace-flags.
172+
# Here it is assumed that traceparent is to be ignored also if
173+
# non-allowed characters are present in trace-flags too.
174+
# FIXME confirm this assumption
175+
raise SpanContextCorruptedException(
176+
"Received an invalid traceparent: {}".format(traceparent)
177+
)
178+
179+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#trace-id
180+
trace_id = remainder_match.group("trace_id")
181+
if trace_id == 32 * "0":
182+
raise SpanContextCorruptedException(
183+
"Forbidden value of {} found in trace-id".format(trace_id)
184+
)
185+
186+
trace_id_extra_characters = len(trace_id) - 16
187+
188+
# FIXME The code inside this if and its corresponding elif needs to
189+
# be confirmed. There is still discussion regarding how trace-id is
190+
# to be handled in the document.
191+
if trace_id_extra_characters > 0:
192+
_LOG.debug(
193+
"Truncating {} extra characters from trace-id"
194+
.format(trace_id_extra_characters)
195+
)
196+
197+
trace_id = trace_id[trace_id_extra_characters:]
198+
199+
elif trace_id_extra_characters < 0:
200+
_LOG.debug(
201+
"Padding {} extra left zeroes in trace-id"
202+
.format(trace_id_extra_characters)
203+
)
204+
205+
trace_id = "".join(
206+
["0" * abs(trace_id_extra_characters), trace_id]
207+
)
208+
209+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#parent-id
210+
parent_id = remainder_match.group("parent_id")
211+
if parent_id == 16 * "0":
212+
raise SpanContextCorruptedException(
213+
"Forbidden value of {}"
214+
" found in parent-id".format(parent_id)
215+
)
216+
217+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#trace-flags
218+
raw_trace_flags = remainder_match.group("trace_flags")
219+
220+
trace_flags = {}
221+
222+
for index, bit_flag in enumerate(
223+
zip(
224+
bin(int(raw_trace_flags, 16))[2:].zfill(8),
225+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#other-flags
226+
# Flags can be added in the next list as the document
227+
# progresses and they get defined.
228+
[None, None, None, None, None, None, None, "sampled"]
229+
)
230+
):
231+
bit, flag = bit_flag
232+
233+
if int(bit):
234+
if flag is None:
235+
warn("Invalid flag set at bit {}".format(index))
236+
237+
else:
238+
trace_flags[flag] = True
239+
240+
else:
241+
trace_flags[flag] = False
242+
243+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#tracestate-header
244+
# As the document indicates in the URL before, failure to parse
245+
# traceparent must stop the parsing of tracestate. This stoppage is
246+
# achieved here by raising SpanContextCorruptedException when one of
247+
# these parsing failures is found.
248+
249+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#key
250+
key_re = (
251+
r"[{0}][-{0}/d_*/]{{0,255}}|"
252+
r"[{0}/d][-{0}/d_*/]{{0,240}}@{0}[-{0}/d_*/]{{0,13}}"
253+
).format(
254+
escape(r"".join([chr(character) for character in range(97, 123)]))
255+
)
256+
257+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#value
258+
value_re = r"( |[{0}]){{0,255}}[{0}]".format(
259+
escape(
260+
r"".join(
261+
[
262+
chr(character) for character in range(33, 127)
263+
if character not in [44, 61]
264+
]
265+
)
266+
)
267+
)
268+
269+
trace_context_free_carrier.update(trace_flags)
270+
271+
span_context = SpanContext(
272+
trace_id=int(trace_id, 16),
273+
span_id=int(parent_id, 16),
274+
baggage=trace_context_free_carrier
275+
)
276+
277+
if tracestate is not None:
278+
tracestate_match = match(
279+
# https://www.w3.org/TR/2019/CR-trace-context-20190813/#list
280+
r"{0}={1}| *( *, *({0}={1}| *)){{0,31}}"
281+
.format(key_re, value_re),
282+
tracestate
283+
)
284+
285+
if tracestate_match is None:
286+
warn("Invalid tracestate found: {}".format(tracestate))
287+
288+
else:
289+
tracestate = OrderedDict()
290+
291+
for key, value in findall(
292+
r"({0})=({1}))".format(key_re, value_re), tracestate
293+
):
294+
295+
tracestate[key] = value
296+
297+
span_context._tracestate = tracestate
298+
299+
return span_context
+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from unittest import TestCase
2+
3+
from lightstep import Tracer
4+
from lightstep.propagation import TRACE_CONTEXT
5+
from lightstep.trace_context import TraceContextPropagator
6+
7+
8+
class TraceContextPropagatorTest(TestCase):
9+
def setUp(self):
10+
self._tracer = Tracer(
11+
periodic_flush_seconds=0,
12+
collector_host='localhost'
13+
)
14+
(
15+
self._tracer.
16+
register_propagator(TRACE_CONTEXT, TraceContextPropagator())
17+
)
18+
19+
def tracer(self):
20+
return self._tracer
21+
22+
def tearDown(self):
23+
self._tracer.flush()
24+
25+
def test_extract(self):
26+
# FIXME Properly test that a randomly-initialized SpanContext is
27+
# returned here. Consider using a fixed random seed to make the tests
28+
# deterministic.
29+
self.tracer().extract(
30+
TRACE_CONTEXT, {
31+
"traceparent": "asdfas"
32+
}
33+
)
34+
35+
self.tracer().extract(
36+
TRACE_CONTEXT, {
37+
"traceparent": "00-23-123-00"
38+
}
39+
)

0 commit comments

Comments
 (0)