diff --git a/opentelemetry-api/src/opentelemetry/distributedcontext/__init__.py b/opentelemetry-api/src/opentelemetry/distributedcontext/__init__.py index d853a7bcf65..859bb0cb4f6 100644 --- a/opentelemetry-api/src/opentelemetry/distributedcontext/__init__.py +++ b/opentelemetry-api/src/opentelemetry/distributedcontext/__init__.py @@ -11,3 +11,123 @@ # 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. + +from contextlib import contextmanager +import itertools +import string +import typing + +PRINTABLE = frozenset(itertools.chain( + string.ascii_letters, + string.digits, + string.punctuation, + " ", +)) + + +class EntryMetadata: + """A class representing metadata of a DistributedContext entry + + Args: + entry_ttl: The time to live (in service hops) of an entry. Must be + initially set to either :attr:`EntryMetadata.NO_PROPAGATION` + or :attr:`EntryMetadata.UNLIMITED_PROPAGATION`. + """ + + NO_PROPAGATION = 0 + UNLIMITED_PROPAGATION = -1 + + def __init__(self, entry_ttl: int) -> None: + self.entry_ttl = entry_ttl + + +class EntryKey(str): + """A class representing a key for a DistributedContext entry""" + + def __new__(cls, value: str) -> "EntryKey": + return cls.create(value) + + @staticmethod + def create(value: str) -> "EntryKey": + # pylint: disable=len-as-condition + if not 0 < len(value) <= 255 or any(c not in PRINTABLE for c in value): + raise ValueError("Invalid EntryKey", value) + + return typing.cast(EntryKey, value) + + +class EntryValue(str): + """A class representing the value of a DistributedContext entry""" + + def __new__(cls, value: str) -> "EntryValue": + return cls.create(value) + + @staticmethod + def create(value: str) -> "EntryValue": + if any(c not in PRINTABLE for c in value): + raise ValueError("Invalid EntryValue", value) + + return typing.cast(EntryValue, value) + + +class Entry: + def __init__( + self, + metadata: EntryMetadata, + key: EntryKey, + value: EntryValue, + ) -> None: + self.metadata = metadata + self.key = key + self.value = value + + +class DistributedContext: + """A container for distributed context entries""" + + def __init__(self, entries: typing.Iterable[Entry]) -> None: + self._container = {entry.key: entry for entry in entries} + + def get_entries(self) -> typing.Iterable[Entry]: + """Returns an immutable iterator to entries.""" + return self._container.values() + + def get_entry_value( + self, + key: EntryKey + ) -> typing.Optional[EntryValue]: + """Returns the entry associated with a key or None + + Args: + key: the key with which to perform a lookup + """ + if key in self._container: + return self._container[key].value + return None + + +class DistributedContextManager: + def get_current_context(self) -> typing.Optional[DistributedContext]: + """Gets the current DistributedContext. + + Returns: + A DistributedContext instance representing the current context. + """ + + @contextmanager # type: ignore + def use_context( + self, + context: DistributedContext, + ) -> typing.Iterator[DistributedContext]: + """Context manager for controlling a DistributedContext lifetime. + + Set the context as the active DistributedContext. + + On exiting, the context manager will restore the parent + DistributedContext. + + Args: + context: A DistributedContext instance to make current. + """ + # pylint: disable=no-self-use + yield context diff --git a/opentelemetry-api/src/opentelemetry/distributedcontext/propagation/__init__.py b/opentelemetry-api/src/opentelemetry/distributedcontext/propagation/__init__.py new file mode 100644 index 00000000000..c8706281ad7 --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/distributedcontext/propagation/__init__.py @@ -0,0 +1,18 @@ +# 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. + +from .binaryformat import BinaryFormat +from .httptextformat import HTTPTextFormat + +__all__ = ["BinaryFormat", "HTTPTextFormat"] diff --git a/opentelemetry-api/src/opentelemetry/distributedcontext/propagation/binaryformat.py b/opentelemetry-api/src/opentelemetry/distributedcontext/propagation/binaryformat.py new file mode 100644 index 00000000000..0441eac5e3b --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/distributedcontext/propagation/binaryformat.py @@ -0,0 +1,61 @@ +# 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. + +import abc +import typing + +from opentelemetry.distributedcontext import DistributedContext + + +class BinaryFormat(abc.ABC): + """API for serialization of span context into binary formats. + + This class provides an interface that enables converting span contexts + to and from a binary format. + """ + + @staticmethod + @abc.abstractmethod + def to_bytes(context: DistributedContext) -> bytes: + """Creates a byte representation of a DistributedContext. + + to_bytes should read values from a DistributedContext and return a data + format to represent it, in bytes. + + Args: + context: the DistributedContext to serialize + + Returns: + A bytes representation of the DistributedContext. + + """ + + @staticmethod + @abc.abstractmethod + def from_bytes( + byte_representation: bytes) -> typing.Optional[DistributedContext]: + """Return a DistributedContext that was represented by bytes. + + from_bytes should return back a DistributedContext that was constructed + from the data serialized in the byte_representation passed. If it is + not possible to read in a proper DistributedContext, return None. + + Args: + byte_representation: the bytes to deserialize + + Returns: + A bytes representation of the DistributedContext if it is valid. + Otherwise return None. + + """ diff --git a/opentelemetry-api/src/opentelemetry/distributedcontext/propagation/httptextformat.py b/opentelemetry-api/src/opentelemetry/distributedcontext/propagation/httptextformat.py new file mode 100644 index 00000000000..3d11b7a3528 --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/distributedcontext/propagation/httptextformat.py @@ -0,0 +1,107 @@ +# 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. + +import abc +import typing + +from opentelemetry.distributedcontext import DistributedContext + +Setter = typing.Callable[[object, str, str], None] +Getter = typing.Callable[[object, str], typing.List[str]] + + +class HTTPTextFormat(abc.ABC): + """API for propagation of span context via headers. + + This class provides an interface that enables extracting and injecting + span context into headers of HTTP requests. HTTP frameworks and clients + can integrate with HTTPTextFormat by providing the object containing the + headers, and a getter and setter function for the extraction and + injection of values, respectively. + + Example:: + + import flask + import requests + from opentelemetry.context.propagation import HTTPTextFormat + + PROPAGATOR = HTTPTextFormat() + + def get_header_from_flask_request(request, key): + return request.headers.get_all(key) + + def set_header_into_requests_request(request: requests.Request, + key: str, value: str): + request.headers[key] = value + + def example_route(): + distributed_context = PROPAGATOR.extract( + get_header_from_flask_request, + flask.request + ) + request_to_downstream = requests.Request( + "GET", "http://httpbin.org/get" + ) + PROPAGATOR.inject( + distributed_context, + set_header_into_requests_request, + request_to_downstream + ) + session = requests.Session() + session.send(request_to_downstream.prepare()) + + + .. _Propagation API Specification: + https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/api-propagators.md + """ + @abc.abstractmethod + def extract(self, get_from_carrier: Getter, + carrier: object) -> DistributedContext: + """Create a DistributedContext from values in the carrier. + + The extract function should retrieve values from the carrier + object using get_from_carrier, and use values to populate a + DistributedContext value and return it. + + Args: + get_from_carrier: a function that can retrieve zero + or more values from the carrier. In the case that + the value does not exist, return an empty list. + carrier: and object which contains values that are + used to construct a DistributedContext. This object + must be paired with an appropriate get_from_carrier + which understands how to extract a value from it. + Returns: + A DistributedContext with configuration found in the carrier. + + """ + @abc.abstractmethod + def inject(self, context: DistributedContext, set_in_carrier: Setter, + carrier: object) -> None: + """Inject values from a DistributedContext into a carrier. + + inject enables the propagation of values into HTTP clients or + other objects which perform an HTTP request. Implementations + should use the set_in_carrier method to set values on the + carrier. + + Args: + context: The DistributedContext to read values from. + set_in_carrier: A setter function that can set values + on the carrier. + carrier: An object that a place to define HTTP headers. + Should be paired with set_in_carrier, which should + know how to set header values on the carrier. + + """ diff --git a/opentelemetry-api/tests/distributedcontext/__init__.py b/opentelemetry-api/tests/distributedcontext/__init__.py new file mode 100644 index 00000000000..d853a7bcf65 --- /dev/null +++ b/opentelemetry-api/tests/distributedcontext/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/opentelemetry-api/tests/distributedcontext/test_distributed_context.py b/opentelemetry-api/tests/distributedcontext/test_distributed_context.py new file mode 100644 index 00000000000..be1aadac9a1 --- /dev/null +++ b/opentelemetry-api/tests/distributedcontext/test_distributed_context.py @@ -0,0 +1,108 @@ +# 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. + +import unittest + +from opentelemetry import distributedcontext + + +class TestEntryMetadata(unittest.TestCase): + def test_entry_ttl_no_propagation(self): + metadata = distributedcontext.EntryMetadata( + distributedcontext.EntryMetadata.NO_PROPAGATION, + ) + self.assertEqual(metadata.entry_ttl, 0) + + def test_entry_ttl_unlimited_propagation(self): + metadata = distributedcontext.EntryMetadata( + distributedcontext.EntryMetadata.UNLIMITED_PROPAGATION, + ) + self.assertEqual(metadata.entry_ttl, -1) + + +class TestEntryKey(unittest.TestCase): + def test_create_empty(self): + with self.assertRaises(ValueError): + distributedcontext.EntryKey.create("") + + def test_create_too_long(self): + with self.assertRaises(ValueError): + distributedcontext.EntryKey.create("a" * 256) + + def test_create_invalid_character(self): + with self.assertRaises(ValueError): + distributedcontext.EntryKey.create("\x00") + + def test_create_valid(self): + key = distributedcontext.EntryKey.create("ok") + self.assertEqual(key, "ok") + + def test_key_new(self): + key = distributedcontext.EntryKey("ok") + self.assertEqual(key, "ok") + + +class TestEntryValue(unittest.TestCase): + def test_create_invalid_character(self): + with self.assertRaises(ValueError): + distributedcontext.EntryValue.create("\x00") + + def test_create_valid(self): + key = distributedcontext.EntryValue.create("ok") + self.assertEqual(key, "ok") + + def test_key_new(self): + key = distributedcontext.EntryValue("ok") + self.assertEqual(key, "ok") + + +class TestDistributedContext(unittest.TestCase): + def setUp(self): + entry = self.entry = distributedcontext.Entry( + distributedcontext.EntryMetadata( + distributedcontext.EntryMetadata.NO_PROPAGATION, + ), + distributedcontext.EntryKey("key"), + distributedcontext.EntryValue("value"), + ) + self.context = distributedcontext.DistributedContext(( + entry, + )) + + def test_get_entries(self): + self.assertIn(self.entry, self.context.get_entries()) + + def test_get_entry_value_present(self): + value = self.context.get_entry_value( + self.entry.key, + ) + self.assertIs(value, self.entry.value) + + def test_get_entry_value_missing(self): + key = distributedcontext.EntryKey("missing") + value = self.context.get_entry_value(key) + self.assertIsNone(value) + + +class TestDistributedContextManager(unittest.TestCase): + def setUp(self): + self.manager = distributedcontext.DistributedContextManager() + + def test_get_current_context(self): + self.assertIsNone(self.manager.get_current_context()) + + def test_use_context(self): + expected = object() + with self.manager.use_context(expected) as output: + self.assertIs(output, expected) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/distributedcontext/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/distributedcontext/__init__.py new file mode 100644 index 00000000000..fc449cfe2df --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/distributedcontext/__init__.py @@ -0,0 +1,67 @@ +# 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. + +from contextlib import contextmanager +import typing + +from opentelemetry import distributedcontext as dctx_api +from opentelemetry.context import Context + + +class DistributedContextManager(dctx_api.DistributedContextManager): + """See `opentelemetry.distributedcontext.DistributedContextManager` + + Args: + name: The name of the context manager + """ + + def __init__(self, name: str = "") -> None: + if name: + slot_name = "DistributedContext.{}".format(name) + else: + slot_name = "DistributedContext" + + self._current_context = Context.register_slot(slot_name) + + def get_current_context( + self, + ) -> typing.Optional[dctx_api.DistributedContext]: + """Gets the current DistributedContext. + + Returns: + A DistributedContext instance representing the current context. + """ + return self._current_context.get() + + @contextmanager + def use_context( + self, + context: dctx_api.DistributedContext, + ) -> typing.Iterator[dctx_api.DistributedContext]: + """Context manager for controlling a DistributedContext lifetime. + + Set the context as the active DistributedContext. + + On exiting, the context manager will restore the parent + DistributedContext. + + Args: + context: A DistributedContext instance to make current. + """ + snapshot = self._current_context.get() + self._current_context.set(context) + try: + yield context + finally: + self._current_context.set(snapshot) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/distributedcontext/propagation/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/distributedcontext/propagation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/opentelemetry-sdk/tests/distributedcontext/__init__.py b/opentelemetry-sdk/tests/distributedcontext/__init__.py new file mode 100644 index 00000000000..d853a7bcf65 --- /dev/null +++ b/opentelemetry-sdk/tests/distributedcontext/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/opentelemetry-sdk/tests/distributedcontext/test_distributed_context.py b/opentelemetry-sdk/tests/distributedcontext/test_distributed_context.py new file mode 100644 index 00000000000..fedcf7c9dd2 --- /dev/null +++ b/opentelemetry-sdk/tests/distributedcontext/test_distributed_context.py @@ -0,0 +1,51 @@ +# 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. + +import unittest + +from opentelemetry import distributedcontext as dctx_api +from opentelemetry.sdk import distributedcontext + + +class TestDistributedContextManager(unittest.TestCase): + def setUp(self): + self.manager = distributedcontext.DistributedContextManager() + + def test_use_context(self): + # Context is None initially + self.assertIsNone(self.manager.get_current_context()) + + # Start initial context + dctx = dctx_api.DistributedContext(()) + with self.manager.use_context(dctx) as current: + self.assertIs(current, dctx) + self.assertIs( + self.manager.get_current_context(), + dctx, + ) + + # Context is overridden + nested_dctx = dctx_api.DistributedContext(()) + with self.manager.use_context(nested_dctx) as current: + self.assertIs(current, nested_dctx) + self.assertIs( + self.manager.get_current_context(), + nested_dctx, + ) + + # Context is restored + self.assertIs( + self.manager.get_current_context(), + dctx, + )