-
Notifications
You must be signed in to change notification settings - Fork 403
feat(openfeature): implement flag evaluation metrics #7993
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| 'use strict' | ||
|
|
||
| const log = require('../log') | ||
|
|
||
| const METER_NAME = 'dd-trace-js/openfeature' | ||
| const COUNTER_NAME = 'feature_flag.evaluations' | ||
| const COUNTER_DESCRIPTION = 'Number of feature flag evaluations' | ||
| const COUNTER_UNIT = '{evaluation}' | ||
|
|
||
| /** | ||
| * OpenFeature hook that tracks feature flag evaluation metrics using an | ||
| * OpenTelemetry counter. | ||
| * | ||
| * Implements the OpenFeature `finally` hook interface so it can be pushed | ||
| * directly onto a provider's `hooks` array. We use the `finally` stage | ||
| * (not diagnostic channels inside the provider's `resolve*` methods) because | ||
| * the OpenFeature SDK short-circuits before calling the provider when it is in | ||
| * NOT_READY state; the `finally` hook still fires, ensuring all evaluations are | ||
| * captured. It also catches type-mismatch errors detected by the SDK client | ||
| * after the provider returns. | ||
| * | ||
| * The counter is created lazily on the first successful `finally()` call rather | ||
| * than in the constructor. This is necessary because `FlaggingProvider` is | ||
| * constructed eagerly by `proxy.js#updateTracing()`, which runs *before* | ||
| * `initializeOpenTelemetryMetrics()` sets the global OTel meter provider. | ||
| * Calling `getMeter()` in the constructor would return the noop meter and | ||
| * produce a noop counter that silently discards all measurements. By deferring | ||
| * to `finally()` time we give the meter provider a chance to be set up first. | ||
| * | ||
| * If counter creation fails (e.g. the OTel API is not yet available), the call | ||
| * is silently skipped and retried on the next `finally()` invocation. | ||
| * | ||
| * When `config.otelMetricsEnabled` is false, `finally()` is always a no-op. | ||
| */ | ||
| class EvalMetricsHook { | ||
| #enabled = false | ||
| #counter = null | ||
|
|
||
| /** | ||
| * @param {import('../config')} config - Tracer configuration object | ||
| */ | ||
| constructor (config) { | ||
| this.#enabled = config.otelMetricsEnabled === true | ||
| } | ||
|
|
||
| /** | ||
| * Returns the OTel counter, creating it on first successful call. | ||
| * Returns `null` if counter creation fails; will retry on next call. | ||
| * | ||
| * @returns {import('@opentelemetry/api').Counter | null} | ||
| */ | ||
| #getCounter () { | ||
| if (this.#counter) return this.#counter | ||
|
|
||
| try { | ||
| const { metrics } = require('@opentelemetry/api') | ||
|
dd-oleksii marked this conversation as resolved.
|
||
| const meter = metrics.getMeter(METER_NAME) | ||
| this.#counter = meter.createCounter(COUNTER_NAME, { | ||
| description: COUNTER_DESCRIPTION, | ||
| unit: COUNTER_UNIT, | ||
| }) | ||
| } catch (e) { | ||
| log.warn('EvalMetricsHook: failed to create counter: %s', e.message) | ||
| } | ||
|
|
||
| return this.#counter | ||
| } | ||
|
|
||
| /** | ||
| * Called by the OpenFeature SDK after every flag evaluation (success or error). | ||
| * | ||
| * @param {{ flagKey: string }} hookContext - Hook context containing the flag key | ||
| * @param {{ variant?: string, reason?: string, errorCode?: string, flagMetadata?: object }} evaluationDetails | ||
| * - Full evaluation details | ||
| * @returns {void} | ||
| */ | ||
| finally (hookContext, evaluationDetails) { | ||
| if (!this.#enabled) return | ||
|
|
||
| const counter = this.#getCounter() | ||
| if (!counter) return | ||
|
|
||
| const attributes = { | ||
| 'feature_flag.key': hookContext?.flagKey ?? '', | ||
| 'feature_flag.result.variant': evaluationDetails?.variant ?? '', | ||
| 'feature_flag.result.reason': evaluationDetails?.reason?.toLowerCase() ?? 'unknown', | ||
| } | ||
|
|
||
| const errorCode = evaluationDetails?.errorCode | ||
| if (errorCode) { | ||
| attributes['error.type'] = errorCode.toLowerCase() | ||
| } | ||
|
|
||
| const allocationKey = evaluationDetails?.flagMetadata?.allocationKey | ||
| if (allocationKey) { | ||
| attributes['feature_flag.result.allocation_key'] = allocationKey | ||
| } | ||
|
|
||
| counter.add(1, attributes) | ||
|
dd-oleksii marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| module.exports = EvalMetricsHook | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
213 changes: 213 additions & 0 deletions
213
packages/dd-trace/test/openfeature/eval-metrics-hook.spec.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| 'use strict' | ||
|
|
||
| const assert = require('node:assert/strict') | ||
|
|
||
| const { describe, it, beforeEach } = require('mocha') | ||
| const sinon = require('sinon') | ||
| const proxyquire = require('proxyquire') | ||
|
|
||
| require('../setup/core') | ||
|
|
||
| describe('EvalMetricsHook', () => { | ||
| let EvalMetricsHook | ||
| let mockCounter | ||
| let mockMeter | ||
| let mockOtelApi | ||
| let log | ||
|
|
||
| beforeEach(() => { | ||
| mockCounter = { | ||
| add: sinon.spy(), | ||
| } | ||
|
|
||
| mockMeter = { | ||
| createCounter: sinon.stub().returns(mockCounter), | ||
| } | ||
|
|
||
| mockOtelApi = { | ||
| metrics: { | ||
| getMeter: sinon.stub().returns(mockMeter), | ||
| }, | ||
| } | ||
|
|
||
| log = { | ||
| warn: sinon.spy(), | ||
| debug: sinon.spy(), | ||
| } | ||
|
|
||
| EvalMetricsHook = proxyquire('../../src/openfeature/eval-metrics-hook', { | ||
| '@opentelemetry/api': mockOtelApi, | ||
| '../log': log, | ||
| }) | ||
| }) | ||
|
|
||
| function makeConfig (otelMetricsEnabled = true) { | ||
| return { otelMetricsEnabled } | ||
| } | ||
|
|
||
| function hookContext (flagKey = 'flag') { | ||
| return { flagKey } | ||
| } | ||
|
|
||
| function evalDetails (overrides = {}) { | ||
| return { variant: 'on', reason: 'TARGETING_MATCH', ...overrides } | ||
| } | ||
|
|
||
| describe('finally()', () => { | ||
| it('should be a no-op when disabled', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(false)) | ||
| metrics.finally(hookContext(), evalDetails()) | ||
|
|
||
| sinon.assert.notCalled(mockCounter.add) | ||
| sinon.assert.notCalled(mockOtelApi.metrics.getMeter) | ||
| }) | ||
|
|
||
| it('should create counter lazily on first call', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| sinon.assert.notCalled(mockOtelApi.metrics.getMeter) | ||
|
|
||
| metrics.finally(hookContext('my-flag'), evalDetails()) | ||
|
|
||
| sinon.assert.calledOnceWithExactly(mockOtelApi.metrics.getMeter, 'dd-trace-js/openfeature') | ||
| sinon.assert.calledWith(mockMeter.createCounter, 'feature_flag.evaluations', { | ||
| description: 'Number of feature flag evaluations', | ||
| unit: '{evaluation}', | ||
| }) | ||
| }) | ||
|
|
||
| it('should cache the counter after first call', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally(hookContext(), evalDetails()) | ||
| metrics.finally(hookContext(), evalDetails()) | ||
|
|
||
| sinon.assert.calledOnce(mockOtelApi.metrics.getMeter) | ||
| }) | ||
|
|
||
| it('should add counter with basic attributes', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally(hookContext('my-flag'), evalDetails()) | ||
|
|
||
| sinon.assert.calledOnce(mockCounter.add) | ||
| const [value, attributes] = mockCounter.add.firstCall.args | ||
| assert.strictEqual(value, 1) | ||
| assert.deepStrictEqual(attributes, { | ||
| 'feature_flag.key': 'my-flag', | ||
| 'feature_flag.result.variant': 'on', | ||
| 'feature_flag.result.reason': 'targeting_match', | ||
| }) | ||
| }) | ||
|
|
||
| it('should lowercase the reason', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally(hookContext(), evalDetails({ reason: 'DEFAULT' })) | ||
|
|
||
| const [, attributes] = mockCounter.add.firstCall.args | ||
| assert.strictEqual(attributes['feature_flag.result.reason'], 'default') | ||
| }) | ||
|
|
||
| it('should use empty string for variant when variant is undefined', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally(hookContext(), evalDetails({ variant: undefined })) | ||
|
|
||
| const [, attributes] = mockCounter.add.firstCall.args | ||
| assert.strictEqual(attributes['feature_flag.result.variant'], '') | ||
| }) | ||
|
|
||
| it('should include error.type when errorCode is set', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally( | ||
| hookContext('flag'), | ||
| evalDetails({ variant: undefined, reason: 'ERROR', errorCode: 'TYPE_MISMATCH' }) | ||
| ) | ||
|
|
||
| const [, attributes] = mockCounter.add.firstCall.args | ||
| assert.deepStrictEqual(attributes, { | ||
| 'feature_flag.key': 'flag', | ||
| 'feature_flag.result.variant': '', | ||
| 'feature_flag.result.reason': 'error', | ||
| 'error.type': 'type_mismatch', | ||
| }) | ||
| }) | ||
|
|
||
| it('should lowercase errorCode in error.type', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally(hookContext(), evalDetails({ reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' })) | ||
|
|
||
| const [, attributes] = mockCounter.add.firstCall.args | ||
| assert.strictEqual(attributes['error.type'], 'flag_not_found') | ||
| }) | ||
|
|
||
| it('should omit error.type when errorCode is falsy', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally(hookContext(), evalDetails()) | ||
|
|
||
| const [, attributes] = mockCounter.add.firstCall.args | ||
| assert.ok(!Object.hasOwn(attributes, 'error.type')) | ||
| }) | ||
|
|
||
| it('should include allocation_key when set', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally( | ||
| hookContext('flag'), | ||
| evalDetails({ flagMetadata: { allocationKey: 'default-allocation' } }) | ||
| ) | ||
|
|
||
| const [, attributes] = mockCounter.add.firstCall.args | ||
| assert.deepStrictEqual(attributes, { | ||
| 'feature_flag.key': 'flag', | ||
| 'feature_flag.result.variant': 'on', | ||
| 'feature_flag.result.reason': 'targeting_match', | ||
| 'feature_flag.result.allocation_key': 'default-allocation', | ||
| }) | ||
| }) | ||
|
|
||
| it('should omit allocation_key when flagMetadata is absent', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally(hookContext(), evalDetails()) | ||
|
|
||
| const [, attributes] = mockCounter.add.firstCall.args | ||
| assert.ok(!Object.hasOwn(attributes, 'feature_flag.result.allocation_key')) | ||
| }) | ||
|
|
||
| it('should omit allocation_key when flagMetadata is empty', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally(hookContext(), evalDetails({ flagMetadata: {} })) | ||
|
|
||
| const [, attributes] = mockCounter.add.firstCall.args | ||
| assert.ok(!Object.hasOwn(attributes, 'feature_flag.result.allocation_key')) | ||
| }) | ||
|
|
||
| it('should skip when OTel api throws', () => { | ||
| mockOtelApi.metrics.getMeter.throws(new Error('OTel not ready')) | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
|
|
||
| metrics.finally(hookContext(), evalDetails()) | ||
|
|
||
| sinon.assert.notCalled(mockCounter.add) | ||
| sinon.assert.calledOnce(log.warn) | ||
|
|
||
| // Fix the OTel API and verify retry succeeds | ||
| mockOtelApi.metrics.getMeter.returns(mockMeter) | ||
| metrics.finally(hookContext(), evalDetails()) | ||
|
|
||
| sinon.assert.calledOnce(mockCounter.add) | ||
| }) | ||
|
|
||
| it('should handle null/undefined hookContext gracefully', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally(undefined, evalDetails()) | ||
| metrics.finally(null, evalDetails()) | ||
|
|
||
| assert.strictEqual(mockCounter.add.callCount, 2) | ||
| assert.strictEqual(mockCounter.add.firstCall.args[1]['feature_flag.key'], '') | ||
| }) | ||
|
|
||
| it('should handle null/undefined evaluationDetails gracefully', () => { | ||
| const metrics = new EvalMetricsHook(makeConfig(true)) | ||
| metrics.finally(hookContext(), undefined) | ||
| metrics.finally(hookContext(), null) | ||
|
|
||
| assert.strictEqual(mockCounter.add.callCount, 2) | ||
| }) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.