Skip to content

Commit ff94516

Browse files
committed
feat: implement new tracing hooks
Signed-off-by: Lukas Reining <[email protected]>
1 parent 15ae73b commit ff94516

File tree

6 files changed

+162
-51
lines changed

6 files changed

+162
-51
lines changed

libs/hooks/open-telemetry/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,5 +94,5 @@ Run `nx package hooks-open-telemetry` to build the library.
9494

9595
Run `nx test hooks-open-telemetry` to execute the unit tests via [Jest](https://jestjs.io).
9696

97-
[otel-spec]: https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/
97+
[otel-spec]: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
9898
[hook-concept]: https://openfeature.dev/docs/reference/concepts/hooks

libs/hooks/open-telemetry/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
"current-version": "echo $npm_package_version"
1616
},
1717
"peerDependencies": {
18-
"@openfeature/server-sdk": "^1.13.0",
18+
"@openfeature/core": "^1.9.1",
1919
"@opentelemetry/api": ">=1.3.0"
20+
},
21+
"dependencies": {
22+
"@opentelemetry/api-events": "^0.202.0"
2023
}
2124
}
Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
1-
// see: https://opentelemetry.io/docs/specs/otel/logs/semantic_conventions/feature-flags/
21
export const FEATURE_FLAG = 'feature_flag';
3-
export const EXCEPTION_ATTR = 'exception';
42

53
export const ACTIVE_COUNT_NAME = `${FEATURE_FLAG}.evaluation_active_count`;
64
export const REQUESTS_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_requests_total`;
75
export const SUCCESS_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_success_total`;
86
export const ERROR_TOTAL_NAME = `${FEATURE_FLAG}.evaluation_error_total`;
97

8+
export const EXCEPTION_ATTR = 'exception';
109
export type EvaluationAttributes = { [key: `${typeof FEATURE_FLAG}.${string}`]: string | undefined };
1110
export type ExceptionAttributes = { [EXCEPTION_ATTR]: string };
12-
13-
export const KEY_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.key`;
14-
export const PROVIDER_NAME_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.provider_name`;
15-
export const VARIANT_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.variant`;
16-
export const REASON_ATTR: keyof EvaluationAttributes = `${FEATURE_FLAG}.reason`;
Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,56 @@
1-
import type { FlagMetadata, Logger } from '@openfeature/server-sdk';
1+
import type {
2+
EvaluationDetails,
3+
FlagMetadata,
4+
FlagValue,
5+
HookContext,
6+
Logger,
7+
TelemetryAttribute,
8+
} from '@openfeature/core';
9+
import { createEvaluationEvent } from '@openfeature/core';
210
import type { Attributes } from '@opentelemetry/api';
311

12+
type EvaluationEvent = { name: string; attributes: Attributes };
13+
type TelemetryAttributesNames = [keyof typeof TelemetryAttribute][number] | string;
14+
415
export type AttributeMapper = (flagMetadata: FlagMetadata) => Attributes;
516

17+
export type EventMutator = (event: EvaluationEvent) => EvaluationEvent;
18+
619
export type OpenTelemetryHookOptions = {
720
/**
821
* A function that maps OpenFeature flag metadata values to OpenTelemetry attributes.
22+
* This can be used to add custom attributes to the telemetry event.
23+
* Note: This function is applied after the excludeAttributes option.
924
*/
1025
attributeMapper?: AttributeMapper;
26+
27+
/**
28+
* Exclude specific attributes from being added to the telemetry event.
29+
* This is useful for excluding sensitive information, or reducing the size of the event.
30+
* By default, no attributes are excluded.
31+
* Note: This option is applied before the attributeMapper and eventMutator options.
32+
*/
33+
excludeAttributes?: TelemetryAttributesNames[];
34+
35+
/**
36+
* Takes a telemetry event and returns a telemetry event.
37+
* This can be used to filter out attributes that are not needed or to add additional attributes.
38+
* Note: This function is applied after the attributeMapper and excludeAttributes options.
39+
*/
40+
eventMutator?: EventMutator;
1141
};
1242

1343
/**
1444
* Base class that does some logging and safely wraps the AttributeMapper.
1545
*/
1646
export abstract class OpenTelemetryHook {
17-
protected safeAttributeMapper: AttributeMapper;
1847
protected abstract name: string;
1948

20-
constructor(options?: OpenTelemetryHookOptions, logger?: Logger) {
49+
protected attributesToExclude: TelemetryAttributesNames[];
50+
protected safeAttributeMapper: AttributeMapper;
51+
protected safeEventMutator: EventMutator;
52+
53+
protected constructor(options?: OpenTelemetryHookOptions, logger?: Logger) {
2154
this.safeAttributeMapper = (flagMetadata: FlagMetadata) => {
2255
try {
2356
return options?.attributeMapper?.(flagMetadata) || {};
@@ -26,5 +59,31 @@ export abstract class OpenTelemetryHook {
2659
return {};
2760
}
2861
};
62+
this.safeEventMutator = (event: EvaluationEvent) => {
63+
try {
64+
return options?.eventMutator?.(event) ?? event;
65+
} catch (err) {
66+
logger?.debug(`${this.name}: error in eventMutator, ${err.message}, ${err.stack}`);
67+
return event;
68+
}
69+
};
70+
this.attributesToExclude = options?.excludeAttributes ?? [];
71+
}
72+
73+
protected toEvaluationEvent(
74+
hookContext: Readonly<HookContext>,
75+
evaluationDetails: EvaluationDetails<FlagValue>,
76+
): EvaluationEvent {
77+
const { name, attributes } = createEvaluationEvent(hookContext, evaluationDetails);
78+
const customAttributes = this.safeAttributeMapper(evaluationDetails.flagMetadata);
79+
80+
for (const attributeToExclude of this.attributesToExclude) {
81+
delete attributes[attributeToExclude];
82+
}
83+
84+
return this.safeEventMutator({
85+
name,
86+
attributes: { ...attributes, ...customAttributes },
87+
});
2988
}
3089
}

libs/hooks/open-telemetry/src/lib/traces/tracing-hook.spec.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { EvaluationDetails, HookContext } from '@openfeature/server-sdk';
1+
import { EvaluationDetails, HookContext, MapHookData } from '@openfeature/core';
22

33
const addEvent = jest.fn();
44
const recordException = jest.fn();
@@ -12,7 +12,7 @@ jest.mock('@opentelemetry/api', () => ({
1212
}));
1313

1414
// Import must be after the mocks
15-
import { TracingHook } from './tracing-hook';
15+
import { SpanEventTracingHook } from './tracing-hook';
1616

1717
describe('OpenTelemetry Hooks', () => {
1818
const hookContext: HookContext = {
@@ -30,9 +30,10 @@ describe('OpenTelemetry Hooks', () => {
3030
defaultValue: true,
3131
flagValueType: 'boolean',
3232
logger: console,
33+
hookData: new MapHookData(),
3334
};
3435

35-
let tracingHook: TracingHook;
36+
let tracingHook: SpanEventTracingHook;
3637

3738
afterEach(() => {
3839
jest.clearAllMocks();
@@ -41,7 +42,7 @@ describe('OpenTelemetry Hooks', () => {
4142
describe('after stage', () => {
4243
describe('no attribute mapper', () => {
4344
beforeEach(() => {
44-
tracingHook = new TracingHook();
45+
tracingHook = new SpanEventTracingHook();
4546
});
4647

4748
it('should use the variant value on the span event', () => {
@@ -52,7 +53,7 @@ describe('OpenTelemetry Hooks', () => {
5253
flagMetadata: {},
5354
};
5455

55-
tracingHook.after(hookContext, evaluationDetails);
56+
tracingHook.finally(hookContext, evaluationDetails);
5657

5758
expect(addEvent).toBeCalledWith('feature_flag', {
5859
'feature_flag.key': 'testFlagKey',
@@ -68,7 +69,7 @@ describe('OpenTelemetry Hooks', () => {
6869
flagMetadata: {},
6970
};
7071

71-
tracingHook.after(hookContext, evaluationDetails);
72+
tracingHook.finally(hookContext, evaluationDetails);
7273

7374
expect(addEvent).toBeCalledWith('feature_flag', {
7475
'feature_flag.key': 'testFlagKey',
@@ -83,7 +84,7 @@ describe('OpenTelemetry Hooks', () => {
8384
value: 'already-string',
8485
flagMetadata: {},
8586
};
86-
tracingHook.after(hookContext, evaluationDetails);
87+
tracingHook.finally(hookContext, evaluationDetails);
8788

8889
expect(addEvent).toBeCalledWith('feature_flag', {
8990
'feature_flag.key': 'testFlagKey',
@@ -101,15 +102,15 @@ describe('OpenTelemetry Hooks', () => {
101102
flagMetadata: {},
102103
};
103104

104-
tracingHook.after(hookContext, evaluationDetails);
105+
tracingHook.finally(hookContext, evaluationDetails);
105106
expect(addEvent).not.toBeCalled();
106107
});
107108
});
108109

109110
describe('attribute mapper configured', () => {
110111
describe('no error in mapper', () => {
111112
beforeEach(() => {
112-
tracingHook = new TracingHook({
113+
tracingHook = new SpanEventTracingHook({
113114
attributeMapper: (flagMetadata) => {
114115
return {
115116
customAttr1: flagMetadata.metadata1,
@@ -132,7 +133,7 @@ describe('OpenTelemetry Hooks', () => {
132133
},
133134
};
134135

135-
tracingHook.after(hookContext, evaluationDetails);
136+
tracingHook.finally(hookContext, evaluationDetails);
136137

137138
expect(addEvent).toBeCalledWith('feature_flag', {
138139
'feature_flag.key': 'testFlagKey',
@@ -147,7 +148,7 @@ describe('OpenTelemetry Hooks', () => {
147148

148149
describe('error in mapper', () => {
149150
beforeEach(() => {
150-
tracingHook = new TracingHook({
151+
tracingHook = new SpanEventTracingHook({
151152
attributeMapper: () => {
152153
throw new Error('fake error');
153154
},
@@ -166,7 +167,7 @@ describe('OpenTelemetry Hooks', () => {
166167
},
167168
};
168169

169-
tracingHook.after(hookContext, evaluationDetails);
170+
tracingHook.finally(hookContext, evaluationDetails);
170171

171172
expect(addEvent).toBeCalledWith('feature_flag', {
172173
'feature_flag.key': 'testFlagKey',

libs/hooks/open-telemetry/src/lib/traces/tracing-hook.ts

Lines changed: 81 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,97 @@
1-
import type { Hook, HookContext, EvaluationDetails, FlagValue, Logger } from '@openfeature/server-sdk';
1+
import type { BaseHook, HookContext, EvaluationDetails, FlagValue, Logger } from '@openfeature/core';
2+
import type { Span } from '@opentelemetry/api';
23
import { trace } from '@opentelemetry/api';
3-
import { FEATURE_FLAG, KEY_ATTR, PROVIDER_NAME_ATTR, VARIANT_ATTR } from '../conventions';
4+
import type { EventLogger } from '@opentelemetry/api-events';
45
import type { OpenTelemetryHookOptions } from '../otel-hook';
56
import { OpenTelemetryHook } from '../otel-hook';
67

7-
export type TracingHookOptions = OpenTelemetryHookOptions;
8+
const tracer = trace.getTracer('dice-server', '0.1.0');
89

910
/**
10-
* A hook that adds conventionally-compliant span events to feature flag evaluations.
11-
*
12-
* See {@link https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/feature-flags/}
11+
* A hook that logs evaluation events to OpenTelemetry using an EventLogger.
12+
* This is useful for exporting evaluation events to a backend that supports
13+
* OpenTelemetry events.
1314
*/
14-
export class TracingHook extends OpenTelemetryHook implements Hook {
15-
protected name = TracingHook.name;
15+
export class LogEventTracingHook extends OpenTelemetryHook implements BaseHook {
16+
protected name = LogEventTracingHook.name;
17+
private eventLogger: EventLogger;
1618

17-
constructor(options?: TracingHookOptions, logger?: Logger) {
19+
constructor(options?: OpenTelemetryHookOptions, logger?: Logger) {
1820
super(options, logger);
1921
}
2022

21-
after(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>) {
23+
finally(hookContext: Readonly<HookContext>, evaluationDetails: EvaluationDetails<FlagValue>) {
24+
this.eventLogger.emit(this.toEvaluationEvent(hookContext, evaluationDetails));
25+
}
26+
27+
error(_: HookContext, err: Error) {
28+
trace.getActiveSpan()?.recordException(err);
29+
}
30+
}
31+
32+
/**
33+
* A hook that adds evaluation events to the current active span.
34+
* This is useful for associating evaluation events with a trace.
35+
* If there is no active span, the event is not logged.
36+
* Span events are being deprecated in favor of using log events.
37+
*/
38+
export class SpanEventTracingHook extends OpenTelemetryHook implements BaseHook {
39+
protected name = SpanEventTracingHook.name;
40+
41+
constructor(options?: OpenTelemetryHookOptions, logger?: Logger) {
42+
super(options, logger);
43+
}
44+
45+
finally(hookContext: Readonly<HookContext>, evaluationDetails: EvaluationDetails<FlagValue>) {
2246
const currentTrace = trace.getActiveSpan();
23-
if (currentTrace) {
24-
let variant = evaluationDetails.variant;
25-
26-
if (!variant) {
27-
if (typeof evaluationDetails.value === 'string') {
28-
variant = evaluationDetails.value;
29-
} else {
30-
variant = JSON.stringify(evaluationDetails.value);
31-
}
32-
}
33-
34-
currentTrace.addEvent(FEATURE_FLAG, {
35-
[KEY_ATTR]: hookContext.flagKey,
36-
[PROVIDER_NAME_ATTR]: hookContext.providerMetadata.name,
37-
[VARIANT_ATTR]: variant,
38-
...this.safeAttributeMapper(evaluationDetails.flagMetadata),
39-
});
47+
if (!currentTrace) {
48+
return;
49+
}
50+
51+
const { name, attributes } = this.toEvaluationEvent(hookContext, evaluationDetails);
52+
53+
currentTrace.addEvent(name, attributes);
54+
}
55+
56+
error(_: HookContext, err: Error) {
57+
trace.getActiveSpan()?.recordException(err);
58+
}
59+
}
60+
61+
const HookContestSpanKey = Symbol('evaluation_span');
62+
type SpanAttributesTracingHookData = { [HookContestSpanKey]: Span };
63+
64+
/**
65+
* A hook that creates a new span for each flag evaluation and sets the evaluation
66+
* details as span attributes.
67+
* This is useful for tracing flag evaluations as part of a larger trace.
68+
* If there is no active span, a new root span is created.
69+
*/
70+
export class SpanAttributesTracingHook extends OpenTelemetryHook implements BaseHook {
71+
protected name = SpanAttributesTracingHook.name;
72+
73+
constructor(options?: OpenTelemetryHookOptions, logger?: Logger) {
74+
super(options, logger);
75+
}
76+
77+
before(hookContext: HookContext<FlagValue, SpanAttributesTracingHookData>) {
78+
const evaluationSpan = tracer.startSpan('feature_flag.evaluation');
79+
hookContext.hookData.set(HookContestSpanKey, evaluationSpan);
80+
}
81+
82+
finally(
83+
hookContext: Readonly<HookContext<FlagValue, SpanAttributesTracingHookData>>,
84+
evaluationDetails: EvaluationDetails<FlagValue>,
85+
) {
86+
const currentSpan = hookContext.hookData.get(HookContestSpanKey);
87+
if (!currentSpan) {
88+
return;
4089
}
90+
91+
const { attributes } = this.toEvaluationEvent(hookContext, evaluationDetails);
92+
93+
currentSpan.setAttributes(attributes);
94+
currentSpan.end();
4195
}
4296

4397
error(_: HookContext, err: Error) {

0 commit comments

Comments
 (0)