From 4b629ef0b822fb3536c14d873e2f5ea7093c6d19 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 16 Jun 2025 18:14:25 -0700 Subject: [PATCH] feat: Update OTEL tracing hook with latest conventions. --- .../__tests__/TracingHook.test.ts | 143 +++++++++++++++--- .../node-server-sdk-otel/src/TracingHook.ts | 53 +++++-- 2 files changed, 163 insertions(+), 33 deletions(-) diff --git a/packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts b/packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts index de1cd1325c..15d05dea47 100644 --- a/packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts +++ b/packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts @@ -74,30 +74,33 @@ describe('with a testing otel span collector', () => { const spanEvent = spans[0]!.events[0]!; expect(spanEvent.name).toEqual('feature_flag'); expect(spanEvent.attributes!['feature_flag.key']).toEqual('test-bool'); - expect(spanEvent.attributes!['feature_flag.provider_name']).toEqual('LaunchDarkly'); - expect(spanEvent.attributes!['feature_flag.context.key']).toEqual('user-key'); - expect(spanEvent.attributes!['feature_flag.variant']).toBeUndefined(); + expect(spanEvent.attributes!['feature_flag.provider.name']).toEqual('LaunchDarkly'); + expect(spanEvent.attributes!['feature_flag.context.id']).toEqual('user-key'); + expect(spanEvent.attributes!['feature_flag.result.value']).toBeUndefined(); expect(spanEvent.attributes!['feature_flag.set.id']).toBeUndefined(); }); - it('can include variant in span events', async () => { - const td = new integrations.TestData(); - const client = init('bad-key', { - sendEvents: false, - updateProcessor: td.getFactory(), - hooks: [new TracingHook({ includeVariant: true })], - }); + it.each(['includeVariant', 'includeValue'])( + 'can include value in span events', + async (optKey) => { + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook({ [optKey]: true })], + }); - const tracer = trace.getTracer('trace-hook-test-tracer'); - await tracer.startActiveSpan('test-span', { root: true }, async (span) => { - await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); - span.end(); - }); + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); - const spans = spanExporter.getFinishedSpans(); - const spanEvent = spans[0]!.events[0]!; - expect(spanEvent.attributes!['feature_flag.variant']).toEqual('false'); - }); + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.result.value']).toEqual('false'); + }, + ); it('can include variation spans', async () => { const td = new integrations.TestData(); @@ -116,7 +119,7 @@ describe('with a testing otel span collector', () => { const spans = spanExporter.getFinishedSpans(); const variationSpan = spans[0]; expect(variationSpan.name).toEqual('LDClient.boolVariation'); - expect(variationSpan.attributes['feature_flag.context.key']).toEqual('user-key'); + expect(variationSpan.attributes['feature_flag.context.id']).toEqual('user-key'); }); it('can handle multi-context key requirements', async () => { @@ -139,7 +142,7 @@ describe('with a testing otel span collector', () => { const spans = spanExporter.getFinishedSpans(); const spanEvent = spans[0]!.events[0]!; - expect(spanEvent.attributes!['feature_flag.context.key']).toEqual('org:org-key:user:bob'); + expect(spanEvent.attributes!['feature_flag.context.id']).toEqual('org:org-key:user:bob'); }); it('can include environmentId from options', async () => { @@ -218,4 +221,102 @@ describe('with a testing otel span collector', () => { const spanEvent = spans[0]!.events[0]!; expect(spanEvent.attributes!['feature_flag.set.id']).toEqual('id-from-options'); }); + + it('includes inExperiment attribute in span events', async () => { + const td = new integrations.TestData(); + td.usePreconfiguredFlag({ + key: 'test-bool', + version: 1, + on: true, + targets: [], + rules: [], + fallthrough: { + rollout: { + kind: 'experiment', + variations: [ + { + weight: 100000, + variation: 0, + }, + ], + }, + }, + variations: [true, false], + }); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook()], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.result.reason.inExperiment']).toEqual(true); + }); + + it('includes variationIndex attribute in span events', async () => { + const td = new integrations.TestData(); + td.usePreconfiguredFlag({ + key: 'test-bool', + version: 1, + on: true, + targets: [], + rules: [], + fallthrough: { + variation: 1, + }, + variations: [true, false], + }); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook()], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.result.variationIndex']).toEqual(1); + }); + + it('does not include inExperiment attribute when not in experiment', async () => { + const td = new integrations.TestData(); + td.usePreconfiguredFlag({ + key: 'test-bool', + version: 1, + on: true, + targets: [], + rules: [], + fallthrough: { + variation: 0, + }, + variations: [true, false], + }); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook()], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.result.reason.inExperiment']).toBeUndefined(); + }); }); diff --git a/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts b/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts index 35d5911a3f..f5a5fff526 100644 --- a/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts +++ b/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts @@ -14,9 +14,13 @@ import { const FEATURE_FLAG_SCOPE = 'feature_flag'; const FEATURE_FLAG_KEY_ATTR = `${FEATURE_FLAG_SCOPE}.key`; -const FEATURE_FLAG_PROVIDER_ATTR = `${FEATURE_FLAG_SCOPE}.provider_name`; -const FEATURE_FLAG_CONTEXT_KEY_ATTR = `${FEATURE_FLAG_SCOPE}.context.key`; -const FEATURE_FLAG_VARIANT_ATTR = `${FEATURE_FLAG_SCOPE}.variant`; +const FEATURE_FLAG_PROVIDER_ATTR = `${FEATURE_FLAG_SCOPE}.provider.name`; +const FEATURE_FLAG_CONTEXT_ID_ATTR = `${FEATURE_FLAG_SCOPE}.context.id`; +const FEATURE_FLAG_RESULT_ATTR = `${FEATURE_FLAG_SCOPE}.result`; +const FEATURE_FLAG_VALUE_ATTR = `${FEATURE_FLAG_RESULT_ATTR}.value`; +const FEATURE_FLAG_VARIATION_INDEX_ATTR = `${FEATURE_FLAG_RESULT_ATTR}.variationIndex`; +const FEATURE_FLAG_REASON_ATTR = `${FEATURE_FLAG_RESULT_ATTR}.reason`; +const FEATURE_FLAG_IN_EXPERIMENT_ATTR = `${FEATURE_FLAG_REASON_ATTR}.inExperiment`; const FEATURE_FLAG_SET_ID = `${FEATURE_FLAG_SCOPE}.set.id`; const TRACING_HOOK_NAME = 'LaunchDarkly Tracing Hook'; @@ -42,9 +46,20 @@ export interface TracingHookOptions { * to span events and spans. * * The default is false. + * + * @deprecated This option is deprecated and will be removed in a future version. + * This has been replaced by `includeValue`. If both are set, `includeValue` will take precedence. */ includeVariant?: boolean; + /** + * If set to true, then the tracing hook will add the evaluated flag value + * to span events and spans. + * + * The default is false. + */ + includeValue?: boolean; + /** * Set to use a custom logging configuration, otherwise the logging will be done * using `console`. @@ -56,7 +71,7 @@ export interface TracingHookOptions { interface ValidatedHookOptions { spans: boolean; - includeVariant: boolean; + includeValue: boolean; logger: LDLogger; environmentId?: string; } @@ -67,7 +82,7 @@ type SpanTraceData = { const defaultOptions: ValidatedHookOptions = { spans: false, - includeVariant: false, + includeValue: false, logger: basicLogger({ name: TRACING_HOOK_NAME }), environmentId: undefined, }; @@ -79,9 +94,17 @@ function validateOptions(options?: TracingHookOptions): ValidatedHookOptions { validatedOptions.logger = new SafeLogger(options.logger, defaultOptions.logger); } - if (options?.includeVariant !== undefined) { + if (options?.includeValue !== undefined) { + if (TypeValidators.Boolean.is(options.includeValue)) { + validatedOptions.includeValue = options.includeValue; + } else { + validatedOptions.logger.error( + OptionMessages.wrongOptionType('includeValue', 'boolean', typeof options?.includeValue), + ); + } + } else if (options?.includeVariant !== undefined) { if (TypeValidators.Boolean.is(options.includeVariant)) { - validatedOptions.includeVariant = options.includeVariant; + validatedOptions.includeValue = options.includeVariant; } else { validatedOptions.logger.error( OptionMessages.wrongOptionType('includeVariant', 'boolean', typeof options?.includeVariant), @@ -153,8 +176,8 @@ export default class TracingHook implements integrations.Hook { const { canonicalKey } = Context.fromLDContext(hookContext.context); const span = this._tracer.startSpan(hookContext.method, undefined, context.active()); - span.setAttribute('feature_flag.context.key', canonicalKey); - span.setAttribute('feature_flag.key', hookContext.flagKey); + span.setAttribute(FEATURE_FLAG_CONTEXT_ID_ATTR, canonicalKey); + span.setAttribute(FEATURE_FLAG_KEY_ATTR, hookContext.flagKey); return { ...data, span }; } @@ -176,15 +199,21 @@ export default class TracingHook implements integrations.Hook { const eventAttributes: Attributes = { [FEATURE_FLAG_KEY_ATTR]: hookContext.flagKey, [FEATURE_FLAG_PROVIDER_ATTR]: 'LaunchDarkly', - [FEATURE_FLAG_CONTEXT_KEY_ATTR]: Context.fromLDContext(hookContext.context).canonicalKey, + [FEATURE_FLAG_CONTEXT_ID_ATTR]: Context.fromLDContext(hookContext.context).canonicalKey, }; + if (typeof detail.variationIndex === 'number') { + eventAttributes[FEATURE_FLAG_VARIATION_INDEX_ATTR] = detail.variationIndex; + } + if (detail.reason.inExperiment) { + eventAttributes[FEATURE_FLAG_IN_EXPERIMENT_ATTR] = detail.reason.inExperiment; + } if (this._options.environmentId) { eventAttributes[FEATURE_FLAG_SET_ID] = this._options.environmentId; } else if (hookContext.environmentId) { eventAttributes[FEATURE_FLAG_SET_ID] = hookContext.environmentId; } - if (this._options.includeVariant) { - eventAttributes[FEATURE_FLAG_VARIANT_ATTR] = JSON.stringify(detail.value); + if (this._options.includeValue) { + eventAttributes[FEATURE_FLAG_VALUE_ATTR] = JSON.stringify(detail.value); } currentTrace.addEvent(FEATURE_FLAG_SCOPE, eventAttributes); }