Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,16 @@ declare namespace tracer {
* Programmatic configuration takes precedence over the environment variables listed above.
*/
initializationTimeoutMs?: number
/**
* Maximum number of feature flag tags to attach to a single trace.
* Limits bandwidth when many flags are evaluated in one trace. Capped at 1000.
* Can be configured via DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS environment variable.
*
* @default 300
* @env DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS
* Programmatic configuration takes precedence over the environment variables listed above.
*/
maxFlagTags?: number
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -326,4 +326,77 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => {
})
})
})

describe('Feature flag span tagging', () => {
let agent, proc

beforeEach(async () => {
agent = await new FakeAgent().start()
proc = await spawnProc(appFile, {
cwd,
env: {
DD_TRACE_AGENT_PORT: agent.port,
DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS: '0.1',
DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED: 'true',
},
})
})

afterEach(async () => {
await stopProc(proc)
await agent.stop()
})

it('should tag root span with feature flag metadata', (done) => {
const configId = 'org-42-env-test'

agent.on('remote-config-ack-update', async (id, _version, state) => {
if (state === UNACKNOWLEDGED) return
if (id !== configId) return

try {
assert.strictEqual(state, ACKNOWLEDGED)

const response = await fetch(`${proc.url}/evaluate-flags`)
assert.strictEqual(response.status, 200)
} catch (error) {
done(error)
}
})

agent.assertMessageReceived(({ payload }) => {
const trace = payload.flat()
const webSpan = trace.find(s => s.resource === 'GET /evaluate-flags')
if (!webSpan) throw new Error('Web span not found in this payload')

// Verify no child spans are created for feature flag evaluations
const ffSpans = trace.filter(s => s.name === 'dd-trace.feature_flag.evaluate')
assert.strictEqual(ffSpans.length, 0, 'Should not create child spans for flag evaluations')

// Verify root span has feature flag metadata tags
assert.ok(
webSpan.meta['feature_flags.test-boolean-flag.variant.key'],
'Root span should have boolean flag variant tag'
)
assert.strictEqual(
webSpan.meta['feature_flags.test-boolean-flag.subject.id'],
'test-user-123'
)
assert.ok(
webSpan.meta['feature_flags.test-string-flag.variant.key'],
'Root span should have string flag variant tag'
)
assert.strictEqual(
webSpan.meta['feature_flags.test-string-flag.subject.id'],
'test-user-456'
)
}, 30000, 10, true).then(done, done)

agent.addRemoteConfig({
product: RC_PRODUCT,
id: configId,
config: ufcPayloads.testBooleanAndStringFlags,
})
})
})
})
5 changes: 5 additions & 0 deletions packages/dd-trace/src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ class Config {
OTEL_TRACES_SAMPLER_ARG,
DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED,
DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS,
DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS,
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT,
OTEL_EXPORTER_OTLP_LOGS_HEADERS,
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL,
Expand Down Expand Up @@ -603,6 +604,10 @@ class Config {
target['experimental.flaggingProvider.initializationTimeoutMs'] =
maybeInt(DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS)
}
if (DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS != null) {
target['experimental.flaggingProvider.maxFlagTags'] =
Math.min(maybeInt(DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS), 1000)
}
setBoolean(target, 'traceEnabled', DD_TRACE_ENABLED)
setBoolean(target, 'experimental.aiguard.enabled', DD_AI_GUARD_ENABLED)
setString(target, 'experimental.aiguard.endpoint', DD_AI_GUARD_ENDPOINT)
Expand Down
10 changes: 10 additions & 0 deletions packages/dd-trace/src/config/supported-configurations.json
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,16 @@
"default": "false"
}
],
"DD_EXPERIMENTAL_FLAGGING_PROVIDER_MAX_FLAG_TAGS": [
{
"implementation": "A",
"type": "int",
"configurationNames": [
"experimental.flaggingProvider.maxFlagTags"
],
"default": "300"
}
],
"DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [
{
"implementation": "A",
Expand Down
6 changes: 6 additions & 0 deletions packages/dd-trace/src/openfeature/constants/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,10 @@ module.exports = {
* @type {string} Reason code for noop provider evaluations
*/
NOOP_REASON: 'STATIC',

/**
* @constant
* @type {string} Tag prefix for feature flag metadata on spans
*/
FF_TAG_PREFIX: 'feature_flags',
}
72 changes: 72 additions & 0 deletions packages/dd-trace/src/openfeature/flagging_provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { DatadogNodeServerProvider } = require('@datadog/openfeature-node-server'
const { channel } = require('dc-polyfill')
const log = require('../log')
const { EXPOSURE_CHANNEL } = require('./constants/constants')
const { tagSpansForEvaluation } = require('./span_tagger')

/**
* OpenFeature provider that integrates with Datadog's feature flagging system.
Expand All @@ -28,6 +29,77 @@ class FlaggingProvider extends DatadogNodeServerProvider {
config.experimental.flaggingProvider.initializationTimeoutMs)
}

/**
* Tags the active and root spans with feature flag evaluation metadata.
*
* @param {string} flagKey - The feature flag key
* @param {import('@openfeature/core').EvaluationContext} context - The evaluation context
* @param {import('@openfeature/core').ResolutionDetails<
* boolean | string | number | import('@openfeature/core').JsonValue
* >} result - The evaluation result
* @returns {import('@openfeature/core').ResolutionDetails<
* boolean | string | number | import('@openfeature/core').JsonValue
* >} The evaluation result (passthrough)
*/
_tagSpans (flagKey, context, result) {
tagSpansForEvaluation(this._tracer, {
flagKey,
variantKey: result.variant ?? String(result.value),
subjectId: context?.targetingKey,
maxFlagTags: this._config.experimental.flaggingProvider.maxFlagTags,
})

return result
}

/**
* @param {string} flagKey
* @param {boolean} defaultValue
* @param {import('@openfeature/core').EvaluationContext} context
* @param {import('@openfeature/core').Logger} logger
* @returns {Promise<import('@openfeature/core').ResolutionDetails<boolean>>}
*/
resolveBooleanEvaluation (flagKey, defaultValue, context, logger) {
return super.resolveBooleanEvaluation(flagKey, defaultValue, context, logger)
.then(result => this._tagSpans(flagKey, context, result))
}

/**
* @param {string} flagKey
* @param {string} defaultValue
* @param {import('@openfeature/core').EvaluationContext} context
* @param {import('@openfeature/core').Logger} logger
* @returns {Promise<import('@openfeature/core').ResolutionDetails<string>>}
*/
resolveStringEvaluation (flagKey, defaultValue, context, logger) {
return super.resolveStringEvaluation(flagKey, defaultValue, context, logger)
.then(result => this._tagSpans(flagKey, context, result))
}

/**
* @param {string} flagKey
* @param {number} defaultValue
* @param {import('@openfeature/core').EvaluationContext} context
* @param {import('@openfeature/core').Logger} logger
* @returns {Promise<import('@openfeature/core').ResolutionDetails<number>>}
*/
resolveNumberEvaluation (flagKey, defaultValue, context, logger) {
return super.resolveNumberEvaluation(flagKey, defaultValue, context, logger)
.then(result => this._tagSpans(flagKey, context, result))
}

/**
* @param {string} flagKey
* @param {import('@openfeature/core').JsonValue} defaultValue
* @param {import('@openfeature/core').EvaluationContext} context
* @param {import('@openfeature/core').Logger} logger
* @returns {Promise<import('@openfeature/core').ResolutionDetails<import('@openfeature/core').JsonValue>>}
*/
resolveObjectEvaluation (flagKey, defaultValue, context, logger) {
return super.resolveObjectEvaluation(flagKey, defaultValue, context, logger)
.then(result => this._tagSpans(flagKey, context, result))
}

/**
* Internal method to update flag configuration from Remote Config.
* This method is called automatically when Remote Config delivers UFC updates.
Expand Down
76 changes: 76 additions & 0 deletions packages/dd-trace/src/openfeature/span_tagger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use strict'

const { FF_TAG_PREFIX } = require('./constants/constants')

/**
* Builds feature flag tags for a given evaluation.
*
* @param {string} flagKey - The feature flag key
* @param {string} [variantKey] - The variant key from the evaluation result
* @param {string} [subjectId] - The subject/targeting key from the evaluation context
* @returns {{[key: string]: string}} Tag key-value pairs
*/
function buildFeatureFlagTags (flagKey, variantKey, subjectId) {
const tags = {}

if (variantKey !== undefined) {
tags[`${FF_TAG_PREFIX}.${flagKey}.variant.key`] = variantKey
}

if (subjectId !== undefined) {
tags[`${FF_TAG_PREFIX}.${flagKey}.subject.id`] = subjectId
}

return tags
}

/**
* Counts the number of unique feature flag keys in the trace-level tags.
*
* @param {import('../opentracing/span')} span
* @returns {number}
*/
function countFlagTags (span) {
const traceTags = span.context()._trace.tags
const prefix = `${FF_TAG_PREFIX}.`
let count = 0

for (const key in traceTags) {
if (key.startsWith(prefix) && key.endsWith('.variant.key')) {
count++
}
}

return count
}

/**
* Tags the active span and trace with feature flag evaluation metadata.
* Span-level tags go on the active span via addTags(). Trace-level tags go on
* _trace.tags, which the formatter automatically applies to the root/chunk span.
* Skips tagging if the number of flags already on the trace reaches maxFlagTags.
*
* @param {import('../tracer')} tracer - Datadog tracer instance
* @param {object} params
* @param {string} params.flagKey - The feature flag key
* @param {string} [params.variantKey] - The variant key from the evaluation result
* @param {string} [params.subjectId] - The subject/targeting key from the evaluation context
* @param {number} [params.maxFlagTags] - Maximum number of flag tags per trace
*/
function tagSpansForEvaluation (tracer, { flagKey, variantKey, subjectId, maxFlagTags }) {
const activeSpan = tracer.scope().active()
if (!activeSpan) return

if (maxFlagTags !== undefined && countFlagTags(activeSpan) >= maxFlagTags) return

const tags = buildFeatureFlagTags(flagKey, variantKey, subjectId)

activeSpan.addTags(tags)
Object.assign(activeSpan.context()._trace.tags, tags)
}

module.exports = {
countFlagTags,
buildFeatureFlagTags,
tagSpansForEvaluation,
}
Loading
Loading