diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e9383c430d6..240c4b15794 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -402,3 +402,8 @@ updates: applies-to: security-updates patterns: - "*" + + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/openfeature.yml b/.github/workflows/openfeature.yml index 22ec5dd03bd..932eff5acab 100644 --- a/.github/workflows/openfeature.yml +++ b/.github/workflows/openfeature.yml @@ -23,6 +23,8 @@ jobs: id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true - uses: ./.github/actions/node with: version: ${{ matrix.version }} diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000000..e21307cfe1d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "packages/dd-trace/test/openfeature/ffe-system-test-data"] + path = packages/dd-trace/test/openfeature/ffe-system-test-data + url = https://github.com/DataDog/ffe-system-test-data.git diff --git a/packages/dd-trace/src/openfeature/ffe-evaluator.js b/packages/dd-trace/src/openfeature/ffe-evaluator.js new file mode 100644 index 00000000000..a43fab10e09 --- /dev/null +++ b/packages/dd-trace/src/openfeature/ffe-evaluator.js @@ -0,0 +1,278 @@ +'use strict' + +const SEMVER_OPERATORS = new Set([ + 'SEMVER_EQ', 'SEMVER_NEQ', 'SEMVER_LT', 'SEMVER_LTE', 'SEMVER_GT', 'SEMVER_GTE', +]) +const OPERATORS = new Set([ + 'LT', 'LTE', 'GT', 'GTE', 'MATCHES', 'NOT_MATCHES', 'ONE_OF', 'NOT_ONE_OF', 'IS_NULL', + ...SEMVER_OPERATORS, +]) +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ +const UINT64_MAX = '18446744073709551615' + +/** + * Validates flags independently and converts SemVer conditions into rules the + * upstream evaluator can execute with per-request synthetic attributes. + * + * @param {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1 | undefined} [configuration] + * @returns {{ + * configuration: import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1 | undefined, + * rejected: Set, + * sourceConfiguration: import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1 | undefined, + * semverConditions: Map> + * }} + */ +function sanitizeConfiguration (configuration) { + const rejected = new Set() + const semverConditions = new Map() + if (!configuration?.flags || typeof configuration.flags !== 'object' || Array.isArray(configuration.flags)) { + return { configuration, rejected, semverConditions, sourceConfiguration: configuration } + } + + const flags = { ...configuration.flags } + for (const [key, flag] of Object.entries(configuration.flags)) { + try { + const conditions = [] + flags[key] = prepareFlag(key, flag, conditions) + if (conditions.length) semverConditions.set(key, conditions) + } catch { + rejected.add(key) + } + } + + return { + configuration: rejected.size || semverConditions.size ? { ...configuration, flags } : configuration, + rejected, + semverConditions, + sourceConfiguration: configuration, + } +} + +/** + * Adds the precomputed attributes used by transformed SemVer rules. + * + * @param {Array<{ + * attribute: string, + * comparand: string, + * operator: string, + * syntheticAttribute: string + * }> | undefined} conditions + * @param {import('@openfeature/server-sdk').EvaluationContext} context + * @returns {import('@openfeature/server-sdk').EvaluationContext} + */ +function addSemverContext (conditions, context) { + if (!conditions) return context + + const semverContext = { ...context } + for (const condition of conditions) { + const matches = compareSemverOperator( + condition.operator, + context?.[condition.attribute], + condition.comparand + ) + semverContext[condition.syntheticAttribute] = matches ? 'true' : 'false' + } + return semverContext +} + +function prepareFlag (key, flag, semverConditions) { + if (!flag || flag.key !== key || typeof flag.enabled !== 'boolean' || + !['BOOLEAN', 'INTEGER', 'NUMERIC', 'STRING', 'JSON'].includes(flag.variationType)) { + throw new Error('invalid flag') + } + if (!flag.variations || typeof flag.variations !== 'object' || Array.isArray(flag.variations)) { + throw new Error('missing variations') + } + for (const [variationKey, variation] of Object.entries(flag.variations)) { + if (!variation || variation.key !== variationKey || !matchesType(variation.value, flag.variationType)) { + throw new Error('invalid variation') + } + } + if (!Array.isArray(flag.allocations)) throw new Error('invalid allocations') + + let transformed = false + const allocations = [] + for (const allocation of flag.allocations) { + if (!allocation || !Array.isArray(allocation.splits)) throw new Error('invalid allocation') + for (const split of allocation.splits) validateSplit(split, flag.variations) + + if (allocation.rules === undefined) { + allocations.push(allocation) + continue + } + if (!Array.isArray(allocation.rules)) throw new Error('invalid rules') + + let allocationTransformed = false + const rules = [] + for (const rule of allocation.rules) { + if (!rule || !Array.isArray(rule.conditions)) throw new Error('invalid rule') + + let ruleTransformed = false + const conditions = [] + for (const condition of rule.conditions) { + validateCondition(condition) + if (!SEMVER_OPERATORS.has(condition.operator)) { + conditions.push(condition) + continue + } + + const syntheticAttribute = `__datadog_semver_condition_${semverConditions.length}` + semverConditions.push({ + attribute: condition.attribute, + comparand: condition.value, + operator: condition.operator, + syntheticAttribute, + }) + conditions.push({ + ...condition, + attribute: syntheticAttribute, + operator: 'ONE_OF', + value: ['true'], + }) + ruleTransformed = true + } + rules.push(ruleTransformed ? { ...rule, conditions } : rule) + allocationTransformed ||= ruleTransformed + } + allocations.push(allocationTransformed ? { ...allocation, rules } : allocation) + transformed ||= allocationTransformed + } + + return transformed ? { ...flag, allocations } : flag +} + +function validateSplit (split, variations) { + if (!split || !Array.isArray(split.shards) || !Object.hasOwn(variations, split.variationKey)) { + throw new Error('invalid split') + } + for (const shard of split.shards) { + if (!shard || !Number.isSafeInteger(shard.totalShards) || shard.totalShards <= 0 || + shard.totalShards > 0xFF_FF_FF_FF || !Array.isArray(shard.ranges)) { + throw new Error('invalid shard') + } + for (const range of shard.ranges) { + if (!range || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || + range.start < 0 || range.start >= range.end || range.end > shard.totalShards) { + throw new Error('invalid shard range') + } + } + } +} + +function validateCondition (condition) { + if (!condition || typeof condition.attribute !== 'string' || !OPERATORS.has(condition.operator)) { + throw new Error('invalid condition') + } + switch (condition.operator) { + case 'MATCHES': + case 'NOT_MATCHES': + if (typeof condition.value !== 'string') throw new Error('invalid regex') + compileRegex(condition.value) + break + case 'LT': + case 'LTE': + case 'GT': + case 'GTE': + if (typeof condition.value !== 'number' || !Number.isFinite(condition.value)) throw new Error('invalid number') + break + case 'ONE_OF': + case 'NOT_ONE_OF': + if (!Array.isArray(condition.value) || condition.value.some(value => typeof value !== 'string')) { + throw new Error('invalid membership') + } + break + case 'IS_NULL': + if (typeof condition.value !== 'boolean') throw new Error('invalid null check') + break + default: + parseSemver(condition.value) + } +} + +function matchesType (value, type) { + if (type === 'BOOLEAN') return typeof value === 'boolean' + if (type === 'STRING') return typeof value === 'string' + if (type === 'INTEGER') return Number.isSafeInteger(value) + if (type === 'NUMERIC') return typeof value === 'number' && Number.isFinite(value) + return value !== undefined +} + +function compileRegex (pattern) { + const inlineFlags = pattern.match(/^\(\?([imsu]+)\)/) + const flags = inlineFlags ? [...new Set(inlineFlags[1])].join('') : '' + const source = (inlineFlags ? pattern.slice(inlineFlags[0].length) : pattern) + .replaceAll('[:alnum:]', 'A-Za-z0-9') + return new RegExp(source, flags) +} + +function parseSemver (value) { + if (typeof value !== 'string') throw new Error('invalid semantic version') + const match = SEMVER.exec(value) + if (!match) throw new Error('invalid semantic version') + + const core = match.slice(1, 4) + if (core.some(part => compareNumeric(part, UINT64_MAX) > 0)) throw new Error('invalid semantic version') + const prerelease = match[4]?.split('.') + if (prerelease?.some(part => /^\d+$/.test(part) && compareNumeric(part, UINT64_MAX) > 0)) { + throw new Error('invalid semantic version') + } + return { core, prerelease } +} + +function compareSemverOperator (operator, left, right) { + let comparison + try { + comparison = compareSemver(parseSemver(left), parseSemver(right)) + } catch { + return false + } + if (operator === 'SEMVER_EQ') return comparison === 0 + if (operator === 'SEMVER_NEQ') return comparison !== 0 + if (operator === 'SEMVER_LT') return comparison < 0 + if (operator === 'SEMVER_LTE') return comparison <= 0 + if (operator === 'SEMVER_GT') return comparison > 0 + return comparison >= 0 +} + +function compareSemver (left, right) { + for (let index = 0; index < 3; index++) { + const result = compareNumeric(left.core[index], right.core[index]) + if (result) return result + } + if (!left.prerelease && !right.prerelease) return 0 + if (!left.prerelease) return 1 + if (!right.prerelease) return -1 + for (let index = 0; index < Math.min(left.prerelease.length, right.prerelease.length); index++) { + const leftPart = left.prerelease[index] + const rightPart = right.prerelease[index] + const leftNumeric = /^\d+$/.test(leftPart) + const rightNumeric = /^\d+$/.test(rightPart) + let result + if (leftNumeric && rightNumeric) { + result = compareNumeric(leftPart, rightPart) + } else if (leftNumeric) { + result = -1 + } else if (rightNumeric) { + result = 1 + } else { + result = compareLexical(leftPart, rightPart) + } + if (result) return result + } + return Math.sign(left.prerelease.length - right.prerelease.length) +} + +function compareNumeric (left, right) { + return Math.sign(left.length - right.length) || compareLexical(left, right) +} + +function compareLexical (left, right) { + return left === right ? 0 : left < right ? -1 : 1 +} + +module.exports = { addSemverContext, sanitizeConfiguration } diff --git a/packages/dd-trace/src/openfeature/flagging_provider.js b/packages/dd-trace/src/openfeature/flagging_provider.js index 8d3d434c9eb..6fe977008c6 100644 --- a/packages/dd-trace/src/openfeature/flagging_provider.js +++ b/packages/dd-trace/src/openfeature/flagging_provider.js @@ -6,9 +6,14 @@ const configurationSource = require('./configuration_source') const { EXPOSURE_CHANNEL } = require('./constants/constants') const EvalMetricsHook = require('./eval-metrics-hook') const SpanEnrichmentHook = require('./span-enrichment-hook') +const { addSemverContext, sanitizeConfiguration } = require('./ffe-evaluator') const { DatadogNodeServerProvider } = require('./require-provider') +/** @type {import('@openfeature/server-sdk').ErrorCode} */ +// @ts-expect-error OpenFeature publishes ErrorCode as a string enum, but providers return its wire value. +const PARSE_ERROR = 'PARSE_ERROR' + /** * OpenFeature provider that integrates with Datadog's feature flagging system. * Extends DatadogNodeServerProvider to add tracer integration and configuration management. @@ -20,6 +25,9 @@ class FlaggingProvider extends DatadogNodeServerProvider { /** @type {{ start: Function, stop: Function } | undefined} */ #configurationSource + /** @type {ReturnType} */ + #ffeState = sanitizeConfiguration() + /** * @param {import('../tracer')} tracer - Datadog tracer instance * @param {import('../config/config-base')} config - Tracer configuration object @@ -48,6 +56,158 @@ class FlaggingProvider extends DatadogNodeServerProvider { this.#configurationSource?.start() } + /** + * Stores the current configuration and updates the base provider. + * + * @param {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1 | undefined} configuration + * @returns {void} + */ + setConfiguration (configuration) { + const state = sanitizeConfiguration(configuration) + // @ts-expect-error The upstream implementation accepts undefined to clear its current configuration. + super.setConfiguration(state.configuration) + this.#ffeState = state + } + + /** + * Returns the exact source configuration supplied to the provider. + * + * @returns {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1 | undefined} + */ + getConfiguration () { + return this.#ffeState.sourceConfiguration + } + + /** + * Resolves a boolean flag and normalizes its canonical result. + * + * @param {string} flagKey + * @param {boolean} defaultValue + * @param {import('@openfeature/server-sdk').EvaluationContext} context + * @param {import('@openfeature/server-sdk').Logger} logger + * @returns {Promise>} + */ + resolveBooleanEvaluation (flagKey, defaultValue, context, logger) { + const local = this.#rejectedFlagResolution(flagKey, defaultValue) + if (local) return Promise.resolve(local) + return super.resolveBooleanEvaluation(flagKey, defaultValue, this.#addSemverContext(flagKey, context), logger) + .then(result => this.#normalizeResolution(flagKey, result)) + } + + /** + * Resolves a string flag and normalizes its canonical result. + * + * @param {string} flagKey + * @param {string} defaultValue + * @param {import('@openfeature/server-sdk').EvaluationContext} context + * @param {import('@openfeature/server-sdk').Logger} logger + * @returns {Promise>} + */ + resolveStringEvaluation (flagKey, defaultValue, context, logger) { + const local = this.#rejectedFlagResolution(flagKey, defaultValue) + if (local) return Promise.resolve(local) + return super.resolveStringEvaluation(flagKey, defaultValue, this.#addSemverContext(flagKey, context), logger) + .then(result => this.#normalizeResolution(flagKey, result)) + } + + /** + * Resolves a number flag and normalizes its canonical result. + * + * @param {string} flagKey + * @param {number} defaultValue + * @param {import('@openfeature/server-sdk').EvaluationContext} context + * @param {import('@openfeature/server-sdk').Logger} logger + * @returns {Promise>} + */ + resolveNumberEvaluation (flagKey, defaultValue, context, logger) { + const local = this.#rejectedFlagResolution(flagKey, defaultValue) + if (local) return Promise.resolve(local) + return super.resolveNumberEvaluation(flagKey, defaultValue, this.#addSemverContext(flagKey, context), logger) + .then(result => this.#normalizeResolution(flagKey, result)) + } + + /** + * Resolves an object flag and normalizes its canonical result. + * + * @template {import('@openfeature/server-sdk').JsonValue} T + * @param {string} flagKey + * @param {T} defaultValue + * @param {import('@openfeature/server-sdk').EvaluationContext} context + * @param {import('@openfeature/server-sdk').Logger} logger + * @returns {Promise>} + */ + resolveObjectEvaluation (flagKey, defaultValue, context, logger) { + const local = this.#rejectedFlagResolution(flagKey, defaultValue) + if (local) return Promise.resolve(local) + return super.resolveObjectEvaluation(flagKey, defaultValue, this.#addSemverContext(flagKey, context), logger) + .then(result => this.#normalizeResolution(flagKey, result)) + } + + /** + * Converts provider results to the canonical FFE reason contract. + * + * @template {import('@openfeature/server-sdk').FlagValue} T + * @param {string} flagKey + * @param {import('@openfeature/server-sdk').ResolutionDetails} result + * @returns {import('@openfeature/server-sdk').ResolutionDetails} + */ + #normalizeResolution (flagKey, result) { + if (result?.reason !== 'TARGETING_MATCH' && result?.reason !== 'DEFAULT') { + return result + } + + const flag = this.#ffeState.configuration?.flags?.[flagKey] + const allocations = flag?.allocations + if (!flag || !Array.isArray(allocations)) { + return result + } + + const allocation = allocations.find(item => item.key === result.flagMetadata?.allocationKey) + if (!allocation || allocation.rules?.length || !Array.isArray(allocation.splits)) { + return result + } + + const selectedSplit = allocation.splits.find(split => { + const variant = flag.variations?.[split.variationKey] + return variant?.key === result.variant || split.variationKey === result.variant + }) + if (!selectedSplit) { + return result + } + + const hasTimeBounds = allocation.startAt !== undefined || allocation.endAt !== undefined + if (hasTimeBounds && allocation.splits.length === 1 && !selectedSplit.shards?.length) { + return { ...result, reason: 'DEFAULT' } + } + + const reason = selectedSplit?.shards?.length ? 'SPLIT' : 'STATIC' + return { ...result, reason } + } + + /** + * Returns the canonical parse error for a flag rejected during ingestion. + * + * @template {import('@openfeature/server-sdk').FlagValue} T + * @param {string} flagKey + * @param {T} defaultValue + * @returns {import('@openfeature/server-sdk').ResolutionDetails | false} + */ + #rejectedFlagResolution (flagKey, defaultValue) { + return this.#ffeState.rejected.has(flagKey) && + { value: defaultValue, reason: 'ERROR', errorCode: PARSE_ERROR } + } + + /** + * Adds synthetic attributes consumed by transformed SemVer rules. + * + * @param {string} flagKey + * @param {import('@openfeature/server-sdk').EvaluationContext} context + * @returns {import('@openfeature/server-sdk').EvaluationContext} + */ + #addSemverContext (flagKey, context) { + return addSemverContext(this.#ffeState.semverConditions.get(flagKey), context) + } + /** * Called when the provider is shut down. * Cleans up resources including channel subscriptions. diff --git a/packages/dd-trace/test/openfeature/ffe-system-test-data b/packages/dd-trace/test/openfeature/ffe-system-test-data new file mode 160000 index 00000000000..ea8b5cc5ce3 --- /dev/null +++ b/packages/dd-trace/test/openfeature/ffe-system-test-data @@ -0,0 +1 @@ +Subproject commit ea8b5cc5ce335109f11f3efbc5fd608f98a3ca54 diff --git a/packages/dd-trace/test/openfeature/flagging_provider.spec.js b/packages/dd-trace/test/openfeature/flagging_provider.spec.js index ba83736731a..c743658cc84 100644 --- a/packages/dd-trace/test/openfeature/flagging_provider.spec.js +++ b/packages/dd-trace/test/openfeature/flagging_provider.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const fs = require('node:fs') +const path = require('node:path') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -10,6 +11,9 @@ const proxyquire = require('proxyquire') require('../setup/core') describe('FlaggingProvider', () => { + const fixtureRoot = path.join(__dirname, 'ffe-system-test-data') + const fixtureCaseDir = path.join(fixtureRoot, 'evaluation-cases') + let FlaggingProvider let mockTracer let mockConfig @@ -286,4 +290,97 @@ describe('FlaggingProvider', () => { ) }) }) + + describe('canonical FFE fixtures', () => { + const fixtureCases = loadFixtureCases() + + for (const { fileName, index, testCase } of fixtureCases) { + it(`should evaluate ${fileName}[${index}]`, async () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + provider.setConfiguration(loadUfc()) + + const details = await evaluateDetails(provider, testCase) + + assert.deepStrictEqual(details.value, testCase.result.value) + assert.strictEqual(details.reason, testCase.result.reason) + if ('variant' in testCase.result) { + assert.strictEqual(details.variant, testCase.result.variant) + } + if ('errorCode' in testCase.result) { + assert.strictEqual(details.errorCode, testCase.result.errorCode) + } + }) + } + }) + + it('replaces rejected flag state when the configuration changes', async () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + const logger = { error () {}, warn () {}, info () {}, debug () {} } + const context = { targetingKey: 'user-1' } + + provider.setConfiguration(refreshFlagConfiguration('invalid-allocations')) + const invalidDetails = await provider.resolveStringEvaluation('refresh-flag', 'fallback', context, logger) + + assert.strictEqual(invalidDetails.value, 'fallback') + assert.strictEqual(invalidDetails.reason, 'ERROR') + assert.strictEqual(invalidDetails.errorCode, 'PARSE_ERROR') + + provider.setConfiguration(refreshFlagConfiguration([{ + key: 'static', + splits: [{ variationKey: 'enabled', shards: [] }], + }])) + const validDetails = await provider.resolveStringEvaluation('refresh-flag', 'fallback', context, logger) + + assert.strictEqual(validDetails.value, 'enabled') + assert.strictEqual(validDetails.reason, 'STATIC') + assert.strictEqual(validDetails.errorCode, undefined) + }) + + function loadUfc () { + return JSON.parse(fs.readFileSync(path.join(fixtureRoot, 'ufc-config.json'), 'utf8')) + } + + function loadFixtureCases () { + const fixtureFiles = fs.readdirSync(fixtureCaseDir).filter(file => file.endsWith('.json')).sort() + assert.ok(fixtureFiles.length > 0, 'FFE fixture submodule is missing or empty') + return fixtureFiles.flatMap(fileName => { + const testCases = JSON.parse(fs.readFileSync(path.join(fixtureCaseDir, fileName), 'utf8')) + return testCases.map((testCase, index) => ({ fileName, index, testCase })) + }) + } + + function refreshFlagConfiguration (allocations) { + return { + flags: { + 'refresh-flag': { + key: 'refresh-flag', + enabled: true, + variationType: 'STRING', + variations: { + enabled: { key: 'enabled', value: 'enabled' }, + }, + allocations, + }, + }, + } + } + + async function evaluateDetails (provider, testCase) { + const context = { targetingKey: testCase.targetingKey, ...testCase.attributes } + const logger = { error () {}, warn () {}, info () {}, debug () {} } + + if (testCase.variationType === 'BOOLEAN') { + return provider.resolveBooleanEvaluation(testCase.flag, testCase.defaultValue, context, logger) + } + if (testCase.variationType === 'STRING') { + return provider.resolveStringEvaluation(testCase.flag, testCase.defaultValue, context, logger) + } + if (testCase.variationType === 'INTEGER' || testCase.variationType === 'NUMERIC') { + return provider.resolveNumberEvaluation(testCase.flag, testCase.defaultValue, context, logger) + } + if (testCase.variationType === 'JSON') { + return provider.resolveObjectEvaluation(testCase.flag, testCase.defaultValue, context, logger) + } + throw new Error(`Unsupported variation type: ${testCase.variationType}`) + } })