diff --git a/.circleci/config.yml b/.circleci/config.yml index 4ce55bef..4e85f9eb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -110,6 +110,17 @@ jobs: - *persist_to_workspace_step - *save_cache_step + trace-context: + docker: + - *test_runner + resource_class: *resource_class + steps: + - checkout + - *restore_cache_step + - run: scripts/run-tox-scenario '^py..-trace-context' + - *persist_to_workspace_step + - *save_cache_step + boto: docker: - *test_runner @@ -901,6 +912,9 @@ workflows: - tracer: requires: - flake8 + - trace-context: + requires: + - flake8 - unit_tests: requires: - flake8 @@ -960,6 +974,7 @@ workflows: - test_logging - tornado - tracer + - trace-context - unit_tests - vertica - deploy_dev: diff --git a/oteltrace/propagation/w3c.py b/oteltrace/propagation/w3c.py index 12b9e576..1d2bbdcb 100644 --- a/oteltrace/propagation/w3c.py +++ b/oteltrace/propagation/w3c.py @@ -4,9 +4,29 @@ from ..ext import priority +_KEY_WITHOUT_VENDOR_FORMAT = r'[a-z][_0-9a-z\-\*\/]{0,255}' +_KEY_WITH_VENDOR_FORMAT = ( + r'[a-z][_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 + + class W3CHTTPPropagator: """w3c compatible propagator""" _TRACEPARENT_HEADER_NAME = 'traceparent' + _TRACESTATE_HEADER_NAME = 'tracestate' _TRACEPARENT_HEADER_FORMAT = ( '^[ \t]*([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.*)?[ \t]*$' ) @@ -29,6 +49,10 @@ def inject(self, span_context, headers): ) headers[self._TRACEPARENT_HEADER_NAME] = traceparent_string + # is there is state in the context propagate it + if hasattr(span_context, 'tracestate'): + tracestate_string = _format_tracestate(span_context.tracestate) + headers[self._TRACESTATE_HEADER_NAME] = tracestate_string def extract(self, headers): if not headers: @@ -57,7 +81,6 @@ def extract(self, headers): trace_id = int(trace_id_str, 16) span_id = int(span_id_str, 16) trace_options = int(trace_options_str, 16) - # TODO: probably not needed if trace_id == 0 or span_id == 0: return Context() @@ -68,8 +91,69 @@ def extract(self, headers): else: sampling_priority = priority.AUTO_REJECT - return Context( + tracestate_header = headers.get(self._TRACESTATE_HEADER_NAME) + tracestate = _parse_tracestate(tracestate_header) + + ctx = Context( trace_id=trace_id, span_id=span_id, sampling_priority=sampling_priority, ) + + ctx.tracestate = tracestate + + return ctx + + +def _parse_tracestate(header): + """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 = {} + value_count = 0 + + if header is None: + return {} + 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 {} + 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 {} + tracestate[key] = value + value_count += 1 + if value_count > _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS: + return {} + return tracestate + + +def _format_tracestate(tracestate): + """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/tests/propagation/test_w3c.sh b/tests/propagation/test_w3c.sh new file mode 100755 index 00000000..25a6ee5a --- /dev/null +++ b/tests/propagation/test_w3c.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -e +pwd +# hard-coding the git tag to ensure stable builds. +TRACECONTEXT_GIT_TAG="98f210efd89c63593dce90e2bae0a1bdcb986f51" +# clone w3c tracecontext tests +mkdir -p target +rm -rf ./target/trace-context +git clone https://github.com/w3c/trace-context ./target/trace-context +cd ./target/trace-context && git checkout $TRACECONTEXT_GIT_TAG && cd - +# start example service, which propagates trace-context by default. +python tests/propagation/w3c_server_test.py 1>&2 & +EXAMPLE_SERVER_PID=$! +# give the app server a little time to start up. Not adding some sort +# of delay would cause many of the tracecontext tests to fail being +# unable to connect. +sleep 1 +onshutdown() +{ + # send a sigint, to ensure + # it is caught as a KeyboardInterrupt in the + # example service. + kill $EXAMPLE_SERVER_PID +} +trap onshutdown EXIT +cd ./target/trace-context/test +python test.py http://127.0.0.1:5000/verify-tracecontext diff --git a/tests/propagation/w3c_server_test.py b/tests/propagation/w3c_server_test.py new file mode 100644 index 00000000..8eea3bb0 --- /dev/null +++ b/tests/propagation/w3c_server_test.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# +# Copyright 2019, 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. +""" +This server is intended to be used with the W3C tracecontext validation +Service. It implements the APIs needed to be exercised by the test bed. +""" + +from oteltrace import patch_all +patch_all() + +import json # noqa: E402 +import flask # noqa: E402 +import requests # noqa: E402 + +from oteltrace.propagation.w3c import W3CHTTPPropagator # noqa: E402 +from oteltrace import tracer # noqa: E402 + +tracer.configure( + http_propagator=W3CHTTPPropagator, +) + +app = flask.Flask(__name__) + + +@app.route('/verify-tracecontext', methods=['POST']) +def verify_tracecontext(): + """Upon reception of some payload, sends a request back to the designated + url. + + This route is designed to be testable with the w3c tracecontext server / + client test. + """ + for action in flask.request.json: + requests.post( + url=action['url'], + data=json.dumps(action['arguments']), + headers={ + 'Accept': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + }, + timeout=5.0, + ) + return 'hello' + + +if __name__ == '__main__': + app.run(debug=True) diff --git a/tox.ini b/tox.ini index fbfa7525..d9694607 100644 --- a/tox.ini +++ b/tox.ini @@ -24,6 +24,7 @@ envlist = {py34,py35,py36,py37}-oteltracerun {py34,py35,py36,py37}-test_utils {py34,py35,py36,py37}-test_logging + py37-trace-context # Integrations environments aiobotocore_contrib-py34-aiobotocore{02,03,04} aiobotocore_contrib-{py35,py36}-aiobotocore{02,03,04,05,07,08,09,010} @@ -331,6 +332,9 @@ deps = tornado50: tornado>=5.0,<5.1 tornado51: tornado>=5.1,<5.2 tornado60: tornado>=6.0,<6.1 + trace-context: aiohttp + trace-context: flask + trace-context: requests vertica060: vertica-python>=0.6.0,<0.7.0 vertica070: vertica-python>=0.7.0,<0.8.0 webtest: WebTest @@ -341,6 +345,7 @@ passenv=TEST_* commands = # run only essential tests related to the tracing client tracer: pytest {posargs} --ignore="tests/contrib" --ignore="tests/commands" --ignore="tests/unit" --ignore="tests/internal" tests + trace-context: {toxinidir}/tests/propagation/test_w3c.sh # run only the `oteltrace.internal` tests internal: pytest {posargs} tests/internal # Contribs