Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Context Prop #325

Closed
wants to merge 73 commits into from
Closed

Conversation

codeboten
Copy link
Contributor

@codeboten codeboten commented Dec 10, 2019

This is a continuation of the work started in #278:

This is a PR to start implementing the context-propagation-spec as outlined in the following documents:

We could break up the pieces into more consumable pull requests with issues and tests for each. What I'd like to get out of this PR though is for folks to review the code examples below to understand if the otep is implemented as it should be. I'd like to use the feedback here to also recommend any changes that need to happen in the otep.

TODO:

  • support merging context
  • review correlationcontext implementation
  • update branch from master, this includes the changes for named tracers
  • add tests for new functionality
  • review documentation for all updated code

Global initialization

def configure_opentelemetry(flask_app: flask.Flask):
    trace.set_preferred_tracer_implementation(lambda T: Tracer())

    # Global initialization
    (baggage_extractor, baggage_injector) = correlation_context.http_propagator()
    (b3_extractor, b3_injector) = b3_format.http_propagator()
    propagation.set_http_extractors([b3_extractor, baggage_extractor])
    propagation.set_http_injectors([b3_injector, baggage_injector])

Extracting and injecting from HTTP headers

@app.route("/")
def handle_request(ctx):
    extractors = propagation.http_extractors()
    with propagation.extract(request.headers, context=ctx, extractors=extractors) as ctx1:
        with tracer.start_as_current_span("service-span", context=ctx1) as ctx2:
            with tracer.start_as_current_span("external-req-span", context=ctx2) as ctx3:
                headers = {"Accept": "application/json"}
                version = CorrelationContextManager.value("version", context=ctx3)
                if version == "2.0":
                    return fetch_from_service_c(ctx3)
                return fetch_from_service_b(ctx3)

def fetch_from_service_b(ctx) -> str:
    # Inject the contexts to be propagated. Note that there is no direct
    # reference to tracing or baggage.
    headers = {"Accept": "application/json"}
		injectors = propagation.http_injectors()
    propagation.inject(headers, context=ctx, injectors=injectors)
    resp = requests.get("https://opentelemetry.io", headers=headers)
    return resp.text

Simpler API with context prop using current context

@app.route("/")
def handle_request():
    with propagation.extract(request.headers):
        with tracer.start_as_current_span("service-span"):
            with tracer.start_as_current_span("external-req-span"):
                version = CorrelationContextManager.value("version")
                if version == "2.0":
                    return fetch_from_service_c()

                return fetch_from_service_b()

def fetch_from_service_b() -> str:
    # Inject the contexts to be propagated. Note that there is no direct
    # reference to tracing or baggage.
    headers = {"Accept": "application/json"}
    propagation.inject(headers)
    resp = requests.get("https://opentelemetry.io", headers=headers)
    return resp.text

Implementing a propagator

class B3Extractor(HTTPExtractor):
    @classmethod
    def extract(cls, context, carrier, get_from_carrier=_getter):
			sc = trace.SpanContext(
          trace_id=int(trace_id, 16),
          span_id=int(span_id, 16),
          trace_options=trace.TraceOptions(options),
          trace_state=trace.TraceState(),
      )
      return Context.set_value(context, ContextKeys.span_context_key(), sc)

class B3Injector(HTTPInjector):
    @classmethod
    def inject(cls, context, carrier, set_in_carrier=_setter):
        sc = Context.value(context, trace.propagation.ContextKeys.span_context_key())
        sampled = (trace.TraceOptions.SAMPLED & sc.trace_options) != 0
        set_in_carrier(carrier, TRACE_ID_KEY, format_trace_id(sc.trace_id))
        set_in_carrier(carrier, SPAN_ID_KEY, format_span_id(sc.span_id))
        set_in_carrier(carrier, SAMPLED_KEY, "1" if sampled else "0")

Implementing a concern

    def start_span(...):
        if parent is Tracer.CURRENT_SPAN:
            parent = self.get_current_span()

        parent_context = parent
        if isinstance(parent_context, trace_api.Span):
            parent_context = parent.get_context()
        
				if parent_context is None:
            parent_context = Context.value(
              Context.current(),
              trace.propagation.ContextKeys.span_context_key()
            )
        ...
        sc = trace_api.SpanContext(
            parent_context.trace_id,
            generate_span_id(),
            parent_context.trace_options,
            parent_context.trace_state
        )
        span = Span(name=name, context=sc)
        Context.set_value(Context.current(), ContextKeys.span_context_key(), sc)

The scope of current context

def do_something():
    Context.set_value(empty_ctx, "say-something", "foo")
  
def scope_things():
    empty_ctx = Context.current()
    Context.set_value("say-something", "foo")
    second_ctx = Context.current()
    print(Context.value("say-something"))  # prints "foo"

    do_something()

    third_ctx = Context.current()
    print(Context.value("say-something"))  # prints "bar"

    print(empty_ctx.get("say-something"))  # prints ""
    print(second_ctx.get("say-something"))  # prints "foo"
    print(third_ctx.get("say-something"))  # prints "bar"

Referencing multiple contexts

merged_ctx = merge_context_correlation(Context.current(), other_ctx)
Context.set_current(merged_ctx)

Falling back to explicit contexts

# Most of the time, the extract function operates on the current context.
extract(headers)

# When a context needs to be extracted without changing the current 
# context, fall back to the explicit API.
extract(headers, context=other_context)

@codeboten codeboten added the WIP Work in progress label Dec 10, 2019
@codeboten codeboten changed the title Context prop [WIP] Context Prop Dec 10, 2019
Alex Boten added 11 commits December 10, 2019 10:01
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Copy link
Member

@toumorokoshi toumorokoshi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left comments around a couple things, but overall I think the implementation matches up a lot of what exists in the spec (or will).

I'm going to call out the main topics for discussion, and I don't see this PR addressing them completely yet:

Named Tracers and Spans from Extractor

As I mentioned in the comments, right now named tracers namespace their active spans, while extractor sets it on the unnamed tracer. And there's no guarantee that the unnamed tracer will be what people use. Theoretically it shouldn't be for integrations (e.g. wsgi and requests), which will probably be the ones responsible for extracting / injecting values into and from the current span.

Here's my thoughts:

  1. Create a new context that merges the extracted values with the existing context
  2. Start a new span with the information from the context object. This should work fine with create_span using the current span as the parent.

I'd argue here that, to make things make sense, the named tracer would still need to write to the same context key that everything else does. This mean that practically, the "name" of the tracer is manifested as a key that is included in the SpanContext, to be processed. It would not be possible to have multiple active spans, one per tracer instance. One could still have multiple active spans by virtue of multiple active contexts.

Context propagating across thread boundaries

This can be addressed separately, but I don't believe there's anything in the code that ensures that spawned threads will always use a context copied from the context of the thread they originated from. Although I think it only affects complex scenarios such a traced request spawning worker threads that live past the existing thread / span.

I'm noting request changes primarily to highlight a few of the areas I'm concerned about. But overall I think it's a huge step forward!

from opentelemetry.baggage import BaggageManager


def configure_opentelemetry(flask_app: flask.Flask):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whole function looks good to me.

def hello():
tracer = trace.tracer()
tracer.add_span_processor(BatchExportSpanProcessor(ConsoleSpanExporter()))
with propagation.extract(request.headers):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this differs from the example you put in the description. Is there one that you preferred?

In my opinion, this method looks great, and reduces a lot of boilerplate in comparison with the other example posted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, this is the same as the "Simpler API" example for anyone that doesn't need to use the context explicitly. As I suspect this would be the more common case, I like how much simpler it is.

@@ -78,48 +78,64 @@ def __init__(
self.value = value


class DistributedContext:
class CorrelationContext:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if you saw the latest memo in the context-propagation chat room, but @tedsuo is planning on modifying the OTEP to only include CorrelationContext, which baggage will utilize.

So could remove the baggage module now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

@@ -319,7 +323,7 @@ def __init__(

def get_current_span(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we support methods like these, instead of making it static?

Thinking about it a little bit, this might be necessary because we have named tracers?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally the spec calls for making methods static that manipulate the Context. I'm thinking through the ramifications but I do you have any thoughts on the Tracer object and it's tendency to abstract interfacing with the context?

Ultimately we will have to change that interface, because extractor / injectors work with the context rather than the tracer.

Copy link
Contributor Author

@codeboten codeboten Dec 17, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's been talk of storing both current span and the extracted span context in the Context. This means this interface will need to change anyways

Gitter conversation: https://gitter.im/open-telemetry/language-agnostic-wg?at=5df8127155d9392300228e87

def with_span_context(
ctx: BaseRuntimeContext, span_context: SpanContext
) -> BaseRuntimeContext:
return Context.set_value(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where I think there'll be an issue with named tracers. The named tracer object assumes that it will set the context at a specific slot, while this method uses the Context.span_context_key(), which probably doesn't align with whatever slot the named tracer has taken.

So unless the main Tracer matches the slot named defined in span_context_key, you will be manipulating a span that will have no impact on the tracer object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, based on the spec meeting this morning, it sounds like there's still some discussions as to the direction of named tracers. Not sure what the right thing to do is here.

ctx = Context()
cls.set_current(ctx)
snapshot = Context()
snapshot.contents = copy.deepcopy(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this I believe does ensure the desired behavior specified in the spec, but deepcopies can get very expensive.

I know the Go implementation does a copy-on-write implementation. Another alternative I could think of is a parented context, that reached into the parent if the child doesn't have the key in question.

Thoughts on any of that? I want to outline the problems and I'll try to come back with something more valuable later :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on deepcopies being too expensive, will take a look at the go implementation.

return cls.current()

@classmethod
def current(cls) -> "Context":
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is the right method name. It does quite a bit more than just get to the current context, as it creates a copy and returns that, without replacing the existing context.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, something like snapshot might be more accurate here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for snapshot.

Alex Boten added 2 commits December 16, 2019 10:28
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Alex Boten added 10 commits December 16, 2019 14:41
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
extractors = get_http_extractors()

for extractor in extractors:
# TODO: improve this
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add try/except. return on the first successful extraction

Copy link
Member

@mauriciovasquezbernal mauriciovasquezbernal Jan 21, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about if there are contexts for multiple concerns on the same carrier?
I think this should loop through all the extractors logging an error/warning when one fails.

"test_span4",
"test_span5",
"futures_test",
],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add test to check if parent is futures_test for each test_span

Copy link
Member

@c24t c24t left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be possible to simplify the context API. Naively, I'd expect this API to be similar to the java prototype but wrapping ContextVar instead of io.grpc.Context.

The existing *RuntimeContext classes are complicated in part because they were meant to expose a nice dict-like interface to the user. If we're hiding them behind Context we probably don't want to keep these classes as they are now.

I only looked at the context API here, more reviews to come.

return self.snapshot.get(key)

@classmethod
def value(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the benefit of making these static if we're passing the context instance as an arg anyway? Calling this as context.value('key') seems better than Context.value('key', context).

On the other hand, if you expect most calls not to include the context, context_module.current().value('key') is more complicated than Context.value('key'), but in that case I'd argue for adding a convenience method.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is also context.get('key') 🤔

_CONTEXT[key] = kwargs[key]
yield
for key in kwargs:
_CONTEXT[key] = snapshot[key]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it matter that this wouldn't delete keys set inside the block?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also if a non referenced slot in kwargs is changed, it won' t be restored.

from opentelemetry.context import Context

Context.set_value('a', 'xxx')
Context.set_value('b', 'yyy')

print(Context.current().snapshot)
with Context.use(a='foo'):
    print(Context.current().snapshot)
    Context.set_value('a', 'i_want_to_mess_it_but_wont_work')
    Context.set_value('b', 'i_want_to_mess_it')
print(Context.current().snapshot)

Prints:

{'a': 'xxx', 'b': 'yyy'}
{'a': 'foo', 'b': 'yyy'}
{'a': 'xxx', 'b': 'i_want_to_mess_it'}

_CONTEXT = ThreadLocalRuntimeContext()


class Context:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be one too many layers of context API onion.

As I understand it, the point of adding this module in #57 was largely the same as in this PR: to provide a single global context, and hide access to the context behind an API so we could swap out the implementation as needed.

If we didn't have to worry about the contextvars package missing (or exotic 3p threading models, about which more later), this could have reasonably been written without aContext object at all. In that case opentelemetry.context would provide two methods:

  • get_current() -> ContextVar[Dict[str, object]]
  • set_current(context: ContextVar[Dict[str, object]]) -> None

or, hiding the contextvar:

  • get_current() -> Dict[str, object]
  • set_current(context: Dict[str, object]) -> None

As compared to this PR, what would we lose with this simpler approach?

What would we lose if AsyncRuntimeContext and ThreadLocalRuntimeContext implemented Context, instead of Context being composed of one of these?


class Context:
def __init__(self) -> None:
self.snapshot = _CONTEXT.snapshot()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understood it, the point of using threadlocals and ContextVars was to avoid this kind of manual context management. If the context is backed by a threadlocal we should get a new copy for each thread we spawn, if it's backed by a ContextVar we should get a new copy for each new async context. When would we need to copy this ourselves?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make that Context object immutable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, a new copy would be made available within a new thread or async context. But as @mauriciovasquezbernal's reply says, the spec suggests in the scope example: https://github.com/open-telemetry/oteps/blob/master/text/0066-separate-context-propagation.md#the-scope-of-current-context get_current returns an immutable Context.

@classmethod
@contextmanager
def use(cls, **kwargs: typing.Dict[str, object]) -> typing.Iterator[None]:
snapshot = {key: _CONTEXT[key] for key in kwargs}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gets a lot simpler using a single ContextVar to store the context:

def use(cls, **kwargs: typing.Dict[str, object]) -> typing.Iterator[None]:
    token = cv.set(kwargs)
    try:
        yield
    finally:
        cv.reset(token)

which may be one argument for letting AsyncRuntimeContext implement Context.

Copy link
Member

@mauriciovasquezbernal mauriciovasquezbernal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm submitting a partial review, I haven't reviewed all the code yet.
So far it looks quite good to me!


class Context:
def __init__(self) -> None:
self.snapshot = _CONTEXT.snapshot()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make that Context object immutable?

return self.snapshot.get(key)

@classmethod
def value(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is also context.get('key') 🤔

return None

@classmethod
def set_value(cls, key: str, value: "object") -> "Context":

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that there is a discrepancy between value() and set_value().
Why there is a context argument in value but is is missing in set_value?.

What about if you want to set a value on a context different than the current one?, I think the following two options should be allowed:

# provide explicit context
ctx1 = it_does_matter_where_i_get_it_from
ctx2 = Context.set_value("name", "mauricio", ctx1)

# work with current context
ctx3 = Context.set_value("name", "mauricio")


return call_with_current_context

def apply(self, ctx: "Context") -> None:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Context is immutable this method should not exist, perhaps it should be a class method intended to be used on the current context?

Would it make sense to have a non-class method def merge(self, ctx: "Context") -> "Context" that doesn't modify the context object?

_CONTEXT[key] = kwargs[key]
yield
for key in kwargs:
_CONTEXT[key] = snapshot[key]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also if a non referenced slot in kwargs is changed, it won' t be restored.

from opentelemetry.context import Context

Context.set_value('a', 'xxx')
Context.set_value('b', 'yyy')

print(Context.current().snapshot)
with Context.use(a='foo'):
    print(Context.current().snapshot)
    Context.set_value('a', 'i_want_to_mess_it_but_wont_work')
    Context.set_value('b', 'i_want_to_mess_it')
print(Context.current().snapshot)

Prints:

{'a': 'xxx', 'b': 'yyy'}
{'a': 'foo', 'b': 'yyy'}
{'a': 'xxx', 'b': 'i_want_to_mess_it'}

@@ -138,15 +138,119 @@ def __repr__(self):
asyncio.run(main())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation above has to be updated as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the restore issue. Will update the docs once the API changes are closer to done

def extract(
cls,
carrier,
context: typing.Optional[Context] = None,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the context parameter be passed down to with_span_context() so it can use it to decide if set the span context in the current context or in the passed context?

return INVALID_SPAN_CONTEXT


def with_span_context(span_context: SpanContext) -> Context:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a similar comment in the http trace extract: Shouldn't this function receive a context parameter to set the span on this? The parameter could be optional and if context is None the current one could be used.

Copy link
Member

@c24t c24t left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more round of comments on inject/extract. I still have some big open questions about explicit context propagation, but this is more because I don't understand the intent of the spec than because of any change here.

Still TODO: review correlation context.

Setter,
)

__all__ = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it predates this change, but AFAICT we don't actually need __all__ here: #213 (comment).

@@ -15,17 +15,19 @@
import abc
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does "format" still have any meaning? Would we lose anything by renaming this module http.py?

What about "text"? It doesn't look like the API distinguishes between text- and byte-valued carriers, is there any reason we still have binaryformat.py?



class HTTPTextFormat(abc.ABC):
class HTTPExtractor(abc.ABC):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a question for the spec, but what makes these "HTTP" extractor/injectors? It doesn't look like there's anything specific to HTTP in here, and the spec defines inject and extract generically enough to apply to any protocol.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I'll rename the API from HTTPExtractor and HTTPInjector to Extractor and Injector, since it is generic enough at this point. I'll also move that interface into the propagation/__init__.py module directly. This can be changed later if it no longer makes sense, once the spec is flushed out for the propagation API.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are three things because of which "HTTP" kinda makes sense

  1. To distinguish them from binary injectors (HTTP implies text based).
  2. To distinguish them from injectors which just return a single string object (namely the binary ones) (HTTP injectors are based on key-value maps)
  3. (Probably) to indicate the that the injector uses only HTTP-compatible keys and values (e.g. ASCII, starts with a-zA-Z, contains no spaces)

Maybe TextMapExtractor would be a better name. I think the 3rd point does not necessarily need to be in the interface. If we still want that, OpenTracing-cpp had an empty interface for HTTPHeader deriving from the corresponding TextMap interface (e.g. https://github.com/opentracing/opentracing-cpp/blob/4bb431f7728eaf383a07e86f9754a5b67575dab0/include/opentracing/propagation.h#L176), we could do the same so we could check isinstance(myTextMapExtractor, HTTPExtractor).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call @Oberon00, I missed that Getter and Setter specified str keys.

How would you expect the binary versions to work now? They're not specified in https://github.com/tedsuo/rfcs/blob/context-prop-2/text/0066-separate-context-propagation.md#Propagation-API, but the API changes to (HTTP) injectors/extractors would seem to make https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/api-propagators.md#binary-format obsolete.

What would it look like ff we split this up into http/text and binary versions? Something like this?

TextSetter = typing.Callable[[CarrierT, str, str], None]
TextGetter = typing.Callable[[CarrierT, str], Sequence[str]]
BinarySetter = typing.Callable[[CarrierT, T], None]
BinaryGetter = typing.Callable[[CarrierT], T]

(Note that I changed ContextT here to CarrierT to make it clear this is the carrier arg.)

Under the old spec, T would be a SpanContext or DistributedContext, and we set/get all fields in the carrier at once. Under the new spec, T could be any "concern".

It's not clear to me why we shouldn't be able to set/get individual keys for binary carriers as well as text carriers. Now that the carrier is generic, what would we lose by making the key and value types generic as well?

context: typing.Optional[Context] = None,
set_in_carrier: typing.Optional[Setter[ContextT]] = None,
) -> None:
"""Inject values from a SpanContext into a carrier.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder that these docs need to be updated.



# TODO: can this be removed until it is needed?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

AFAICT we don't even need separate text and binary "formats" with the new API.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

carrier: ContextT,
context: typing.Optional[Context] = None,
extractors: typing.Optional[
typing.List[httptextformat.HTTPExtractor]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this should probably be Sequence, like many of our other Lists in type annotations.

def extract(
carrier: ContextT,
context: typing.Optional[Context] = None,
extractors: typing.Optional[
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a use case for injecting/extracting with a different set of injector/extractors than the global one ?



_HTTP_TEXT_INJECTORS = [
DefaultHTTPInjector
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these are only used in inject and extract, what's the difference between an empty list and a list with a single no-op injector/extractor?

@@ -93,9 +99,120 @@ def extract(

"""


class HTTPInjector(abc.ABC):
@classmethod
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why make these classmethods? Is it an error to ever instantiate an injector/extractor?

@@ -416,7 +417,9 @@ class Tracer:
# This is the default behavior when creating spans.
CURRENT_SPAN = Span()

def get_current_span(self) -> "Span":
def get_current_span(
self, context: typing.Optional[Context] = None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand it, returning the context here (and accepting it as an arg below) is required for "explicit" context propagation. But I still don't understand the use case for maintaining multiple contexts, and since the rest of the code assumes implicit (or "automatic" as per the spec) context propagation, I suspect there are more changes we'd have to make to make this work.

Alex Boten added 5 commits January 20, 2020 09:37
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Copy link
Member

@mauriciovasquezbernal mauriciovasquezbernal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another round of general comments.

injectors: typing.Optional[
typing.List[httptextformat.HTTPInjector]
] = None,
context: typing.Optional[Context] = None,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is set_in_carrier missing in this function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Good catch, thanks!

headers. Should be paired with set_in_carrier, which
should know how to set header values on the carrier.
"""
if context is None:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noob question: couldn't it be done using default values in the function arguments?

injector.inject(context=context, carrier=carrier)


_HTTP_TEXT_INJECTORS = [

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any specific reason to use a list instead of a tuple?


__all__ = ["BinaryFormat", "HTTPTextFormat"]
def set_in_dict(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that it's not used anywhere in the API and the default values for getter are None in the API.
Is this and get_as_list like helper functions that the API provide?

if version == "2.0":
return fetch_from_service_c()

return fetch_from_service_b()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should have one more indentation level.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

KEY = "correlation-context"

@classmethod
def span_context_key(cls) -> str:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is span_context_key the right name?


On exiting, the context manager will restore the parent
DistributedContext.
@classmethod

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it needed to implement these methods?

Alex Boten added 3 commits January 20, 2020 13:51
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
@@ -507,7 +518,7 @@ def __init__(
):
# TODO: How should multiple TracerSources behave? Should they get their own contexts?
# This could be done by adding `str(id(self))` to the slot name.
self._current_span_slot = Context.register_slot("current_span")
self._current_span_name = "current_span"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is something wrong, are there two slots with an almost equal name?, current-span and current_span?

@@ -529,8 +540,9 @@ def get_tracer(
),
)

def get_current_span(self) -> Span:
return self._current_span_slot.get()
def get_current_span(self, context: Optional[Context] = None) -> Span:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it use span_from_context?

carrier,
context: typing.Optional[Context] = None,
get_from_carrier: typing.Optional[Getter[_T]] = get_as_list,
):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing return type annotation?

carrier: ContextT,
context: typing.Optional[Context] = None,
set_in_carrier: typing.Optional[Setter[ContextT]] = None,
) -> None:
Copy link
Member

@mauriciovasquezbernal mauriciovasquezbernal Jan 21, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the OTEP it should return the carrier. Do you think it should return it? I'm not able to understand the motivation of returning them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question. Looking at the examples, it looks like the simplified API injects directly into the carrier. but the previous example shows it setting the request header to the return value.

Changes the Context to be an ABC that is implemented by BaseContext which is then extended by ThreadLocalRuntimeContext and AsyncRuntimeContext.

Signed-off-by: Alex Boten <aboten@lightstep.com>
@codeboten codeboten marked this pull request as ready for review January 21, 2020 17:26
@codeboten codeboten requested a review from a team January 21, 2020 17:26
@@ -15,52 +15,48 @@
import typing
from contextlib import contextmanager

from opentelemetry import distributedcontext as dctx_api
from opentelemetry import correlationcontext as dctx_api

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should dctx_api be renamed to something else?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

"test_span5",
]

def do_some_work(self, name):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest to rename this as there is already do_work() defined above.



class TestContext(unittest.TestCase):
spans = [

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be span_names a better name?

spans_names_list = [span.name for span in span_list]
self.assertListEqual(
[
"test_span1",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this test has to be reworked to avoid testing the order of the finished spans as it is random.

# Start the load operations
for span in self.spans:
executor.submit(
contextvars.copy_context().run,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if ThreadLocalRuntimeContext is used instead of AsyncRuntimeContext?
Context includes with_current_context, why not to use that?

def test_propagation(self):
pass

def test_with_futures(self):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this should use asyncio instead of threads?

Alex Boten added 2 commits January 21, 2020 10:58
Signed-off-by: Alex Boten <aboten@lightstep.com>
Signed-off-by: Alex Boten <aboten@lightstep.com>
Copy link
Member

@mauriciovasquezbernal mauriciovasquezbernal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the fact that we provide automatic context and some functions (like set_value(), value(), etc) are used on the current or in a different context creates some confusion.

The current approach is to define a Context class and define some methods that receive a context parameter, if the value passed is None then the operation is performed on the current context, otherwise it is performed on the passed object. I think this class is getting complicated as it handles both cases, accessing the current context and accessing a snapshot of it.

As @c24t already pointed above, maybe it could be better to implement such methods directly on the module and not in the Context class and have a Context / ContextView class that is a immutable snapshot of the context.

I created the following pseudo code to clarify my ideas, I think a similar approach could work.

# place to save the current context
_slots = {} ....


def set_value(key, value, context = None):
  # Function inside the module that performs the action on the current context
  # or in the passsed one based on the context object
  if context:
    ret = Context()
    ret.snapshot = context.snapshot # deep copy to avoid having the same snapshot in both
    ret.snapshot[key] = value
    return ret
  
  # update value on current context:
  slot = _register_slot(key)
  slot.set(value)
  return current()
  
  
def value(key, context = None):
  if context:
    return context.value(key)
    
  # get context from current context
  slot = slots[key]
  return slot.get()


def current():
  ret = Context()
  for key, value in _slots:  
    ret.snapshot[key] = value.get()
  
  return ret


def set_current(context):
  slots.cleart() # remove current data
  
  for key, value in current.snapshot:
    slots[key] = value

def _register_slot():
  # creates a slot based on the implmentation to use, contextvars or threadlocal


    
class Context:
  # Context represents a snapshot of the context, basically wraps a dict object
  def __init__():
    snapshot = {}

  def value(key):
    return snapshot[key]

  #def set_value() not implemented as Context is immutable

"""


class BaseContext(Context):
class Slot:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it needed to define Slot inside BaseContext? I personally find it easier to understand if Slot is an independent (abstract) class and it is implemented by ThreadLocalRuntimeContext & AsyncRuntimeContext.

API provides a function which takes a Context.
"""
keys = self._slots.keys()
for name in keys:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really needed to clear all the slots before removing them?

span = span_from_context(context=context)
if span:
return span.get_context()
sc = current().value(ContextKeys.span_context_key(), context=context) # type: ignore

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

current() returns a snapshot of the current context but only a key/slot is used, shouldn't be a more straightforward way to get a single "slot" without creating a full snapshot?



def with_span(span: Span, context: Optional[Context] = None) -> Context:
return current().set_value(ContextKeys.span_key(), span, context=context)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why to get a snapshot of the current context when the context parameter is passed?
Shouldn't it be only something like return context.set_value() in that case?

self[key] = snapshot[key]
def value(
self, key: str, context: typing.Optional["Context"] = None
) -> typing.Optional["object"]:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this method rather confusing. It's part of class BaseContext that implements Context, it receives a context parameter of type Context and, if context is passed the value is got from that, otherwise from self.

In other words, A.do_work(par=B) is actually doing work on B and not in A.

@@ -67,64 +147,98 @@ def register_slot(
cls._slots[name] = cls.Slot(name, default)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slot could be replaced by a variable that is set to AsyncRuntimeContext or ThreadLocalRuntimeContext based on a falling back mechanism similar to the one implemented in new_context().

return dest.merge(source)


_CONTEXT = new_context()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably you don't need to have an object here but the type of context to use.

@codeboten
Copy link
Contributor Author

After spending a bunch of time on this, I agree that it's confusing for the same code to try and behave in two different ways. I was trying to keep the Context API as an abstract class to make it easier to move the implementation in the SDK down the road, but I'm not sure the added level of complexity makes sense here and there's alternative approaches to accomplishing this. Will submit an update to the PR shortly, which implements what @c24t, @ocelotl and @mauriciovasquezbernal are suggesting. Thanks for all the feedback.

Simplifying the Context API implementation significantly:
- removing multiple layers of Context classes, replacing it with `context` module level functions
- moved Slot classi out of BaseContext
- broke off threads/futures test into separate tests files, which allowed me to move test_context back into the api package where it belongs

Signed-off-by: Alex Boten <aboten@lightstep.com>
Copy link
Member

@mauriciovasquezbernal mauriciovasquezbernal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the last changes in the context part and it looks good to me, just few nits.

return ret

# update value on current context:
slot = _register_slot(name)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid taking the lock when updating slots that are registered already?
Maybe a conditional here to call _register_slot() only on new ones?

self.contextvar.set(value)

class AsyncRuntimeContext(base_context.Context):
def with_current_context(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this method has to be different for both implementations.

Wouldn't an implementation like the following work?

def with_current_context(
self, func: typing.Callable[..., "object"]
) -> typing.Callable[..., "object"]:
"""Capture the current context and apply it to the provided func.
"""
caller_context = self.snapshot()
def call_with_current_context(
*args: "object", **kwargs: "object"
) -> "object":
try:
backup_context = self.snapshot()
self.apply(caller_context)
return func(*args, **kwargs)
finally:
self.apply(backup_context)
return call_with_current_context

_slots.clear() # remove current data

for key, val in context.snapshot.items():
slot = _register_slot(key)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: it could be optimized by taking the lock once and having a lock-less function that registers the slot. For instance

slot = _slot_class(key)
slot.set(val)
_slots[key] = slot

@codeboten codeboten closed this Jan 30, 2020
@codeboten codeboten deleted the context-prop branch October 9, 2020 18:50
srikanthccv pushed a commit to srikanthccv/opentelemetry-python that referenced this pull request Nov 1, 2020
* fix(grpc): fix client/server span propagation

* fix(test): uncomment grpc patch

* fix: linting, add missing unwrap

* docs(grpc): add supported versions to readme
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
WIP Work in progress
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants