Skip to content

Commit 25dafad

Browse files
authored
feat: Update tracing hook for semantic convention updates. (#31)
1 parent 9fcc28c commit 25dafad

File tree

2 files changed

+160
-27
lines changed

2 files changed

+160
-27
lines changed

ldotel/testing/test_tracing.py

Lines changed: 131 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import pytest
22
from ldclient import Config, Context, LDClient
3+
from ldclient.evaluation import EvaluationDetail
4+
from ldclient.hook import EvaluationSeriesContext
35
from ldclient.integrations.test_data import TestData
46
from opentelemetry.sdk.trace import TracerProvider
5-
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
6-
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
7-
InMemorySpanExporter, SpanExporter)
7+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter
8+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import \
9+
InMemorySpanExporter
810
from opentelemetry.trace import (Tracer, get_tracer_provider,
911
set_tracer_provider)
1012

@@ -59,9 +61,11 @@ def test_records_basic_span_event(self, client: LDClient, exporter: SpanExporter
5961
event = spans[0].events[0]
6062
assert event.name == 'feature_flag'
6163
assert event.attributes['feature_flag.key'] == 'boolean'
62-
assert event.attributes['feature_flag.provider_name'] == 'LaunchDarkly'
63-
assert event.attributes['feature_flag.context.key'] == 'org:org-key'
64-
assert 'feature_flag.variant' not in event.attributes
64+
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
65+
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
66+
assert event.attributes['feature_flag.result.variationIndex'] == '0'
67+
assert 'feature_flag.result.value' not in event.attributes
68+
assert 'feature_flag.result.reason.inExperiment' not in event.attributes
6569

6670
def test_can_include_variant(self, client: LDClient, exporter: SpanExporter, tracer: Tracer):
6771
client.add_hook(Hook(HookOptions(include_variant=True)))
@@ -75,9 +79,42 @@ def test_can_include_variant(self, client: LDClient, exporter: SpanExporter, tra
7579
event = spans[0].events[0]
7680
assert event.name == 'feature_flag'
7781
assert event.attributes['feature_flag.key'] == 'boolean'
78-
assert event.attributes['feature_flag.provider_name'] == 'LaunchDarkly'
79-
assert event.attributes['feature_flag.context.key'] == 'org:org-key'
80-
assert event.attributes['feature_flag.variant'] == 'True'
82+
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
83+
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
84+
assert event.attributes['feature_flag.result.variationIndex'] == '0'
85+
assert event.attributes['feature_flag.result.value'] == 'true'
86+
assert 'feature_flag.result.reason.inExperiment' not in event.attributes
87+
88+
@pytest.mark.parametrize("flag_key, variations, variation_index, expected_value", [
89+
("string-flag", ["alpha", "beta"], 1, "beta"),
90+
("number-flag", [42, 99], 0, 42),
91+
("array-flag", [[1, 2], [3, 4]], 1, [3, 4]),
92+
("object-flag", [{"a": 1}, {"b": 2}], 0, {"a": 1}),
93+
])
94+
def test_can_include_value_types(self, flag_key, variations, variation_index, expected_value, exporter: SpanExporter, tracer: Tracer):
95+
td = TestData.data_source()
96+
td.update(td.flag(flag_key).variations(*variations).variation_for_all(variation_index))
97+
config = Config('sdk-key', update_processor_class=td, send_events=False)
98+
client = LDClient(config=config)
99+
client.add_hook(Hook(HookOptions(include_value=True)))
100+
101+
with tracer.start_as_current_span(f"test_can_include_value_types_{flag_key}"):
102+
context = Context.create('org-key', 'org')
103+
client.variation(flag_key, context, None)
104+
105+
spans = exporter.get_finished_spans() # type: ignore[attr-defined]
106+
assert len(spans) == 1
107+
assert len(spans[0].events) == 1
108+
109+
import json
110+
event = spans[0].events[0]
111+
assert event.name == 'feature_flag'
112+
assert event.attributes['feature_flag.key'] == flag_key
113+
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
114+
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
115+
assert event.attributes['feature_flag.result.variationIndex'] == str(variation_index)
116+
assert event.attributes['feature_flag.result.value'] == json.dumps(expected_value)
117+
assert 'feature_flag.result.reason.inExperiment' not in event.attributes
81118

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

89-
assert spans[0].attributes['feature_flag.context.key'] == 'org:org-key'
126+
assert spans[0].attributes['feature_flag.context.id'] == 'org:org-key'
90127
assert spans[0].attributes['feature_flag.key'] == 'boolean'
91128
assert len(spans[0].events) == 0
92129

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

104-
assert ld_span.attributes['feature_flag.context.key'] == 'org:org-key'
141+
assert ld_span.attributes['feature_flag.context.id'] == 'org:org-key'
105142
assert ld_span.attributes['feature_flag.key'] == 'boolean'
106143

107144
event = toplevel.events[0]
108145
assert event.name == 'feature_flag'
109146
assert event.attributes['feature_flag.key'] == 'boolean'
110-
assert event.attributes['feature_flag.provider_name'] == 'LaunchDarkly'
111-
assert event.attributes['feature_flag.context.key'] == 'org:org-key'
112-
assert 'feature_flag.variant' not in event.attributes
147+
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
148+
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
149+
assert event.attributes['feature_flag.result.variationIndex'] == '0'
150+
assert 'feature_flag.result.value' not in event.attributes
151+
assert 'feature_flag.result.reason.inExperiment' not in event.attributes
113152

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

128-
assert inner.attributes['feature_flag.context.key'] == 'org:org-key'
167+
assert inner.attributes['feature_flag.context.id'] == 'org:org-key'
129168
assert inner.attributes['feature_flag.key'] == 'boolean'
130169
assert len(inner.events) == 0
131170

132-
assert middle.attributes['feature_flag.context.key'] == 'org:org-key'
171+
assert middle.attributes['feature_flag.context.id'] == 'org:org-key'
133172
assert middle.attributes['feature_flag.key'] == 'boolean'
134173
assert middle.events[0].name == 'feature_flag'
135174
assert middle.events[0].attributes['feature_flag.key'] == 'boolean'
136-
assert middle.events[0].attributes['feature_flag.provider_name'] == 'LaunchDarkly'
137-
assert middle.events[0].attributes['feature_flag.context.key'] == 'org:org-key'
138-
assert 'feature_flag.variant' not in middle.events[0].attributes
175+
assert middle.events[0].attributes['feature_flag.provider.name'] == 'LaunchDarkly'
176+
assert middle.events[0].attributes['feature_flag.context.id'] == 'org:org-key'
177+
assert middle.events[0].attributes['feature_flag.result.variationIndex'] == '0'
178+
assert 'feature_flag.result.value' not in middle.events[0].attributes
179+
assert 'feature_flag.result.reason.inExperiment' not in middle.events[0].attributes
139180

140181
assert top.events[0].name == 'feature_flag'
141182
assert top.events[0].attributes['feature_flag.key'] == 'boolean'
142-
assert top.events[0].attributes['feature_flag.provider_name'] == 'LaunchDarkly'
143-
assert top.events[0].attributes['feature_flag.context.key'] == 'org:org-key'
144-
assert 'feature_flag.variant' not in top.events[0].attributes
183+
assert top.events[0].attributes['feature_flag.provider.name'] == 'LaunchDarkly'
184+
assert top.events[0].attributes['feature_flag.context.id'] == 'org:org-key'
185+
assert top.events[0].attributes['feature_flag.result.variationIndex'] == '0'
186+
assert 'feature_flag.result.value' not in top.events[0].attributes
187+
assert 'feature_flag.result.reason.inExperiment' not in top.events[0].attributes
188+
189+
def test_records_in_experiment_attribute(self, exporter: SpanExporter, tracer: Tracer):
190+
series_context = EvaluationSeriesContext(
191+
key='experiment-flag',
192+
context=Context.create('org-key', 'org'),
193+
default_value=False,
194+
method='variation',
195+
)
196+
197+
# Create an EvaluationDetail with inExperiment=True in the reason
198+
detail = EvaluationDetail(
199+
value=True,
200+
variation_index=1,
201+
reason={"inExperiment": True}
202+
)
203+
204+
hook = Hook()
205+
with tracer.start_as_current_span("test_records_in_experiment_attribute"):
206+
data = hook.before_evaluation(series_context, {}) # type: ignore
207+
hook.after_evaluation(series_context, data, detail) # type: ignore
208+
209+
spans = exporter.get_finished_spans() # type: ignore[attr-defined]
210+
assert len(spans) == 1
211+
assert len(spans[0].events) == 1
212+
213+
event = spans[0].events[0]
214+
assert event.name == 'feature_flag'
215+
assert event.attributes['feature_flag.key'] == 'experiment-flag'
216+
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
217+
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
218+
assert event.attributes['feature_flag.result.variationIndex'] == '1'
219+
assert event.attributes['feature_flag.result.reason.inExperiment'] == 'true'
220+
assert 'feature_flag.result.value' not in event.attributes
221+
222+
def test_does_not_include_variation_index_when_none(self, exporter: SpanExporter, tracer: Tracer):
223+
series_context = EvaluationSeriesContext(
224+
key='flag-without-variation',
225+
context=Context.create('org-key', 'org'),
226+
default_value=False,
227+
method='variation',
228+
)
229+
230+
detail = EvaluationDetail(
231+
value=False,
232+
variation_index=None,
233+
reason={"kind": "FALLTHROUGH"}
234+
)
235+
236+
hook = Hook()
237+
with tracer.start_as_current_span("test_does_not_include_variation_index_when_none"):
238+
data = hook.before_evaluation(series_context, {}) # type: ignore
239+
hook.after_evaluation(series_context, data, detail) # type: ignore
240+
241+
spans = exporter.get_finished_spans() # type: ignore[attr-defined]
242+
assert len(spans) == 1
243+
assert len(spans[0].events) == 1
244+
245+
event = spans[0].events[0]
246+
assert event.name == 'feature_flag'
247+
assert event.attributes['feature_flag.key'] == 'flag-without-variation'
248+
assert event.attributes['feature_flag.provider.name'] == 'LaunchDarkly'
249+
assert event.attributes['feature_flag.context.id'] == 'org:org-key'
250+
# variationIndex should not be present when variation_index is None
251+
assert 'feature_flag.result.variationIndex' not in event.attributes
252+
assert 'feature_flag.result.reason.inExperiment' not in event.attributes
253+
assert 'feature_flag.result.value' not in event.attributes

ldotel/tracing.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
import warnings
13
from dataclasses import dataclass
24

35
from ldclient.evaluation import EvaluationDetail
@@ -25,13 +27,29 @@ class HookOptions:
2527
"""
2628
If set to true, then the tracing hook will add the evaluated flag value to
2729
span events.
30+
31+
.. deprecated:: 1.0.0
32+
This option is deprecated and will be removed in a future version.
33+
Use :attr:`include_value` instead.
34+
"""
35+
36+
include_value: bool = False
37+
"""
38+
If set to true, then the tracing hook will add the evaluated flag value to
39+
span events.
2840
"""
2941

3042

3143
class Hook(LDHook):
3244
def __init__(self, options: HookOptions = HookOptions()):
3345
self.__tracer = trace.get_tracer_provider().get_tracer("launchdarkly")
3446
self.__options = options
47+
if self.__options.include_variant:
48+
warnings.warn(
49+
"The 'include_variant' option is deprecated and will be removed in a future version. "
50+
"Use 'include_value' instead.",
51+
DeprecationWarning,
52+
)
3553

3654
@property
3755
def metadata(self) -> Metadata:
@@ -56,7 +74,7 @@ def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict)
5674
return data
5775

5876
attributes = {
59-
'feature_flag.context.key': series_context.context.fully_qualified_key,
77+
'feature_flag.context.id': series_context.context.fully_qualified_key,
6078
'feature_flag.key': series_context.key,
6179
}
6280

@@ -88,13 +106,19 @@ def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict,
88106
return data
89107

90108
attributes = {
91-
'feature_flag.context.key': series_context.context.fully_qualified_key,
109+
'feature_flag.context.id': series_context.context.fully_qualified_key,
92110
'feature_flag.key': series_context.key,
93-
'feature_flag.provider_name': 'LaunchDarkly'
111+
'feature_flag.provider.name': 'LaunchDarkly',
94112
}
95113

96-
if self.__options.include_variant:
97-
attributes['feature_flag.variant'] = str(detail.value)
114+
if detail.variation_index is not None:
115+
attributes['feature_flag.result.variationIndex'] = str(detail.variation_index)
116+
117+
if detail.reason.get('inExperiment'):
118+
attributes['feature_flag.result.reason.inExperiment'] = 'true'
119+
120+
if self.__options.include_value or self.__options.include_variant:
121+
attributes['feature_flag.result.value'] = json.dumps(detail.value)
98122

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

0 commit comments

Comments
 (0)