Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
153 changes: 131 additions & 22 deletions ldotel/testing/test_tracing.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import pytest
from ldclient import Config, Context, LDClient
from ldclient.evaluation import EvaluationDetail
from ldclient.hook import EvaluationSeriesContext
from ldclient.integrations.test_data import TestData
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter, SpanExporter)
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter
from opentelemetry.sdk.trace.export.in_memory_span_exporter import \
InMemorySpanExporter
from opentelemetry.trace import (Tracer, get_tracer_provider,
set_tracer_provider)

Expand Down Expand Up @@ -59,9 +61,11 @@ def test_records_basic_span_event(self, client: LDClient, exporter: SpanExporter
event = spans[0].events[0]
assert event.name == 'feature_flag'
assert event.attributes['feature_flag.key'] == 'boolean'
assert event.attributes['feature_flag.provider_name'] == 'LaunchDarkly'
assert event.attributes['feature_flag.context.key'] == 'org:org-key'
assert 'feature_flag.variant' not in event.attributes
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
assert event.attributes['feature_flag.result.variationIndex'] == '0'
assert 'feature_flag.result.value' not in event.attributes
assert 'feature_flag.result.reason.inExperiment' not in event.attributes

def test_can_include_variant(self, client: LDClient, exporter: SpanExporter, tracer: Tracer):
client.add_hook(Hook(HookOptions(include_variant=True)))
Expand All @@ -75,9 +79,42 @@ def test_can_include_variant(self, client: LDClient, exporter: SpanExporter, tra
event = spans[0].events[0]
assert event.name == 'feature_flag'
assert event.attributes['feature_flag.key'] == 'boolean'
assert event.attributes['feature_flag.provider_name'] == 'LaunchDarkly'
assert event.attributes['feature_flag.context.key'] == 'org:org-key'
assert event.attributes['feature_flag.variant'] == 'True'
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
assert event.attributes['feature_flag.result.variationIndex'] == '0'
assert event.attributes['feature_flag.result.value'] == 'true'
assert 'feature_flag.result.reason.inExperiment' not in event.attributes

@pytest.mark.parametrize("flag_key, variations, variation_index, expected_value", [
("string-flag", ["alpha", "beta"], 1, "beta"),
("number-flag", [42, 99], 0, 42),
("array-flag", [[1, 2], [3, 4]], 1, [3, 4]),
("object-flag", [{"a": 1}, {"b": 2}], 0, {"a": 1}),
])
def test_can_include_value_types(self, flag_key, variations, variation_index, expected_value, exporter: SpanExporter, tracer: Tracer):
td = TestData.data_source()
td.update(td.flag(flag_key).variations(*variations).variation_for_all(variation_index))
config = Config('sdk-key', update_processor_class=td, send_events=False)
client = LDClient(config=config)
client.add_hook(Hook(HookOptions(include_value=True)))

with tracer.start_as_current_span(f"test_can_include_value_types_{flag_key}"):
context = Context.create('org-key', 'org')
client.variation(flag_key, context, None)

spans = exporter.get_finished_spans() # type: ignore[attr-defined]
assert len(spans) == 1
assert len(spans[0].events) == 1

import json
event = spans[0].events[0]
assert event.name == 'feature_flag'
assert event.attributes['feature_flag.key'] == flag_key
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
assert event.attributes['feature_flag.result.variationIndex'] == str(variation_index)
assert event.attributes['feature_flag.result.value'] == json.dumps(expected_value)
assert 'feature_flag.result.reason.inExperiment' not in event.attributes

def test_add_span_creates_span_if_one_not_active(self, client: LDClient, exporter: SpanExporter, tracer: Tracer):
client.add_hook(Hook(HookOptions(add_spans=True)))
Expand All @@ -86,7 +123,7 @@ def test_add_span_creates_span_if_one_not_active(self, client: LDClient, exporte
spans = exporter.get_finished_spans() # type: ignore[attr-defined]
assert len(spans) == 1

assert spans[0].attributes['feature_flag.context.key'] == 'org:org-key'
assert spans[0].attributes['feature_flag.context.id'] == 'org:org-key'
assert spans[0].attributes['feature_flag.key'] == 'boolean'
assert len(spans[0].events) == 0

Expand All @@ -101,15 +138,17 @@ def test_add_span_leaves_events_on_top_level_span(self, client: LDClient, export
ld_span = spans[0]
toplevel = spans[1]

assert ld_span.attributes['feature_flag.context.key'] == 'org:org-key'
assert ld_span.attributes['feature_flag.context.id'] == 'org:org-key'
assert ld_span.attributes['feature_flag.key'] == 'boolean'

event = toplevel.events[0]
assert event.name == 'feature_flag'
assert event.attributes['feature_flag.key'] == 'boolean'
assert event.attributes['feature_flag.provider_name'] == 'LaunchDarkly'
assert event.attributes['feature_flag.context.key'] == 'org:org-key'
assert 'feature_flag.variant' not in event.attributes
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
assert event.attributes['feature_flag.result.variationIndex'] == '0'
assert 'feature_flag.result.value' not in event.attributes
assert 'feature_flag.result.reason.inExperiment' not in event.attributes

def test_hook_makes_its_span_active(self, client: LDClient, exporter: SpanExporter, tracer: Tracer):
client.add_hook(Hook(HookOptions(add_spans=True)))
Expand All @@ -125,20 +164,90 @@ def test_hook_makes_its_span_active(self, client: LDClient, exporter: SpanExport
middle = spans[1]
top = spans[2]

assert inner.attributes['feature_flag.context.key'] == 'org:org-key'
assert inner.attributes['feature_flag.context.id'] == 'org:org-key'
assert inner.attributes['feature_flag.key'] == 'boolean'
assert len(inner.events) == 0

assert middle.attributes['feature_flag.context.key'] == 'org:org-key'
assert middle.attributes['feature_flag.context.id'] == 'org:org-key'
assert middle.attributes['feature_flag.key'] == 'boolean'
assert middle.events[0].name == 'feature_flag'
assert middle.events[0].attributes['feature_flag.key'] == 'boolean'
assert middle.events[0].attributes['feature_flag.provider_name'] == 'LaunchDarkly'
assert middle.events[0].attributes['feature_flag.context.key'] == 'org:org-key'
assert 'feature_flag.variant' not in middle.events[0].attributes
assert middle.events[0].attributes['feature_flag.provider.name'] == 'LaunchDarkly'
assert middle.events[0].attributes['feature_flag.context.id'] == 'org:org-key'
assert middle.events[0].attributes['feature_flag.result.variationIndex'] == '0'
assert 'feature_flag.result.value' not in middle.events[0].attributes
assert 'feature_flag.result.reason.inExperiment' not in middle.events[0].attributes

assert top.events[0].name == 'feature_flag'
assert top.events[0].attributes['feature_flag.key'] == 'boolean'
assert top.events[0].attributes['feature_flag.provider_name'] == 'LaunchDarkly'
assert top.events[0].attributes['feature_flag.context.key'] == 'org:org-key'
assert 'feature_flag.variant' not in top.events[0].attributes
assert top.events[0].attributes['feature_flag.provider.name'] == 'LaunchDarkly'
assert top.events[0].attributes['feature_flag.context.id'] == 'org:org-key'
assert top.events[0].attributes['feature_flag.result.variationIndex'] == '0'
assert 'feature_flag.result.value' not in top.events[0].attributes
assert 'feature_flag.result.reason.inExperiment' not in top.events[0].attributes

def test_records_in_experiment_attribute(self, exporter: SpanExporter, tracer: Tracer):
series_context = EvaluationSeriesContext(
key='experiment-flag',
context=Context.create('org-key', 'org'),
default_value=False,
method='variation',
)

# Create an EvaluationDetail with inExperiment=True in the reason
detail = EvaluationDetail(
value=True,
variation_index=1,
reason={"inExperiment": True}
)

hook = Hook()
with tracer.start_as_current_span("test_records_in_experiment_attribute"):
data = hook.before_evaluation(series_context, {}) # type: ignore
hook.after_evaluation(series_context, data, detail) # type: ignore

spans = exporter.get_finished_spans() # type: ignore[attr-defined]
assert len(spans) == 1
assert len(spans[0].events) == 1

event = spans[0].events[0]
assert event.name == 'feature_flag'
assert event.attributes['feature_flag.key'] == 'experiment-flag'
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
assert event.attributes['feature_flag.result.variationIndex'] == '1'
assert event.attributes['feature_flag.result.reason.inExperiment'] == 'true'
assert 'feature_flag.result.value' not in event.attributes

def test_does_not_include_variation_index_when_none(self, exporter: SpanExporter, tracer: Tracer):
series_context = EvaluationSeriesContext(
key='flag-without-variation',
context=Context.create('org-key', 'org'),
default_value=False,
method='variation',
)

detail = EvaluationDetail(
value=False,
variation_index=None,
reason={"kind": "FALLTHROUGH"}
)

hook = Hook()
with tracer.start_as_current_span("test_does_not_include_variation_index_when_none"):
data = hook.before_evaluation(series_context, {}) # type: ignore
hook.after_evaluation(series_context, data, detail) # type: ignore

spans = exporter.get_finished_spans() # type: ignore[attr-defined]
assert len(spans) == 1
assert len(spans[0].events) == 1

event = spans[0].events[0]
assert event.name == 'feature_flag'
assert event.attributes['feature_flag.key'] == 'flag-without-variation'
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
# variationIndex should not be present when variation_index is None
assert 'feature_flag.result.variationIndex' not in event.attributes
assert 'feature_flag.result.reason.inExperiment' not in event.attributes
assert 'feature_flag.result.value' not in event.attributes
34 changes: 29 additions & 5 deletions ldotel/tracing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json
import warnings
from dataclasses import dataclass

from ldclient.evaluation import EvaluationDetail
Expand Down Expand Up @@ -25,13 +27,29 @@ class HookOptions:
"""
If set to true, then the tracing hook will add the evaluated flag value to
span events.

.. deprecated:: 1.0.0
This option is deprecated and will be removed in a future version.
Use :attr:`include_value` instead.
"""

include_value: bool = False
"""
If set to true, then the tracing hook will add the evaluated flag value to
span events.
"""


class Hook(LDHook):
def __init__(self, options: HookOptions = HookOptions()):
self.__tracer = trace.get_tracer_provider().get_tracer("launchdarkly")
self.__options = options
if self.__options.include_variant:
Copy link
Member Author

Choose a reason for hiding this comment

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

I don't know an actually reasonable way to do this without the logger instance.

Copy link
Member

Choose a reason for hiding this comment

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

You can just grab the logger from wherever. Python is just like that.

from logging import getLogger

logger = getLogger('launchdarkly-otel')
logger.warning('Hi there')

If we document that we always use the 'launchdarkly-otel' named logger, then it makes it easy for customers to affect the formatting without having to pass a logger around everywhere.

But this honestly seems pretty reasonable to me.

warnings.warn(
"The 'include_variant' option is deprecated and will be removed in a future version. "
"Use 'include_value' instead.",
DeprecationWarning,
)

@property
def metadata(self) -> Metadata:
Expand All @@ -56,7 +74,7 @@ def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict)
return data

attributes = {
'feature_flag.context.key': series_context.context.fully_qualified_key,
'feature_flag.context.id': series_context.context.fully_qualified_key,
'feature_flag.key': series_context.key,
}

Expand Down Expand Up @@ -88,13 +106,19 @@ def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict,
return data

attributes = {
'feature_flag.context.key': series_context.context.fully_qualified_key,
'feature_flag.context.id': series_context.context.fully_qualified_key,
'feature_flag.key': series_context.key,
'feature_flag.provider_name': 'LaunchDarkly'
'feature_flag.provider.name': 'LaunchDarkly',
}

if self.__options.include_variant:
attributes['feature_flag.variant'] = str(detail.value)
if detail.variation_index is not None:
attributes['feature_flag.result.variationIndex'] = str(detail.variation_index)

if detail.reason.get('inExperiment'):
attributes['feature_flag.result.reason.inExperiment'] = 'true'

if self.__options.include_value or self.__options.include_variant:
attributes['feature_flag.result.value'] = json.dumps(detail.value)

span.add_event('feature_flag', attributes=attributes)

Expand Down