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
143 changes: 122 additions & 21 deletions packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
});
});
53 changes: 41 additions & 12 deletions packages/telemetry/node-server-sdk-otel/src/TracingHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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`.
Expand All @@ -56,7 +71,7 @@ export interface TracingHookOptions {

interface ValidatedHookOptions {
spans: boolean;
includeVariant: boolean;
includeValue: boolean;
logger: LDLogger;
environmentId?: string;
}
Expand All @@ -67,7 +82,7 @@ type SpanTraceData = {

const defaultOptions: ValidatedHookOptions = {
spans: false,
includeVariant: false,
includeValue: false,
logger: basicLogger({ name: TRACING_HOOK_NAME }),
environmentId: undefined,
};
Expand All @@ -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),
Expand Down Expand Up @@ -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 };
}
Expand All @@ -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);
}
Expand Down