diff --git a/.flake8 b/.flake8 index a3411a16147..5abd0630ea0 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,15 @@ [flake8] -ignore = E501,W503,E203 -exclude = .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,ext/opentelemetry-ext-jaeger/src/opentelemetry/ext/jaeger/gen/,ext/opentelemetry-ext-jaeger/build/* +ignore = + E501 # line too long, defer to black + F401 # unused import, defer to pylint + W503 # allow line breaks after binary ops, not after +exclude = + .bzr + .git + .hg + .svn + .tox + CVS + __pycache__ + ext/opentelemetry-ext-jaeger/src/opentelemetry/ext/jaeger/gen/ + ext/opentelemetry-ext-jaeger/build/* diff --git a/.pylintrc b/.pylintrc index 782fc58700e..1aa1e10d0b4 100644 --- a/.pylintrc +++ b/.pylintrc @@ -68,7 +68,8 @@ disable=missing-docstring, ungrouped-imports, # Leave this up to isort wrong-import-order, # Leave this up to isort bad-continuation, # Leave this up to black - line-too-long # Leave this up to black + line-too-long, # Leave this up to black + exec-used # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/docs/conf.py b/docs/conf.py index 694ba7f0056..94d17cb3eb6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,7 +19,7 @@ # -- Project information ----------------------------------------------------- project = "OpenTelemetry" -copyright = "2019, OpenTelemetry Authors" +copyright = "2019, OpenTelemetry Authors" # pylint: disable=redefined-builtin author = "OpenTelemetry Authors" diff --git a/opentelemetry-api/src/opentelemetry/trace/__init__.py b/opentelemetry-api/src/opentelemetry/trace/__init__.py index fff5d556b92..d2426fd31d1 100644 --- a/opentelemetry-api/src/opentelemetry/trace/__init__.py +++ b/opentelemetry-api/src/opentelemetry/trace/__init__.py @@ -253,8 +253,8 @@ def __exit__( class TraceOptions(int): """A bitmask that represents options specific to the trace. - The only supported option is the "recorded" flag (``0x01``). If set, this - flag indicates that the trace may have been recorded upstream. + The only supported option is the "sampled" flag (``0x01``). If set, this + flag indicates that the trace may have been sampled upstream. See the `W3C Trace Context - Traceparent`_ spec for details. @@ -263,12 +263,16 @@ class TraceOptions(int): """ DEFAULT = 0x00 - RECORDED = 0x01 + SAMPLED = 0x01 @classmethod def get_default(cls) -> "TraceOptions": return cls(cls.DEFAULT) + @property + def sampled(self) -> bool: + return bool(self & TraceOptions.SAMPLED) + DEFAULT_TRACE_OPTIONS = TraceOptions.get_default() @@ -309,8 +313,8 @@ class SpanContext: Args: trace_id: The ID of the trace that this span belongs to. span_id: This span's ID. - options: Trace options to propagate. - state: Tracing-system-specific info to propagate. + trace_options: Trace options to propagate. + trace_state: Tracing-system-specific info to propagate. """ def __init__( @@ -363,6 +367,9 @@ def __init__(self, context: "SpanContext") -> None: def get_context(self) -> "SpanContext": return self._context + def is_recording_events(self) -> bool: + return False + INVALID_SPAN_ID = 0x0000000000000000 INVALID_TRACE_ID = 0x00000000000000000000000000000000 diff --git a/opentelemetry-api/src/opentelemetry/trace/sampling.py b/opentelemetry-api/src/opentelemetry/trace/sampling.py new file mode 100644 index 00000000000..f16e80495bf --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/trace/sampling.py @@ -0,0 +1,125 @@ +# 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 +from typing import Dict, Mapping, Optional, Sequence + +# pylint: disable=unused-import +from opentelemetry.trace import Link, SpanContext +from opentelemetry.util.types import AttributeValue + + +class Decision: + """A sampling decision as applied to a newly-created Span. + + Args: + sampled: Whether the `Span` should be sampled. + attributes: Attributes to add to the `Span`. + """ + + def __repr__(self) -> str: + return "{}({}, attributes={})".format( + type(self).__name__, str(self.sampled), str(self.attributes) + ) + + def __init__( + self, + sampled: bool = False, + attributes: Mapping[str, "AttributeValue"] = None, + ) -> None: + self.sampled = sampled # type: bool + if attributes is None: + self.attributes = {} # type: Dict[str, "AttributeValue"] + else: + self.attributes = dict(attributes) + + +class Sampler(abc.ABC): + @abc.abstractmethod + def should_sample( + self, + parent_context: Optional["SpanContext"], + trace_id: int, + span_id: int, + name: str, + links: Sequence["Link"] = (), + ) -> "Decision": + pass + + +class StaticSampler(Sampler): + """Sampler that always returns the same decision.""" + + def __init__(self, decision: "Decision"): + self._decision = decision + + def should_sample( + self, + parent_context: Optional["SpanContext"], + trace_id: int, + span_id: int, + name: str, + links: Sequence["Link"] = (), + ) -> "Decision": + return self._decision + + +class ProbabilitySampler(Sampler): + def __init__(self, rate: float): + self._rate = rate + self._bound = self.get_bound_for_rate(self._rate) + + # The sampler checks the last 8 bytes of the trace ID to decide whether to + # sample a given trace. + CHECK_BYTES = 0xFFFFFFFFFFFFFFFF + + @classmethod + def get_bound_for_rate(cls, rate: float) -> int: + return round(rate * (cls.CHECK_BYTES + 1)) + + @property + def rate(self) -> float: + return self._rate + + @rate.setter + def rate(self, new_rate: float) -> None: + self._rate = new_rate + self._bound = self.get_bound_for_rate(self._rate) + + @property + def bound(self) -> int: + return self._bound + + def should_sample( + self, + parent_context: Optional["SpanContext"], + trace_id: int, + span_id: int, + name: str, + links: Sequence["Link"] = (), + ) -> "Decision": + if parent_context is not None: + return Decision(parent_context.trace_options.sampled) + + return Decision(trace_id & self.CHECK_BYTES < self.bound) + + +# Samplers that ignore the parent sampling decision and never/always sample. +ALWAYS_OFF = StaticSampler(Decision(False)) +ALWAYS_ON = StaticSampler(Decision(True)) + +# Samplers that respect the parent sampling decision, but otherwise +# never/always sample. +DEFAULT_OFF = ProbabilitySampler(0.0) +DEFAULT_ON = ProbabilitySampler(1.0) diff --git a/opentelemetry-api/tests/trace/test_sampling.py b/opentelemetry-api/tests/trace/test_sampling.py new file mode 100644 index 00000000000..b456aa91f18 --- /dev/null +++ b/opentelemetry-api/tests/trace/test_sampling.py @@ -0,0 +1,223 @@ +# 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 trace +from opentelemetry.trace import sampling + +TO_DEFAULT = trace.TraceOptions(trace.TraceOptions.DEFAULT) +TO_SAMPLED = trace.TraceOptions(trace.TraceOptions.SAMPLED) + + +class TestSampler(unittest.TestCase): + def test_always_on(self): + no_record_always_on = sampling.ALWAYS_ON.should_sample( + trace.SpanContext( + 0xDEADBEEF, 0xDEADBEF0, trace_options=TO_DEFAULT + ), + 0xDEADBEF1, + 0xDEADBEF2, + "unsampled parent, sampling on", + ) + self.assertTrue(no_record_always_on.sampled) + self.assertEqual(no_record_always_on.attributes, {}) + + sampled_always_on = sampling.ALWAYS_ON.should_sample( + trace.SpanContext( + 0xDEADBEEF, 0xDEADBEF0, trace_options=TO_SAMPLED + ), + 0xDEADBEF1, + 0xDEADBEF2, + "sampled parent, sampling on", + ) + self.assertTrue(sampled_always_on.sampled) + self.assertEqual(sampled_always_on.attributes, {}) + + def test_always_off(self): + no_record_always_off = sampling.ALWAYS_OFF.should_sample( + trace.SpanContext( + 0xDEADBEEF, 0xDEADBEF0, trace_options=TO_DEFAULT + ), + 0xDEADBEF1, + 0xDEADBEF2, + "unsampled parent, sampling off", + ) + self.assertFalse(no_record_always_off.sampled) + self.assertEqual(no_record_always_off.attributes, {}) + + sampled_always_on = sampling.ALWAYS_OFF.should_sample( + trace.SpanContext( + 0xDEADBEEF, 0xDEADBEF0, trace_options=TO_SAMPLED + ), + 0xDEADBEF1, + 0xDEADBEF2, + "sampled parent, sampling off", + ) + self.assertFalse(sampled_always_on.sampled) + self.assertEqual(sampled_always_on.attributes, {}) + + def test_default_on(self): + no_record_default_on = sampling.DEFAULT_ON.should_sample( + trace.SpanContext( + 0xDEADBEEF, 0xDEADBEF0, trace_options=TO_DEFAULT + ), + 0xDEADBEF1, + 0xDEADBEF2, + "unsampled parent, sampling on", + ) + self.assertFalse(no_record_default_on.sampled) + self.assertEqual(no_record_default_on.attributes, {}) + + sampled_default_on = sampling.DEFAULT_ON.should_sample( + trace.SpanContext( + 0xDEADBEEF, 0xDEADBEF0, trace_options=TO_SAMPLED + ), + 0xDEADBEF1, + 0xDEADBEF2, + "sampled parent, sampling on", + ) + self.assertTrue(sampled_default_on.sampled) + self.assertEqual(sampled_default_on.attributes, {}) + + def test_default_off(self): + no_record_default_off = sampling.DEFAULT_OFF.should_sample( + trace.SpanContext( + 0xDEADBEEF, 0xDEADBEF0, trace_options=TO_DEFAULT + ), + 0xDEADBEF1, + 0xDEADBEF2, + "unsampled parent, sampling off", + ) + self.assertFalse(no_record_default_off.sampled) + self.assertEqual(no_record_default_off.attributes, {}) + + sampled_default_off = sampling.DEFAULT_OFF.should_sample( + trace.SpanContext( + 0xDEADBEEF, 0xDEADBEF0, trace_options=TO_SAMPLED + ), + 0xDEADBEF1, + 0xDEADBEF2, + "sampled parent, sampling off", + ) + self.assertTrue(sampled_default_off.sampled) + self.assertEqual(sampled_default_off.attributes, {}) + + def test_probability_sampler(self): + sampler = sampling.ProbabilitySampler(0.5) + + # Check that we sample based on the trace ID if the parent context is + # null + self.assertTrue( + sampler.should_sample( + None, 0x7FFFFFFFFFFFFFFF, 0xDEADBEEF, "span name" + ).sampled + ) + self.assertFalse( + sampler.should_sample( + None, 0x8000000000000000, 0xDEADBEEF, "span name" + ).sampled + ) + + # Check that the sampling decision matches the parent context if given, + # and that the sampler ignores the trace ID + self.assertFalse( + sampler.should_sample( + trace.SpanContext( + 0xDEADBEF0, 0xDEADBEF1, trace_options=TO_DEFAULT + ), + 0x8000000000000000, + 0xDEADBEEF, + "span name", + ).sampled + ) + self.assertTrue( + sampler.should_sample( + trace.SpanContext( + 0xDEADBEF0, 0xDEADBEF1, trace_options=TO_SAMPLED + ), + 0x8000000000000001, + 0xDEADBEEF, + "span name", + ).sampled + ) + + def test_probability_sampler_zero(self): + default_off = sampling.ProbabilitySampler(0.0) + self.assertFalse( + default_off.should_sample( + None, 0x0, 0xDEADBEEF, "span name" + ).sampled + ) + + def test_probability_sampler_one(self): + default_off = sampling.ProbabilitySampler(1.0) + self.assertTrue( + default_off.should_sample( + None, 0xFFFFFFFFFFFFFFFF, 0xDEADBEEF, "span name" + ).sampled + ) + + def test_probability_sampler_limits(self): + + # Sample one of every 2^64 (= 5e-20) traces. This is the lowest + # possible meaningful sampling rate, only traces with trace ID 0x0 + # should get sampled. + almost_always_off = sampling.ProbabilitySampler(2 ** -64) + self.assertTrue( + almost_always_off.should_sample( + None, 0x0, 0xDEADBEEF, "span name" + ).sampled + ) + self.assertFalse( + almost_always_off.should_sample( + None, 0x1, 0xDEADBEEF, "span name" + ).sampled + ) + self.assertEqual( + sampling.ProbabilitySampler.get_bound_for_rate(2 ** -64), 0x1 + ) + + # Sample every trace with (last 8 bytes of) trace ID less than + # 0xffffffffffffffff. In principle this is the highest possible + # sampling rate less than 1, but we can't actually express this rate as + # a float! + # + # In practice, the highest possible sampling rate is: + # + # round(sys.float_info.epsilon * 2 ** 64) + + almost_always_on = sampling.ProbabilitySampler(1 - 2 ** -64) + self.assertTrue( + almost_always_on.should_sample( + None, 0xFFFFFFFFFFFFFFFE, 0xDEADBEEF, "span name" + ).sampled + ) + + # These tests are logically consistent, but fail because of the float + # precision issue above. Changing the sampler to check fewer bytes of + # the trace ID will cause these to pass. + + # self.assertFalse( + # almost_always_on.should_sample( + # None, + # 0xffffffffffffffff, + # 0xdeadbeef, + # "span name", + # ).sampled + # ) + # self.assertEqual( + # sampling.ProbabilitySampler.get_bound_for_rate(1 - 2 ** -64)), + # 0xffffffffffffffff, + # ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/context/propagation/b3_format.py b/opentelemetry-sdk/src/opentelemetry/sdk/context/propagation/b3_format.py index 2eca8afaa1b..7d59fddb9e5 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/context/propagation/b3_format.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/context/propagation/b3_format.py @@ -90,7 +90,7 @@ def extract(cls, get_from_carrier, carrier): # the desire for some form of sampling, propagate if either # header is set to allow. if sampled in cls._SAMPLE_PROPAGATE_VALUES or flags == "1": - options |= trace.TraceOptions.RECORDED + options |= trace.TraceOptions.SAMPLED return trace.SpanContext( # trace an span ids are encoded in hex, so must be converted trace_id=int(trace_id, 16), @@ -101,7 +101,7 @@ def extract(cls, get_from_carrier, carrier): @classmethod def inject(cls, context, set_in_carrier, carrier): - sampled = (trace.TraceOptions.RECORDED & context.trace_options) != 0 + sampled = (trace.TraceOptions.SAMPLED & context.trace_options) != 0 set_in_carrier( carrier, cls.TRACE_ID_KEY, format_trace_id(context.trace_id) ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py index 7b274f852f4..d33aa01146c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py @@ -16,13 +16,14 @@ import logging import random import threading -import typing from contextlib import contextmanager +from typing import Iterator, Optional, Sequence, Tuple from opentelemetry import trace as trace_api from opentelemetry.context import Context from opentelemetry.sdk import util from opentelemetry.sdk.util import BoundedDict, BoundedList +from opentelemetry.trace import sampling from opentelemetry.util import time_ns, types logger = logging.getLogger(__name__) @@ -73,7 +74,7 @@ class MultiSpanProcessor(SpanProcessor): def __init__(self): # use a tuple to avoid race conditions when adding a new span and # iterating through it on "on_start" and "on_end". - self._span_processors = () # type: typing.Tuple[SpanProcessor, ...] + self._span_processors = () # type: Tuple[SpanProcessor, ...] self._lock = threading.Lock() def add_span_processor(self, span_processor: SpanProcessor) -> None: @@ -104,7 +105,7 @@ class Span(trace_api.Span): context: The immutable span context parent: This span's parent, may be a `SpanContext` if the parent is remote, null if this is a root span - sampler: TODO + sampler: The sampler used to create this span trace_config: TODO resource: TODO attributes: The span's attributes to be exported @@ -124,12 +125,12 @@ def __init__( name: str, context: trace_api.SpanContext, parent: trace_api.ParentSpan = None, - sampler: None = None, # TODO + sampler: Optional[sampling.Sampler] = None, trace_config: None = None, # TODO resource: None = None, # TODO attributes: types.Attributes = None, # TODO - events: typing.Sequence[trace_api.Event] = None, # TODO - links: typing.Sequence[trace_api.Link] = None, # TODO + events: Sequence[trace_api.Event] = None, # TODO + links: Sequence[trace_api.Link] = None, # TODO kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, span_processor: SpanProcessor = SpanProcessor(), ) -> None: @@ -163,8 +164,8 @@ def __init__( else: self.links = BoundedList.from_seq(MAX_NUM_LINKS, links) - self.end_time = None # type: typing.Optional[int] - self.start_time = None # type: typing.Optional[int] + self.end_time = None # type: Optional[int] + self.start_time = None # type: Optional[int] def __repr__(self): return '{}(name="{}", context={})'.format( @@ -246,7 +247,7 @@ def add_lazy_link(self, link: "trace_api.Link") -> None: return self.links.append(link) - def start(self, start_time: typing.Optional[int] = None) -> None: + def start(self, start_time: Optional[int] = None) -> None: with self._lock: if not self.is_recording_events(): return @@ -320,12 +321,17 @@ class Tracer(trace_api.Tracer): name: The name of the tracer. """ - def __init__(self, name: str = "") -> None: + def __init__( + self, + name: str = "", + sampler: sampling.Sampler = trace_api.sampling.ALWAYS_ON, + ) -> None: slot_name = "current_span" if name: slot_name = "{}.current_span".format(name) self._current_span_slot = Context.register_slot(slot_name) self._active_span_processor = MultiSpanProcessor() + self.sampler = sampler def get_current_span(self): """See `opentelemetry.trace.Tracer.get_current_span`.""" @@ -348,7 +354,7 @@ def start_as_current_span( name: str, parent: trace_api.ParentSpan = trace_api.Tracer.CURRENT_SPAN, kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, - ) -> typing.Iterator[trace_api.Span]: + ) -> Iterator[trace_api.Span]: """See `opentelemetry.trace.Tracer.start_as_current_span`.""" span = self.start_span(name, parent, kind) @@ -359,38 +365,68 @@ def create_span( name: str, parent: trace_api.ParentSpan = trace_api.Tracer.CURRENT_SPAN, kind: trace_api.SpanKind = trace_api.SpanKind.INTERNAL, - ) -> "Span": - """See `opentelemetry.trace.Tracer.create_span`.""" - span_id = generate_span_id() + ) -> "trace_api.Span": + """See `opentelemetry.trace.Tracer.create_span`. + + If `parent` is null the new span will be created as a root span, i.e. a + span with no parent context. By default, the new span will be created + as a child of the current span in this tracer's context, or as a root + span if no current span exists. + """ + if parent is Tracer.CURRENT_SPAN: parent = self.get_current_span() + if parent is None: - context = trace_api.SpanContext(generate_trace_id(), span_id) + parent_context = None + new_span_context = trace_api.SpanContext( + generate_trace_id(), generate_span_id() + ) else: if isinstance(parent, trace_api.Span): parent_context = parent.get_context() elif isinstance(parent, trace_api.SpanContext): parent_context = parent else: + # TODO: error handling raise TypeError - context = trace_api.SpanContext( + + new_span_context = trace_api.SpanContext( parent_context.trace_id, - span_id, + generate_span_id(), parent_context.trace_options, parent_context.trace_state, ) - return Span( - name=name, - context=context, - parent=parent, - span_processor=self._active_span_processor, - kind=kind, + + # The sampler decides whether to create a real or no-op span at the + # time of span creation. No-op spans do not record events, and are not + # exported. + # The sampler may also add attributes to the newly-created span, e.g. + # to include information about the sampling decision. + sampling_decision = self.sampler.should_sample( + parent_context, + new_span_context.trace_id, + new_span_context.span_id, + name, + {}, # TODO: links ) + if sampling_decision.sampled: + return Span( + name=name, + context=new_span_context, + parent=parent, + sampler=self.sampler, + attributes=sampling_decision.attributes, + span_processor=self._active_span_processor, + kind=kind, + ) + return trace_api.DefaultSpan(context=new_span_context) + @contextmanager def use_span( self, span: trace_api.Span, end_on_exit: bool = False - ) -> typing.Iterator[trace_api.Span]: + ) -> Iterator[trace_api.Span]: """See `opentelemetry.trace.Tracer.use_span`.""" try: span_snapshot = self._current_span_slot.get() diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index fa8547a0a5f..c167e374aca 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -17,6 +17,7 @@ from opentelemetry import trace as trace_api from opentelemetry.sdk import trace +from opentelemetry.trace import sampling from opentelemetry.util import time_ns @@ -26,6 +27,29 @@ def test_extends_api(self): self.assertIsInstance(tracer, trace_api.Tracer) +class TestTracerSampling(unittest.TestCase): + def test_default_sampler(self): + tracer = trace.Tracer() + + # Check that the default tracer creates real spans via the default + # sampler + root_span = tracer.create_span(name="root span", parent=None) + self.assertIsInstance(root_span, trace.Span) + child_span = tracer.create_span(name="child span", parent=root_span) + self.assertIsInstance(child_span, trace.Span) + + def test_sampler_no_sampling(self): + tracer = trace.Tracer() + tracer.sampler = sampling.ALWAYS_OFF + + # Check that the default tracer creates no-op spans if the sampler + # decides not to sampler + root_span = tracer.create_span(name="root span", parent=None) + self.assertIsInstance(root_span, trace_api.DefaultSpan) + child_span = tracer.create_span(name="child span", parent=root_span) + self.assertIsInstance(child_span, trace_api.DefaultSpan) + + class TestSpanCreation(unittest.TestCase): def test_start_span_implicit(self): tracer = trace.Tracer("test_start_span_implicit")