Skip to content

Commit 1e93b3c

Browse files
beeme1mrtoddbaert
andauthored
feat: add telemetry helper utils (#1120)
## This PR - adds a method to core that returns a semantically valid flag evaluation event ### Related Issues Fixes #1118 ## Notes I've omitted value type because it is likely to be declined in the OTel spec and adds complexity. We should consider removing that section from [appendix d](https://openfeature.dev/specification/appendix-d#evaluation-details). --------- Signed-off-by: Michael Beemer <[email protected]> Signed-off-by: Todd Baert <[email protected]> Co-authored-by: Todd Baert <[email protected]>
1 parent bc9f6e4 commit 1e93b3c

File tree

6 files changed

+301
-0
lines changed

6 files changed

+301
-0
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* The attributes of an OpenTelemetry compliant event for flag evaluation.
3+
* @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
4+
*/
5+
export const TelemetryAttribute = {
6+
/**
7+
* The lookup key of the feature flag.
8+
*
9+
* - type: `string`
10+
* - requirement level: `required`
11+
* - example: `logo-color`
12+
*/
13+
KEY: 'feature_flag.key',
14+
/**
15+
* Describes a class of error the operation ended with.
16+
*
17+
* - type: `string`
18+
* - requirement level: `conditionally required`
19+
* - condition: `reason` is `error`
20+
* - example: `flag_not_found`
21+
*/
22+
ERROR_CODE: 'error.type',
23+
/**
24+
* A semantic identifier for an evaluated flag value.
25+
*
26+
* - type: `string`
27+
* - requirement level: `conditionally required`
28+
* - condition: variant is defined on the evaluation details
29+
* - example: `blue`; `on`; `true`
30+
*/
31+
VARIANT: 'feature_flag.variant',
32+
/**
33+
* The unique identifier for the flag evaluation context. For example, the targeting key.
34+
*
35+
* - type: `string`
36+
* - requirement level: `recommended`
37+
* - example: `5157782b-2203-4c80-a857-dbbd5e7761db`
38+
*/
39+
CONTEXT_ID: 'feature_flag.context.id',
40+
/**
41+
* A message explaining the nature of an error occurring during flag evaluation.
42+
*
43+
* - type: `string`
44+
* - requirement level: `recommended`
45+
* - example: `Flag not found`
46+
*/
47+
ERROR_MESSAGE: 'feature_flag.evaluation.error.message',
48+
/**
49+
* The reason code which shows how a feature flag value was determined.
50+
*
51+
* - type: `string`
52+
* - requirement level: `recommended`
53+
* - example: `targeting_match`
54+
*/
55+
REASON: 'feature_flag.evaluation.reason',
56+
/**
57+
* Describes a class of error the operation ended with.
58+
*
59+
* - type: `string`
60+
* - requirement level: `recommended`
61+
* - example: `flag_not_found`
62+
*/
63+
PROVIDER: 'feature_flag.provider_name',
64+
/**
65+
* The identifier of the flag set to which the feature flag belongs.
66+
*
67+
* - type: `string`
68+
* - requirement level: `recommended`
69+
* - example: `proj-1`; `ab98sgs`; `service1/dev`
70+
*/
71+
FLAG_SET_ID: 'feature_flag.set.id',
72+
/**
73+
* The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset.
74+
*
75+
* - type: `string`
76+
* - requirement level: `recommended`
77+
* - example: `1.0.0`; `2021-01-01`
78+
*/
79+
VERSION: 'feature_flag.version',
80+
} as const;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Event data, sometimes referred as "body", is specific to a specific event.
3+
* In this case, the event is `feature_flag.evaluation`. That's why the prefix
4+
* is omitted from the values.
5+
* @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
6+
*/
7+
export const TelemetryEvaluationData = {
8+
/**
9+
* The evaluated value of the feature flag.
10+
*
11+
* - type: `undefined`
12+
* - requirement level: `conditionally required`
13+
* - condition: variant is not defined on the evaluation details
14+
* - example: `#ff0000`; `1`; `true`
15+
*/
16+
VALUE: 'value',
17+
} as const;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails, type FlagValue } from '../evaluation/evaluation';
2+
import type { HookContext } from '../hooks/hooks';
3+
import type { JsonValue } from '../types';
4+
import { TelemetryAttribute } from './attributes';
5+
import { TelemetryEvaluationData } from './evaluation-data';
6+
import { TelemetryFlagMetadata } from './flag-metadata';
7+
8+
type EvaluationEvent = {
9+
name: string;
10+
attributes: Record<string, string | number | boolean>;
11+
data: Record<string, JsonValue>;
12+
};
13+
14+
const FLAG_EVALUATION_EVENT_NAME = 'feature_flag.evaluation';
15+
16+
/**
17+
* Returns an OpenTelemetry compliant event for flag evaluation.
18+
* @param {HookContext} hookContext Contextual information about the flag evaluation
19+
* @param {EvaluationDetails} evaluationDetails The details of the flag evaluation
20+
* @returns {EvaluationEvent} An evaluation event object containing the event name and attributes
21+
*/
22+
export function createEvaluationEvent(
23+
hookContext: Readonly<HookContext<FlagValue>>,
24+
evaluationDetails: EvaluationDetails<FlagValue>,
25+
): EvaluationEvent {
26+
const attributes: EvaluationEvent['attributes'] = {
27+
[TelemetryAttribute.KEY]: hookContext.flagKey,
28+
[TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name,
29+
[TelemetryAttribute.REASON]: (evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN).toLowerCase(),
30+
};
31+
const data: EvaluationEvent['data'] = {};
32+
33+
if (evaluationDetails.variant) {
34+
attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant;
35+
} else {
36+
data[TelemetryEvaluationData.VALUE] = evaluationDetails.value;
37+
}
38+
39+
const contextId =
40+
evaluationDetails.flagMetadata[TelemetryFlagMetadata.CONTEXT_ID] || hookContext.context.targetingKey;
41+
if (contextId) {
42+
attributes[TelemetryAttribute.CONTEXT_ID] = contextId;
43+
}
44+
45+
const setId = evaluationDetails.flagMetadata[TelemetryFlagMetadata.FLAG_SET_ID];
46+
if (setId) {
47+
attributes[TelemetryAttribute.FLAG_SET_ID] = setId;
48+
}
49+
50+
const version = evaluationDetails.flagMetadata[TelemetryFlagMetadata.VERSION];
51+
if (version) {
52+
attributes[TelemetryAttribute.VERSION] = version;
53+
}
54+
55+
if (evaluationDetails.reason === StandardResolutionReasons.ERROR) {
56+
attributes[TelemetryAttribute.ERROR_CODE] = (evaluationDetails.errorCode ?? ErrorCode.GENERAL).toLowerCase();
57+
if (evaluationDetails.errorMessage) {
58+
attributes[TelemetryAttribute.ERROR_MESSAGE] = evaluationDetails.errorMessage;
59+
}
60+
}
61+
62+
return {
63+
name: FLAG_EVALUATION_EVENT_NAME,
64+
attributes,
65+
data,
66+
};
67+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Well-known flag metadata attributes for telemetry events.
3+
* @see https://openfeature.dev/specification/appendix-d#flag-metadata
4+
*/
5+
export const TelemetryFlagMetadata = {
6+
/**
7+
* The context identifier returned in the flag metadata uniquely identifies
8+
* the subject of the flag evaluation. If not available, the targeting key
9+
* should be used.
10+
*/
11+
CONTEXT_ID: 'contextId',
12+
/**
13+
* A logical identifier for the flag set.
14+
*/
15+
FLAG_SET_ID: 'flagSetId',
16+
/**
17+
* A version string (format unspecified) for the flag or flag set.
18+
*/
19+
VERSION: 'version',
20+
} as const;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './attributes';
2+
export * from './evaluation-data';
3+
export * from './flag-metadata';
4+
export * from './evaluation-event';
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { createEvaluationEvent } from '../src/telemetry/evaluation-event';
2+
import { ErrorCode, StandardResolutionReasons, type EvaluationDetails } from '../src/evaluation/evaluation';
3+
import type { HookContext } from '../src/hooks/hooks';
4+
import { TelemetryAttribute, TelemetryFlagMetadata, TelemetryEvaluationData } from '../src/telemetry';
5+
6+
describe('evaluationEvent', () => {
7+
const flagKey = 'test-flag';
8+
const providerMetadata = {
9+
name: 'test-provider',
10+
};
11+
const mockHookContext: HookContext<boolean> = {
12+
flagKey,
13+
providerMetadata: providerMetadata,
14+
context: {
15+
targetingKey: 'test-target',
16+
},
17+
clientMetadata: {
18+
providerMetadata,
19+
},
20+
defaultValue: false,
21+
flagValueType: 'boolean',
22+
logger: {
23+
debug: jest.fn(),
24+
info: jest.fn(),
25+
error: jest.fn(),
26+
warn: jest.fn(),
27+
},
28+
};
29+
30+
it('should return basic event body with mandatory fields', () => {
31+
const details: EvaluationDetails<boolean> = {
32+
value: true,
33+
reason: StandardResolutionReasons.STATIC,
34+
flagMetadata: {},
35+
flagKey,
36+
};
37+
38+
const result = createEvaluationEvent(mockHookContext, details);
39+
40+
expect(result.name).toBe('feature_flag.evaluation');
41+
expect(result.attributes).toEqual({
42+
[TelemetryAttribute.KEY]: 'test-flag',
43+
[TelemetryAttribute.PROVIDER]: 'test-provider',
44+
[TelemetryAttribute.REASON]: StandardResolutionReasons.STATIC.toLowerCase(),
45+
[TelemetryAttribute.CONTEXT_ID]: 'test-target',
46+
});
47+
expect(result.data).toEqual({
48+
[TelemetryEvaluationData.VALUE]: true,
49+
});
50+
});
51+
52+
it('should include variant when provided', () => {
53+
const details: EvaluationDetails<boolean> = {
54+
flagKey,
55+
value: true,
56+
variant: 'test-variant',
57+
reason: StandardResolutionReasons.STATIC,
58+
flagMetadata: {},
59+
};
60+
61+
const result = createEvaluationEvent(mockHookContext, details);
62+
63+
expect(result.attributes[TelemetryAttribute.VARIANT]).toBe('test-variant');
64+
expect(result.attributes[TelemetryEvaluationData.VALUE]).toBeUndefined();
65+
});
66+
67+
it('should include flag metadata when provided', () => {
68+
const details: EvaluationDetails<boolean> = {
69+
flagKey,
70+
value: true,
71+
reason: StandardResolutionReasons.STATIC,
72+
flagMetadata: {
73+
[TelemetryFlagMetadata.FLAG_SET_ID]: 'test-set',
74+
[TelemetryFlagMetadata.VERSION]: 'v1.0',
75+
[TelemetryFlagMetadata.CONTEXT_ID]: 'metadata-context',
76+
},
77+
};
78+
79+
const result = createEvaluationEvent(mockHookContext, details);
80+
81+
expect(result.attributes[TelemetryAttribute.FLAG_SET_ID]).toBe('test-set');
82+
expect(result.attributes[TelemetryAttribute.VERSION]).toBe('v1.0');
83+
expect(result.attributes[TelemetryAttribute.CONTEXT_ID]).toBe('metadata-context');
84+
});
85+
86+
it('should handle error cases', () => {
87+
const details: EvaluationDetails<boolean> = {
88+
flagKey,
89+
value: false,
90+
reason: StandardResolutionReasons.ERROR,
91+
errorCode: ErrorCode.GENERAL,
92+
errorMessage: 'test error',
93+
flagMetadata: {},
94+
};
95+
96+
const result = createEvaluationEvent(mockHookContext, details);
97+
98+
expect(result.attributes[TelemetryAttribute.ERROR_CODE]).toBe(ErrorCode.GENERAL.toLowerCase());
99+
expect(result.attributes[TelemetryAttribute.ERROR_MESSAGE]).toBe('test error');
100+
});
101+
102+
it('should use unknown reason when reason is not provided', () => {
103+
const details: EvaluationDetails<boolean> = {
104+
flagKey,
105+
value: true,
106+
flagMetadata: {},
107+
};
108+
109+
const result = createEvaluationEvent(mockHookContext, details);
110+
111+
expect(result.attributes[TelemetryAttribute.REASON]).toBe(StandardResolutionReasons.UNKNOWN.toLowerCase());
112+
});
113+
});

0 commit comments

Comments
 (0)