Skip to content

Commit 245f1a3

Browse files
authored
feat: Update OTEL tracing hook with latest conventions. (#887)
1 parent 6b7bf7e commit 245f1a3

File tree

2 files changed

+163
-33
lines changed

2 files changed

+163
-33
lines changed

packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts

Lines changed: 122 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -74,30 +74,33 @@ describe('with a testing otel span collector', () => {
7474
const spanEvent = spans[0]!.events[0]!;
7575
expect(spanEvent.name).toEqual('feature_flag');
7676
expect(spanEvent.attributes!['feature_flag.key']).toEqual('test-bool');
77-
expect(spanEvent.attributes!['feature_flag.provider_name']).toEqual('LaunchDarkly');
78-
expect(spanEvent.attributes!['feature_flag.context.key']).toEqual('user-key');
79-
expect(spanEvent.attributes!['feature_flag.variant']).toBeUndefined();
77+
expect(spanEvent.attributes!['feature_flag.provider.name']).toEqual('LaunchDarkly');
78+
expect(spanEvent.attributes!['feature_flag.context.id']).toEqual('user-key');
79+
expect(spanEvent.attributes!['feature_flag.result.value']).toBeUndefined();
8080
expect(spanEvent.attributes!['feature_flag.set.id']).toBeUndefined();
8181
});
8282

83-
it('can include variant in span events', async () => {
84-
const td = new integrations.TestData();
85-
const client = init('bad-key', {
86-
sendEvents: false,
87-
updateProcessor: td.getFactory(),
88-
hooks: [new TracingHook({ includeVariant: true })],
89-
});
83+
it.each(['includeVariant', 'includeValue'])(
84+
'can include value in span events',
85+
async (optKey) => {
86+
const td = new integrations.TestData();
87+
const client = init('bad-key', {
88+
sendEvents: false,
89+
updateProcessor: td.getFactory(),
90+
hooks: [new TracingHook({ [optKey]: true })],
91+
});
9092

91-
const tracer = trace.getTracer('trace-hook-test-tracer');
92-
await tracer.startActiveSpan('test-span', { root: true }, async (span) => {
93-
await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false);
94-
span.end();
95-
});
93+
const tracer = trace.getTracer('trace-hook-test-tracer');
94+
await tracer.startActiveSpan('test-span', { root: true }, async (span) => {
95+
await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false);
96+
span.end();
97+
});
9698

97-
const spans = spanExporter.getFinishedSpans();
98-
const spanEvent = spans[0]!.events[0]!;
99-
expect(spanEvent.attributes!['feature_flag.variant']).toEqual('false');
100-
});
99+
const spans = spanExporter.getFinishedSpans();
100+
const spanEvent = spans[0]!.events[0]!;
101+
expect(spanEvent.attributes!['feature_flag.result.value']).toEqual('false');
102+
},
103+
);
101104

102105
it('can include variation spans', async () => {
103106
const td = new integrations.TestData();
@@ -116,7 +119,7 @@ describe('with a testing otel span collector', () => {
116119
const spans = spanExporter.getFinishedSpans();
117120
const variationSpan = spans[0];
118121
expect(variationSpan.name).toEqual('LDClient.boolVariation');
119-
expect(variationSpan.attributes['feature_flag.context.key']).toEqual('user-key');
122+
expect(variationSpan.attributes['feature_flag.context.id']).toEqual('user-key');
120123
});
121124

122125
it('can handle multi-context key requirements', async () => {
@@ -139,7 +142,7 @@ describe('with a testing otel span collector', () => {
139142

140143
const spans = spanExporter.getFinishedSpans();
141144
const spanEvent = spans[0]!.events[0]!;
142-
expect(spanEvent.attributes!['feature_flag.context.key']).toEqual('org:org-key:user:bob');
145+
expect(spanEvent.attributes!['feature_flag.context.id']).toEqual('org:org-key:user:bob');
143146
});
144147

145148
it('can include environmentId from options', async () => {
@@ -218,4 +221,102 @@ describe('with a testing otel span collector', () => {
218221
const spanEvent = spans[0]!.events[0]!;
219222
expect(spanEvent.attributes!['feature_flag.set.id']).toEqual('id-from-options');
220223
});
224+
225+
it('includes inExperiment attribute in span events', async () => {
226+
const td = new integrations.TestData();
227+
td.usePreconfiguredFlag({
228+
key: 'test-bool',
229+
version: 1,
230+
on: true,
231+
targets: [],
232+
rules: [],
233+
fallthrough: {
234+
rollout: {
235+
kind: 'experiment',
236+
variations: [
237+
{
238+
weight: 100000,
239+
variation: 0,
240+
},
241+
],
242+
},
243+
},
244+
variations: [true, false],
245+
});
246+
const client = init('bad-key', {
247+
sendEvents: false,
248+
updateProcessor: td.getFactory(),
249+
hooks: [new TracingHook()],
250+
});
251+
252+
const tracer = trace.getTracer('trace-hook-test-tracer');
253+
await tracer.startActiveSpan('test-span', { root: true }, async (span) => {
254+
await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false);
255+
span.end();
256+
});
257+
258+
const spans = spanExporter.getFinishedSpans();
259+
const spanEvent = spans[0]!.events[0]!;
260+
expect(spanEvent.attributes!['feature_flag.result.reason.inExperiment']).toEqual(true);
261+
});
262+
263+
it('includes variationIndex attribute in span events', async () => {
264+
const td = new integrations.TestData();
265+
td.usePreconfiguredFlag({
266+
key: 'test-bool',
267+
version: 1,
268+
on: true,
269+
targets: [],
270+
rules: [],
271+
fallthrough: {
272+
variation: 1,
273+
},
274+
variations: [true, false],
275+
});
276+
const client = init('bad-key', {
277+
sendEvents: false,
278+
updateProcessor: td.getFactory(),
279+
hooks: [new TracingHook()],
280+
});
281+
282+
const tracer = trace.getTracer('trace-hook-test-tracer');
283+
await tracer.startActiveSpan('test-span', { root: true }, async (span) => {
284+
await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false);
285+
span.end();
286+
});
287+
288+
const spans = spanExporter.getFinishedSpans();
289+
const spanEvent = spans[0]!.events[0]!;
290+
expect(spanEvent.attributes!['feature_flag.result.variationIndex']).toEqual(1);
291+
});
292+
293+
it('does not include inExperiment attribute when not in experiment', async () => {
294+
const td = new integrations.TestData();
295+
td.usePreconfiguredFlag({
296+
key: 'test-bool',
297+
version: 1,
298+
on: true,
299+
targets: [],
300+
rules: [],
301+
fallthrough: {
302+
variation: 0,
303+
},
304+
variations: [true, false],
305+
});
306+
const client = init('bad-key', {
307+
sendEvents: false,
308+
updateProcessor: td.getFactory(),
309+
hooks: [new TracingHook()],
310+
});
311+
312+
const tracer = trace.getTracer('trace-hook-test-tracer');
313+
await tracer.startActiveSpan('test-span', { root: true }, async (span) => {
314+
await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false);
315+
span.end();
316+
});
317+
318+
const spans = spanExporter.getFinishedSpans();
319+
const spanEvent = spans[0]!.events[0]!;
320+
expect(spanEvent.attributes!['feature_flag.result.reason.inExperiment']).toBeUndefined();
321+
});
221322
});

packages/telemetry/node-server-sdk-otel/src/TracingHook.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ import {
1414

1515
const FEATURE_FLAG_SCOPE = 'feature_flag';
1616
const FEATURE_FLAG_KEY_ATTR = `${FEATURE_FLAG_SCOPE}.key`;
17-
const FEATURE_FLAG_PROVIDER_ATTR = `${FEATURE_FLAG_SCOPE}.provider_name`;
18-
const FEATURE_FLAG_CONTEXT_KEY_ATTR = `${FEATURE_FLAG_SCOPE}.context.key`;
19-
const FEATURE_FLAG_VARIANT_ATTR = `${FEATURE_FLAG_SCOPE}.variant`;
17+
const FEATURE_FLAG_PROVIDER_ATTR = `${FEATURE_FLAG_SCOPE}.provider.name`;
18+
const FEATURE_FLAG_CONTEXT_ID_ATTR = `${FEATURE_FLAG_SCOPE}.context.id`;
19+
const FEATURE_FLAG_RESULT_ATTR = `${FEATURE_FLAG_SCOPE}.result`;
20+
const FEATURE_FLAG_VALUE_ATTR = `${FEATURE_FLAG_RESULT_ATTR}.value`;
21+
const FEATURE_FLAG_VARIATION_INDEX_ATTR = `${FEATURE_FLAG_RESULT_ATTR}.variationIndex`;
22+
const FEATURE_FLAG_REASON_ATTR = `${FEATURE_FLAG_RESULT_ATTR}.reason`;
23+
const FEATURE_FLAG_IN_EXPERIMENT_ATTR = `${FEATURE_FLAG_REASON_ATTR}.inExperiment`;
2024
const FEATURE_FLAG_SET_ID = `${FEATURE_FLAG_SCOPE}.set.id`;
2125

2226
const TRACING_HOOK_NAME = 'LaunchDarkly Tracing Hook';
@@ -42,9 +46,20 @@ export interface TracingHookOptions {
4246
* to span events and spans.
4347
*
4448
* The default is false.
49+
*
50+
* @deprecated This option is deprecated and will be removed in a future version.
51+
* This has been replaced by `includeValue`. If both are set, `includeValue` will take precedence.
4552
*/
4653
includeVariant?: boolean;
4754

55+
/**
56+
* If set to true, then the tracing hook will add the evaluated flag value
57+
* to span events and spans.
58+
*
59+
* The default is false.
60+
*/
61+
includeValue?: boolean;
62+
4863
/**
4964
* Set to use a custom logging configuration, otherwise the logging will be done
5065
* using `console`.
@@ -56,7 +71,7 @@ export interface TracingHookOptions {
5671

5772
interface ValidatedHookOptions {
5873
spans: boolean;
59-
includeVariant: boolean;
74+
includeValue: boolean;
6075
logger: LDLogger;
6176
environmentId?: string;
6277
}
@@ -67,7 +82,7 @@ type SpanTraceData = {
6782

6883
const defaultOptions: ValidatedHookOptions = {
6984
spans: false,
70-
includeVariant: false,
85+
includeValue: false,
7186
logger: basicLogger({ name: TRACING_HOOK_NAME }),
7287
environmentId: undefined,
7388
};
@@ -79,9 +94,17 @@ function validateOptions(options?: TracingHookOptions): ValidatedHookOptions {
7994
validatedOptions.logger = new SafeLogger(options.logger, defaultOptions.logger);
8095
}
8196

82-
if (options?.includeVariant !== undefined) {
97+
if (options?.includeValue !== undefined) {
98+
if (TypeValidators.Boolean.is(options.includeValue)) {
99+
validatedOptions.includeValue = options.includeValue;
100+
} else {
101+
validatedOptions.logger.error(
102+
OptionMessages.wrongOptionType('includeValue', 'boolean', typeof options?.includeValue),
103+
);
104+
}
105+
} else if (options?.includeVariant !== undefined) {
83106
if (TypeValidators.Boolean.is(options.includeVariant)) {
84-
validatedOptions.includeVariant = options.includeVariant;
107+
validatedOptions.includeValue = options.includeVariant;
85108
} else {
86109
validatedOptions.logger.error(
87110
OptionMessages.wrongOptionType('includeVariant', 'boolean', typeof options?.includeVariant),
@@ -153,8 +176,8 @@ export default class TracingHook implements integrations.Hook {
153176
const { canonicalKey } = Context.fromLDContext(hookContext.context);
154177

155178
const span = this._tracer.startSpan(hookContext.method, undefined, context.active());
156-
span.setAttribute('feature_flag.context.key', canonicalKey);
157-
span.setAttribute('feature_flag.key', hookContext.flagKey);
179+
span.setAttribute(FEATURE_FLAG_CONTEXT_ID_ATTR, canonicalKey);
180+
span.setAttribute(FEATURE_FLAG_KEY_ATTR, hookContext.flagKey);
158181

159182
return { ...data, span };
160183
}
@@ -176,15 +199,21 @@ export default class TracingHook implements integrations.Hook {
176199
const eventAttributes: Attributes = {
177200
[FEATURE_FLAG_KEY_ATTR]: hookContext.flagKey,
178201
[FEATURE_FLAG_PROVIDER_ATTR]: 'LaunchDarkly',
179-
[FEATURE_FLAG_CONTEXT_KEY_ATTR]: Context.fromLDContext(hookContext.context).canonicalKey,
202+
[FEATURE_FLAG_CONTEXT_ID_ATTR]: Context.fromLDContext(hookContext.context).canonicalKey,
180203
};
204+
if (typeof detail.variationIndex === 'number') {
205+
eventAttributes[FEATURE_FLAG_VARIATION_INDEX_ATTR] = detail.variationIndex;
206+
}
207+
if (detail.reason.inExperiment) {
208+
eventAttributes[FEATURE_FLAG_IN_EXPERIMENT_ATTR] = detail.reason.inExperiment;
209+
}
181210
if (this._options.environmentId) {
182211
eventAttributes[FEATURE_FLAG_SET_ID] = this._options.environmentId;
183212
} else if (hookContext.environmentId) {
184213
eventAttributes[FEATURE_FLAG_SET_ID] = hookContext.environmentId;
185214
}
186-
if (this._options.includeVariant) {
187-
eventAttributes[FEATURE_FLAG_VARIANT_ATTR] = JSON.stringify(detail.value);
215+
if (this._options.includeValue) {
216+
eventAttributes[FEATURE_FLAG_VALUE_ATTR] = JSON.stringify(detail.value);
188217
}
189218
currentTrace.addEvent(FEATURE_FLAG_SCOPE, eventAttributes);
190219
}

0 commit comments

Comments
 (0)