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

NH-2313 Add basic TraceState handling and W3C trace context propagation #11

Merged
merged 78 commits into from
May 9, 2022
Merged
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
23e4f8e
Add stub for SolarWindsFormat custom propagator
tammy-baylis-swi Feb 25, 2022
b591d4f
(WIP) tracestate sometimes gets updated with span_id
tammy-baylis-swi Feb 26, 2022
9ceb4df
Add helpers to format span_id, trace_flags
tammy-baylis-swi Feb 28, 2022
a5701a1
Add temp manual trace_state update logic
tammy-baylis-swi Feb 28, 2022
a6533bc
Rm SolarWindsFormat.extract duplicate code from TraceContextTextMapPr…
tammy-baylis-swi Mar 1, 2022
05b8887
Fix propagator use of tracestate.update
tammy-baylis-swi Mar 8, 2022
a7c6244
Rename to SwSampler, WIP decision-tracestate-attributes management
tammy-baylis-swi Mar 8, 2022
d91e276
WIP attributes
tammy-baylis-swi Mar 9, 2022
ae29003
Rm unnecessary attributes touching by Propagator
tammy-baylis-swi Mar 9, 2022
394ae52
SwSampler adds/updates sw.w3c.tracestate, sw.tracestate_parent_id
tammy-baylis-swi Mar 9, 2022
f6ff7f4
SwSampler adds/updates sw.parent_span_id
tammy-baylis-swi Mar 9, 2022
c380527
Refactor/Break up _SwSampler.should_sample into several helpers
tammy-baylis-swi Mar 10, 2022
5659f5b
Fix format of args sent to Context::getDecisions
tammy-baylis-swi Mar 10, 2022
f17b2b8
Fix duplication of sw.parent_span_id by Sampler; stop invalid attribu…
tammy-baylis-swi Mar 11, 2022
52e655a
install_requires less strict for Django ASGI support
tammy-baylis-swi Mar 14, 2022
69a487a
Mv SolarWindsFormat to SolarWindsPropagator
tammy-baylis-swi Mar 16, 2022
cb5800e
Mv ot_ao_transformer to w3c_transformer, used by Exporter Sampler Pro…
tammy-baylis-swi Mar 16, 2022
537cf0a
Simplify variables used in Propagator.inject
tammy-baylis-swi Mar 17, 2022
88529a1
Refactor of _SwSampler
tammy-baylis-swi Mar 17, 2022
761e34f
Rm ununsed variables
tammy-baylis-swi Mar 17, 2022
07ec508
Fix trace_flags and some log formatting
tammy-baylis-swi Mar 17, 2022
77dbfb6
Rm f-strings for older Python versions
tammy-baylis-swi Mar 17, 2022
c4ca055
Mv tracestate sw value format checks to _SwSampler.calculate_liboboe_…
tammy-baylis-swi Mar 17, 2022
30899bf
Misc fixups for style and comments
tammy-baylis-swi Mar 17, 2022
7e4e8c9
Sampler lets liboboe do all the decision-making
tammy-baylis-swi Mar 18, 2022
d9c0e63
Fix flag format at trace_state and attribute update
tammy-baylis-swi Mar 21, 2022
5204536
Update Propagator debug logs for clarity
tammy-baylis-swi Mar 22, 2022
12d4cbd
Fix sw.tracestate_parent_id assignment
tammy-baylis-swi Mar 22, 2022
bd315a6
Preserve non-vendor values of injected tracestate header
tammy-baylis-swi Mar 22, 2022
8f5a215
Add TraceOptions class and an init with headers from propagator.extract
tammy-baylis-swi Mar 23, 2022
60522b7
Mv TraceOptions to XTraceOptions; add from_context and iter,str overl…
tammy-baylis-swi Mar 23, 2022
3e175ba
Sampler passes xtraceoptions from otel context to liboboe
tammy-baylis-swi Mar 24, 2022
38639c9
Sampler passes liboboe tracestate from header tracestate, not tracepa…
tammy-baylis-swi Mar 24, 2022
f95bf46
Fix XTraceOptions init when other headers provided
tammy-baylis-swi Mar 24, 2022
af11112
Add TT signature handling; Sampler gives liboboe None if parent not v…
tammy-baylis-swi Mar 28, 2022
322949b
Add SolarWindsResponsePropagator (wip); up otel-instrumentation version
tammy-baylis-swi Mar 30, 2022
ff201c7
Sampler creates xtraceoptions_response for tracestate piggyback; Resp…
tammy-baylis-swi Apr 1, 2022
05f03c7
Fix sampler tracestate creation; mv XTraceOptions str overload to to_…
tammy-baylis-swi Apr 4, 2022
3b62bc5
Sampler create x-trace-options-response if parent tracestate exists
tammy-baylis-swi Apr 4, 2022
1dfd489
Fix bug in to_options_header
tammy-baylis-swi Apr 4, 2022
99d2fc4
Add W3CTransformer classmethods
tammy-baylis-swi Apr 5, 2022
32df3d0
Fix Sampler OBOE_SETTINGS_UNSET of tracing_mode, sample_rate, trigger…
tammy-baylis-swi Apr 6, 2022
7444d43
String formatting a bit less brittle
tammy-baylis-swi Apr 6, 2022
84a863e
Fix trace_state KV add at inject
tammy-baylis-swi Apr 6, 2022
de88565
Style and comment for ResponsePropagator
tammy-baylis-swi Apr 6, 2022
ec2c317
Python 3.6+, otel SDK 1.10.0, instrumentation 0.29.b0
tammy-baylis-swi Apr 8, 2022
59b1bac
xtraceoptions log lines to debug level
tammy-baylis-swi Apr 8, 2022
45f1910
Assign several keys/attrs as constants
tammy-baylis-swi Apr 9, 2022
552398c
Fix sw.tracestate_parent_id, sw.w3c.tracestate KV/attribute creation
tammy-baylis-swi Apr 11, 2022
58acd70
Rm _LiboboeDecision class to use dict instead
tammy-baylis-swi Apr 11, 2022
783cac0
distro set_global_textmap hardcode instead of env var for OTEL_PROPAG…
tammy-baylis-swi Apr 11, 2022
b58e29b
traceparent_from_context returns lowercase
tammy-baylis-swi Apr 11, 2022
b08517e
Fixups: variable assignments, rm ununsed param
tammy-baylis-swi Apr 11, 2022
f7f487a
Fix comment
tammy-baylis-swi Apr 12, 2022
3aad13d
NH-11236 sampler creates Service Entry Internal KVs
tammy-baylis-swi Apr 14, 2022
ffccf28
SWKeys KV added to root span, sw_keys always str, trigger_trace as in…
tammy-baylis-swi Apr 14, 2022
3a90bdb
Simplify x-trace-options handling
tammy-baylis-swi Apr 18, 2022
118c1bc
Sampler always sets SWKeys KV if available; calculate_attributes refa…
tammy-baylis-swi Apr 18, 2022
23a12a2
Sampler sets bucket/sample KVs if liboboe decision not cont'd
tammy-baylis-swi Apr 18, 2022
526df98
Merge branch 'add-custom-propagator' into NH-11236-service-entry-inte…
tammy-baylis-swi Apr 18, 2022
7077e6a
Latest otel 1.11.0/0.30b0, rename entry point to solarwinds_propagato…
tammy-baylis-swi Apr 19, 2022
f9171c8
Exporter and Propagator(s) are env var configurable
tammy-baylis-swi Apr 19, 2022
0aef14c
NH-9150 Use oboe_api fns for no default timestamp
tammy-baylis-swi Apr 21, 2022
c77afc9
Merge pull request #13 from appoptics/nh-9150-use-oboe-api-no-default…
tammy-baylis-swi Apr 21, 2022
68a0e63
Merge pull request #12 from appoptics/NH-11236-service-entry-internal…
tammy-baylis-swi Apr 22, 2022
7b75e65
Separate concerns: env vars in distro, component init in configurator
tammy-baylis-swi Apr 22, 2022
600e5b2
Custom Propagator gets tracestate from carrier in case of upstream pr…
tammy-baylis-swi Apr 27, 2022
ede5686
Call TraceState.from_header correctly
tammy-baylis-swi Apr 28, 2022
2842209
NH-2313 adjust CompositePropagator config with OTEL_PROPAGATORS
tammy-baylis-swi Apr 28, 2022
7ffb1a9
solarwinds_sampler cannot be env default because not in OTel _KNOWN_S…
tammy-baylis-swi Apr 28, 2022
02bde11
Merge pull request #15 from appoptics/NH-2313-adjust-propagator-confi…
tammy-baylis-swi May 2, 2022
54a0e7d
Merge pull request #16 from appoptics/NH-2313-propagator-gets-tracest…
tammy-baylis-swi May 2, 2022
1241e7a
load_entry_point instead of iter for singulars
tammy-baylis-swi May 3, 2022
1b830a1
Merge pull request #14 from appoptics/NH-12018-distro-and-configurato…
tammy-baylis-swi May 4, 2022
73be566
Move _initialize_solarwinds_reporter from Exporter to Configurator
tammy-baylis-swi May 4, 2022
126e030
Fix non-SW sampler,exporter configure
tammy-baylis-swi May 4, 2022
01f1e98
Always use SW Sampler
tammy-baylis-swi May 5, 2022
7e758df
Merge pull request #17 from appoptics/NH-12018-oboe-reporter-in-confi…
tammy-baylis-swi May 5, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions opentelemetry_distro_solarwinds/distro.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from opentelemetry import trace
from opentelemetry.instrumentation.distro import BaseDistro
from opentelemetry.instrumentation.propagators import set_global_response_propagator
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

from opentelemetry_distro_solarwinds.exporter import SolarWindsSpanExporter
from opentelemetry_distro_solarwinds.sampler import ParentBasedAoSampler
from opentelemetry_distro_solarwinds.response_propagator import SolarWindsTraceResponsePropagator
from opentelemetry_distro_solarwinds.sampler import ParentBasedSwSampler


class SolarWindsDistro(BaseDistro):
Expand All @@ -16,9 +18,11 @@ class SolarWindsDistro(BaseDistro):
- no functionality added at this time
"""
def _configure(self, **kwargs):
# automatically make use of custom SolarWinds sampler
# Automatically make use of custom SolarWinds sampler
trace.set_tracer_provider(
TracerProvider(sampler=ParentBasedAoSampler()))
TracerProvider(sampler=ParentBasedSwSampler()))
# Automatically configure the SolarWinds Span exporter
span_exporter = BatchSpanProcessor(SolarWindsSpanExporter())
trace.get_tracer_provider().add_span_processor(span_exporter)
# Set global HTTP response propagator
set_global_response_propagator(SolarWindsTraceResponsePropagator())
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we also set our custom request propagator here, rather than needing the customor to configure it in? https://github.com/open-telemetry/opentelemetry-python-contrib/blob/22b069baaec15b21bc1879874d1a244210bc6e1b/opentelemetry-distro/src/opentelemetry/distro/__init__.py#L34 shows using an environment variable to configure, there's probably also a programmatic way similar to how our custom sampler and exporter is configured.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In 783cac0 I've added this to the distro _configure method to always use a Composite of tracecontext,baggage,solarwinds propagators, instead of needing the customer to set OTEL_PROPAGATORS env var:

set_global_textmap(
    CompositePropagator([
        TraceContextTextMapPropagator(),
        W3CBaggagePropagator(),
        SolarWindsPropagator()
    ])
)

This does prevent the customer from customizing the CompositePropagator further... Actually I don't know how a customer would do this unless they contribute to our custom-distro. Is the above the way we want to go instead of os.environ.setdefault with added checks?

Copy link
Contributor

Choose a reason for hiding this comment

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

Good point re: configurability! It would be more flexible if we allow the customer to configure components such as propagator and exporter -- might introduce more support issues if there's negative interference but one major use case is someone comparing (or even transitioning to) our product could for example use our agent but also keep exporting to another vendor platform.

Digging around, there are quite a few env vars defined by OTel:

And quick scan of these threads seems custom distro configuring via env var is the approach talked about by the community: open-telemetry/opentelemetry-python-contrib#551 and open-telemetry/opentelemetry-python#1937 (comment)

So I think it makes sense to allow the propagator and exporter to be customized by the customer, on top of our distro. No need to support that for the sampler or tracer provider for 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.

Researching and working on this one.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @cheempz I've set up one way to do this in f9171c8 and 7077e6a. What do you think?

I've set up custom-distro to always use tracecontext,baggage,solarwinds_propagator first and in that order. Anything else in OTEL_PROPAGATORS is used to set up the CompositePropagator after them. If any of the propagators can't be loaded, exception is raised. This is the same behaviour as opentelemetry-api: https://github.com/open-telemetry/opentelemetry-python/blob/main/opentelemetry-api/src/opentelemetry/propagate/__init__.py#L132-L144 This means the propagators have to be implemented/properly-mocked ones and not just "foo/bar".

I think OTel Python supports having only one exporter (no composites), so our custom-distro uses OTEL_TRACES_EXPORTER else default to ours with entrypoint name solarwinds_exporter (set in setup.cfg) If whichever exporter is specified can't be loaded, exception is raised like above.

To test this I've updated the testbed in this PR: https://github.com/appoptics/opentelemetry-python-testbed/pull/21 One thing that makes me happy is that we can get customers to install opentelemetry-exporter-otlp-proto-grpc on their end if they want to switch Exporter to otlp_proto_grpc, which is one of the common OTel Python ones: https://opentelemetry.io/docs/instrumentation/python/exporters/

In 7077e6a and the testbed PR I've also upped OTel Python version to 1.11.0/0.30b0 which was released yesterday. In standup I mentioned I thought the update might break custom-distro setup, but not the case! I still updated anyway since we want to keep up with the latest version.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll add the above info to the ongoing NH Python info page: https://swicloud.atlassian.net/wiki/spaces/NIT/pages/2867726621/NH+Python+Troubleshooting

Copy link
Contributor Author

@tammy-baylis-swi tammy-baylis-swi Apr 28, 2022

Choose a reason for hiding this comment

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

UPDATE: This behaviour will change in TWO PRS:
#14
#15

Please see those PRs and their linked test docs. The NH Python info page has also been updated to reflect.

10 changes: 6 additions & 4 deletions opentelemetry_distro_solarwinds/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from opentelemetry_distro_solarwinds.extension.oboe import (Context, Metadata,
Reporter)
from opentelemetry_distro_solarwinds.ot_ao_transformer import transform_id
from opentelemetry_distro_solarwinds.w3c_transformer import W3CTransformer

logger = logging.getLogger(__file__)

Expand All @@ -36,15 +36,15 @@ def export(self, spans):
md = self._build_metadata(span.get_span_context())
if span.parent and span.parent.is_valid:
# If there is a parent, we need to add an edge to this parent to this entry event
logger.debug("Continue trace from %s", md.toString())
logger.debug("Continue trace from {}".format(md.toString()))
parent_md = self._build_metadata(span.parent)
evt = Context.startTrace(md, int(span.start_time / 1000),
parent_md)
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit and inherited code, but let's rename this to something like startSpan / spanEntry and the matching one stopSpan / spanExit or similar, just to clarify this about a span rather than entire trace.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think that's going to be a separate liboboe PR (oboe itself or the SWIG interface only if possible?), related to NH-9150, NH-7246, and this comment: https://swicloud.atlassian.net/wiki/spaces/NIT/pages/2823618801/liboboe+and+OTel+Python+custom-distro?focusedCommentId=2826010703#comment-2826010703

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We've now addressed this in 0aef14c by switching to use the new Context.createEntry functions Jerry added to oboe_api in NH-9150.

else:
# In OpenTelemrtry, there are no events with individual IDs, but only a span ID
# and trace ID. Thus, the entry event needs to be generated such that it has the
# same op ID as the span ID of the OTel span.
logger.debug("Start a new trace %s", md.toString())
logger.debug("Start a new trace {}".format(md.toString()))
evt = Context.startTrace(md, int(span.start_time / 1000))
evt.addInfo('Layer', span.name)
evt.addInfo('Language', 'Python')
Expand Down Expand Up @@ -122,4 +122,6 @@ def _initialize_solarwinds_reporter(self):

@staticmethod
def _build_metadata(span_context):
return Metadata.fromString(transform_id(span_context))
return Metadata.fromString(
W3CTransformer.traceparent_from_context(span_context)
)
17 changes: 0 additions & 17 deletions opentelemetry_distro_solarwinds/ot_ao_transformer.py

This file was deleted.

100 changes: 100 additions & 0 deletions opentelemetry_distro_solarwinds/propagator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import logging
import typing

from opentelemetry import trace
from opentelemetry.context.context import Context
from opentelemetry.propagators import textmap
from opentelemetry.trace.span import TraceState

from opentelemetry_distro_solarwinds.traceoptions import XTraceOptions
from opentelemetry_distro_solarwinds.w3c_transformer import W3CTransformer

logger = logging.getLogger(__file__)

class SolarWindsPropagator(textmap.TextMapPropagator):
"""Extracts and injects SolarWinds headers for trace propagation.
Must be used in composite with TraceContextTextMapPropagator.
"""
_TRACESTATE_HEADER_NAME = "tracestate"
_XTRACEOPTIONS_HEADER_NAME = "x-trace-options"
_XTRACEOPTIONS_SIGNATURE_HEADER_NAME = "x-trace-options-signature"
Copy link

Choose a reason for hiding this comment

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

I a little bit forget the header name: is it all lowercased x-trace-options-signature or something like X-Trace-Options-Signature or it doesn't actually matter?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As Alex mentioned in standup, field names are case-insensitive: https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2

I also did a quick test by making requests to the instrumented testbed Django A app with either x-trace-options-signature header or X-Trace-Options-Signature. Both resulted in traces so I think it's ok and doesn't matter.


def extract(
self,
carrier: textmap.CarrierT,
context: typing.Optional[Context] = None,
getter: textmap.Getter = textmap.default_getter,
) -> Context:
"""Extracts sw trace options and signature from carrier into OTel
Context. Note: tracestate is extracted by TraceContextTextMapPropagator
"""
if context is None:
context = Context()

xtraceoptions_header = getter.get(
carrier,
self._XTRACEOPTIONS_HEADER_NAME
)
if not xtraceoptions_header:
logger.debug("No xtraceoptions to extract; ignoring signature")
return context
logger.debug("Extracted xtraceoptions_header: {}".format(
xtraceoptions_header[0]
))

signature_header = getter.get(
carrier,
self._XTRACEOPTIONS_SIGNATURE_HEADER_NAME
)
if signature_header:
xtraceoptions = XTraceOptions(
context,
xtraceoptions_header[0],
signature_header[0],
)
else:
xtraceoptions = XTraceOptions(
context,
xtraceoptions_header[0],
)
context.update(dict(xtraceoptions))
return context

def inject(
self,
carrier: textmap.CarrierT,
context: typing.Optional[Context] = None,
setter: textmap.Setter = textmap.default_setter,
) -> None:
"""Injects sw tracestate and trace options from SpanContext into carrier for HTTP request"""
span = trace.get_current_span(context)
span_context = span.get_span_context()
trace_state = span_context.trace_state
sw_value = W3CTransformer.sw_from_context(span_context)

# Prepare carrier with context's or new tracestate
if trace_state:
# Check if trace_state already contains sw KV
if "sw" in trace_state.keys():
# If so, modify current span_id and trace_flags, and move to beginning of list
logger.debug("Updating trace state for injection with {}".format(sw_value))
trace_state = trace_state.update("sw", sw_value)

else:
# If not, add sw KV to beginning of list
logger.debug("Adding KV to trace state for injection with {}".format(sw_value))
trace_state = trace_state.add("sw", sw_value)
else:
logger.debug("Creating new trace state for injection with {}".format(sw_value))
trace_state = TraceState([("sw", sw_value)])

setter.set(
carrier, self._TRACESTATE_HEADER_NAME, trace_state.to_header()
)

@property
def fields(self) -> typing.Set[str]:
"""Returns a set with the fields set in `inject`"""
return {
self._TRACESTATE_HEADER_NAME
}
74 changes: 74 additions & 0 deletions opentelemetry_distro_solarwinds/response_propagator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import logging
import typing

from opentelemetry import trace
from opentelemetry.context.context import Context
from opentelemetry.instrumentation.propagators import ResponsePropagator
from opentelemetry.propagators import textmap
from opentelemetry.trace.span import TraceState

from opentelemetry_distro_solarwinds.traceoptions import XTraceOptions
from opentelemetry_distro_solarwinds.w3c_transformer import W3CTransformer

logger = logging.getLogger(__file__)

class SolarWindsTraceResponsePropagator(ResponsePropagator):
"""Propagator that injects SW values into HTTP responses"""
_HTTP_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers"
_XTRACE_HEADER_NAME = "x-trace"
_XTRACEOPTIONS_RESPONSE_HEADER_NAME = "x-trace-options-response"

def inject(
self,
carrier: textmap.CarrierT,
context: typing.Optional[Context] = None,
setter: textmap.Setter = textmap.default_setter,
) -> None:
"""Injects x-trace and options-response into the HTTP response carrier."""
span = trace.get_current_span(context)
span_context = span.get_span_context()
if span_context == trace.INVALID_SPAN_CONTEXT:
return

x_trace = W3CTransformer.traceparent_from_context(span_context)
setter.set(
carrier,
self._XTRACE_HEADER_NAME,
x_trace,
)
exposed_headers = [self._XTRACE_HEADER_NAME]

xtraceoptions_response = self.recover_response_from_tracestate(
span_context.trace_state
)
if xtraceoptions_response:
exposed_headers.append("{}".format(
self._XTRACEOPTIONS_RESPONSE_HEADER_NAME
))
setter.set(
carrier,
self._XTRACEOPTIONS_RESPONSE_HEADER_NAME,
xtraceoptions_response,
)
setter.set(
carrier,
self._HTTP_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS,
",".join(exposed_headers),
)

def recover_response_from_tracestate(
self,
tracestate: TraceState
) -> str:
"""Use tracestate to recover xtraceoptions response by
converting delimiters:
`####` becomes `=`
`....` becomes `,`
"""
sanitized = tracestate.get(
XTraceOptions.get_sw_xtraceoptions_response_key(),
None
)
if not sanitized:
return
return sanitized.replace("####", "=").replace("....", ",")
Loading