|
| 1 | +'use strict' |
| 2 | + |
| 3 | +const assert = require('node:assert/strict') |
| 4 | + |
| 5 | +const { describe, it, beforeEach } = require('mocha') |
| 6 | +const sinon = require('sinon') |
| 7 | +const proxyquire = require('proxyquire') |
| 8 | + |
| 9 | +require('../setup/core') |
| 10 | + |
| 11 | +describe('EvalMetricsHook', () => { |
| 12 | + let EvalMetricsHook |
| 13 | + let mockCounter |
| 14 | + let mockMeter |
| 15 | + let mockOtelApi |
| 16 | + let log |
| 17 | + |
| 18 | + beforeEach(() => { |
| 19 | + mockCounter = { |
| 20 | + add: sinon.spy(), |
| 21 | + } |
| 22 | + |
| 23 | + mockMeter = { |
| 24 | + createCounter: sinon.stub().returns(mockCounter), |
| 25 | + } |
| 26 | + |
| 27 | + mockOtelApi = { |
| 28 | + metrics: { |
| 29 | + getMeter: sinon.stub().returns(mockMeter), |
| 30 | + }, |
| 31 | + } |
| 32 | + |
| 33 | + log = { |
| 34 | + warn: sinon.spy(), |
| 35 | + debug: sinon.spy(), |
| 36 | + } |
| 37 | + |
| 38 | + EvalMetricsHook = proxyquire('../../src/openfeature/eval-metrics-hook', { |
| 39 | + '@opentelemetry/api': mockOtelApi, |
| 40 | + '../log': log, |
| 41 | + }) |
| 42 | + }) |
| 43 | + |
| 44 | + function makeConfig (otelMetricsEnabled = true) { |
| 45 | + return { otelMetricsEnabled } |
| 46 | + } |
| 47 | + |
| 48 | + function hookContext (flagKey = 'flag') { |
| 49 | + return { flagKey } |
| 50 | + } |
| 51 | + |
| 52 | + function evalDetails (overrides = {}) { |
| 53 | + return { variant: 'on', reason: 'TARGETING_MATCH', ...overrides } |
| 54 | + } |
| 55 | + |
| 56 | + describe('finally()', () => { |
| 57 | + it('should be a no-op when disabled', () => { |
| 58 | + const metrics = new EvalMetricsHook(makeConfig(false)) |
| 59 | + metrics.finally(hookContext(), evalDetails()) |
| 60 | + |
| 61 | + sinon.assert.notCalled(mockCounter.add) |
| 62 | + sinon.assert.notCalled(mockOtelApi.metrics.getMeter) |
| 63 | + }) |
| 64 | + |
| 65 | + it('should create counter lazily on first call', () => { |
| 66 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 67 | + sinon.assert.notCalled(mockOtelApi.metrics.getMeter) |
| 68 | + |
| 69 | + metrics.finally(hookContext('my-flag'), evalDetails()) |
| 70 | + |
| 71 | + sinon.assert.calledOnceWithExactly(mockOtelApi.metrics.getMeter, 'dd-trace-js/openfeature') |
| 72 | + sinon.assert.calledWith(mockMeter.createCounter, 'feature_flag.evaluations', { |
| 73 | + description: 'Number of feature flag evaluations', |
| 74 | + unit: '{evaluation}', |
| 75 | + }) |
| 76 | + }) |
| 77 | + |
| 78 | + it('should cache the counter after first call', () => { |
| 79 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 80 | + metrics.finally(hookContext(), evalDetails()) |
| 81 | + metrics.finally(hookContext(), evalDetails()) |
| 82 | + |
| 83 | + sinon.assert.calledOnce(mockOtelApi.metrics.getMeter) |
| 84 | + }) |
| 85 | + |
| 86 | + it('should add counter with basic attributes', () => { |
| 87 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 88 | + metrics.finally(hookContext('my-flag'), evalDetails()) |
| 89 | + |
| 90 | + sinon.assert.calledOnce(mockCounter.add) |
| 91 | + const [value, attributes] = mockCounter.add.firstCall.args |
| 92 | + assert.strictEqual(value, 1) |
| 93 | + assert.deepStrictEqual(attributes, { |
| 94 | + 'feature_flag.key': 'my-flag', |
| 95 | + 'feature_flag.result.variant': 'on', |
| 96 | + 'feature_flag.result.reason': 'targeting_match', |
| 97 | + }) |
| 98 | + }) |
| 99 | + |
| 100 | + it('should lowercase the reason', () => { |
| 101 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 102 | + metrics.finally(hookContext(), evalDetails({ reason: 'DEFAULT' })) |
| 103 | + |
| 104 | + const [, attributes] = mockCounter.add.firstCall.args |
| 105 | + assert.strictEqual(attributes['feature_flag.result.reason'], 'default') |
| 106 | + }) |
| 107 | + |
| 108 | + it('should use empty string for variant when variant is undefined', () => { |
| 109 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 110 | + metrics.finally(hookContext(), evalDetails({ variant: undefined })) |
| 111 | + |
| 112 | + const [, attributes] = mockCounter.add.firstCall.args |
| 113 | + assert.strictEqual(attributes['feature_flag.result.variant'], '') |
| 114 | + }) |
| 115 | + |
| 116 | + it('should include error.type when errorCode is set', () => { |
| 117 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 118 | + metrics.finally( |
| 119 | + hookContext('flag'), |
| 120 | + evalDetails({ variant: undefined, reason: 'ERROR', errorCode: 'TYPE_MISMATCH' }) |
| 121 | + ) |
| 122 | + |
| 123 | + const [, attributes] = mockCounter.add.firstCall.args |
| 124 | + assert.deepStrictEqual(attributes, { |
| 125 | + 'feature_flag.key': 'flag', |
| 126 | + 'feature_flag.result.variant': '', |
| 127 | + 'feature_flag.result.reason': 'error', |
| 128 | + 'error.type': 'type_mismatch', |
| 129 | + }) |
| 130 | + }) |
| 131 | + |
| 132 | + it('should lowercase errorCode in error.type', () => { |
| 133 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 134 | + metrics.finally(hookContext(), evalDetails({ reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' })) |
| 135 | + |
| 136 | + const [, attributes] = mockCounter.add.firstCall.args |
| 137 | + assert.strictEqual(attributes['error.type'], 'flag_not_found') |
| 138 | + }) |
| 139 | + |
| 140 | + it('should omit error.type when errorCode is falsy', () => { |
| 141 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 142 | + metrics.finally(hookContext(), evalDetails()) |
| 143 | + |
| 144 | + const [, attributes] = mockCounter.add.firstCall.args |
| 145 | + assert.ok(!Object.hasOwn(attributes, 'error.type')) |
| 146 | + }) |
| 147 | + |
| 148 | + it('should include allocation_key when set', () => { |
| 149 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 150 | + metrics.finally( |
| 151 | + hookContext('flag'), |
| 152 | + evalDetails({ flagMetadata: { allocationKey: 'default-allocation' } }) |
| 153 | + ) |
| 154 | + |
| 155 | + const [, attributes] = mockCounter.add.firstCall.args |
| 156 | + assert.deepStrictEqual(attributes, { |
| 157 | + 'feature_flag.key': 'flag', |
| 158 | + 'feature_flag.result.variant': 'on', |
| 159 | + 'feature_flag.result.reason': 'targeting_match', |
| 160 | + 'feature_flag.result.allocation_key': 'default-allocation', |
| 161 | + }) |
| 162 | + }) |
| 163 | + |
| 164 | + it('should omit allocation_key when flagMetadata is absent', () => { |
| 165 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 166 | + metrics.finally(hookContext(), evalDetails()) |
| 167 | + |
| 168 | + const [, attributes] = mockCounter.add.firstCall.args |
| 169 | + assert.ok(!Object.hasOwn(attributes, 'feature_flag.result.allocation_key')) |
| 170 | + }) |
| 171 | + |
| 172 | + it('should omit allocation_key when flagMetadata is empty', () => { |
| 173 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 174 | + metrics.finally(hookContext(), evalDetails({ flagMetadata: {} })) |
| 175 | + |
| 176 | + const [, attributes] = mockCounter.add.firstCall.args |
| 177 | + assert.ok(!Object.hasOwn(attributes, 'feature_flag.result.allocation_key')) |
| 178 | + }) |
| 179 | + |
| 180 | + it('should skip when OTel api throws', () => { |
| 181 | + mockOtelApi.metrics.getMeter.throws(new Error('OTel not ready')) |
| 182 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 183 | + |
| 184 | + metrics.finally(hookContext(), evalDetails()) |
| 185 | + |
| 186 | + sinon.assert.notCalled(mockCounter.add) |
| 187 | + sinon.assert.calledOnce(log.warn) |
| 188 | + |
| 189 | + // Fix the OTel API and verify retry succeeds |
| 190 | + mockOtelApi.metrics.getMeter.returns(mockMeter) |
| 191 | + metrics.finally(hookContext(), evalDetails()) |
| 192 | + |
| 193 | + sinon.assert.calledOnce(mockCounter.add) |
| 194 | + }) |
| 195 | + |
| 196 | + it('should handle null/undefined hookContext gracefully', () => { |
| 197 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 198 | + metrics.finally(undefined, evalDetails()) |
| 199 | + metrics.finally(null, evalDetails()) |
| 200 | + |
| 201 | + assert.strictEqual(mockCounter.add.callCount, 2) |
| 202 | + assert.strictEqual(mockCounter.add.firstCall.args[1]['feature_flag.key'], '') |
| 203 | + }) |
| 204 | + |
| 205 | + it('should handle null/undefined evaluationDetails gracefully', () => { |
| 206 | + const metrics = new EvalMetricsHook(makeConfig(true)) |
| 207 | + metrics.finally(hookContext(), undefined) |
| 208 | + metrics.finally(hookContext(), null) |
| 209 | + |
| 210 | + assert.strictEqual(mockCounter.add.callCount, 2) |
| 211 | + }) |
| 212 | + }) |
| 213 | +}) |
0 commit comments