Skip to content

Commit 03cf986

Browse files
Merge pull request #354 from DataDog/leo.romanovsky/rum-user-evaluation-context
feat(browser): include RUM user in evaluation context
2 parents b056459 + 53f40a6 commit 03cf986

10 files changed

Lines changed: 335 additions & 28 deletions

File tree

packages/browser/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,29 @@ console.log(result.value) // Flag value
9090
console.log(result.reason) // Evaluation reason
9191
```
9292

93+
### RUM User Context
94+
95+
When RUM integration is enabled (the default), the provider includes flat primitive properties returned by
96+
`DD_RUM.getUser()` in the OpenFeature evaluation context. The RUM user ID is used as the targeting key, while fields
97+
set explicitly through `OpenFeature.setContext()` take precedence.
98+
99+
Initialize the RUM user before registering the provider:
100+
101+
```javascript
102+
DD_RUM.setUser({
103+
id: 'user-123',
104+
email: 'user@example.com',
105+
company_name: 'Example, Inc.',
106+
})
107+
108+
await OpenFeature.setProviderAndWait(new DatadogProvider(configuration))
109+
```
110+
111+
If the RUM user changes after provider initialization, call
112+
`await OpenFeature.setContext(OpenFeature.getContext())` to reconcile the provider with the latest user while
113+
preserving explicitly configured OpenFeature properties. Nested RUM user properties are not included in the
114+
evaluation context.
115+
93116
## End-user license agreement
94117

95118
https://www.datadoghq.com/legal/eula

packages/browser/src/domain/configuration.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ export interface FlaggingInitConfiguration extends InitConfiguration {
4747
enableFlagEvaluationTracking?: boolean
4848

4949
/**
50-
* Whether to include feature flag assignment details in RUM events (default: true)
50+
* Whether to enable RUM integration (default: true). This includes feature flag assignment details in RUM events
51+
* and flat primitive RUM user properties in the OpenFeature evaluation context.
5152
* See: https://docs.datadoghq.com/real_user_monitoring/feature_flag_tracking/
5253
*/
5354
enableRumFeatureFlagTracking?: boolean

packages/browser/src/openfeature/exposures.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ import type { Context, RawError } from '@datadog/browser-core'
22
import { addTelemetryDebug, createPageMayExitObservable } from '@datadog/browser-core'
33
import { type AssignmentCache, createExposureEvent, type ExposureEventWithTimestamp } from '@datadog/flagging-core'
44
import { timeStampNow } from '@datadog/js-core/time'
5-
import type { EvaluationDetails, FlagValue, Hook, HookContext } from '@openfeature/web-sdk'
5+
import type { EvaluationContext, EvaluationDetails, FlagValue, Hook, HookContext } from '@openfeature/web-sdk'
66
import type { FlaggingConfiguration } from '../domain/configuration'
77
import { startExposuresBatch } from '../transport/startExposuresBatch'
88

99
/**
1010
* Create hook for exposure logging.
1111
*/
12-
export function createExposureLoggingHook(configuration: FlaggingConfiguration, exposureCache: AssignmentCache): Hook {
12+
export function createExposureLoggingHook(
13+
configuration: FlaggingConfiguration,
14+
exposureCache: AssignmentCache,
15+
getEvaluationContext: (context: EvaluationContext) => EvaluationContext = (context) => context
16+
): Hook {
1317
const pageMayExitObservable = createPageMayExitObservable(configuration)
1418
const exposuresBatch = startExposuresBatch(
1519
configuration,
@@ -22,7 +26,8 @@ export function createExposureLoggingHook(configuration: FlaggingConfiguration,
2226
return {
2327
after: (hookContext: HookContext, details: EvaluationDetails<FlagValue>) => {
2428
const timestamp = timeStampNow()
25-
const exposureEvent = createExposureEvent(hookContext.context, details)
29+
const evaluationContext = getEvaluationContext(hookContext.context)
30+
const exposureEvent = createExposureEvent(evaluationContext, details)
2631
if (!exposureEvent) {
2732
return
2833
}

packages/browser/src/openfeature/flagEvaluations.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ import {
99
Observable,
1010
} from '@datadog/browser-core'
1111
import { FlagEvaluationAggregator, type FlagEvaluationEvent } from '@datadog/flagging-core'
12-
import type { EvaluationDetails, FlagValue, Hook, HookContext } from '@openfeature/web-sdk'
12+
import type { EvaluationContext, EvaluationDetails, FlagValue, Hook, HookContext } from '@openfeature/web-sdk'
1313
import type { FlaggingConfiguration } from '../domain/configuration'
1414

15-
export function createFlagEvalEVPHook(configuration: FlaggingConfiguration): Hook {
15+
export function createFlagEvalEVPHook(
16+
configuration: FlaggingConfiguration,
17+
getEvaluationContext: (context: EvaluationContext) => EvaluationContext = (context) => context
18+
): Hook {
1619
const pageMayExitObservable = createPageMayExitObservable(configuration)
1720
const flagEvaluationBatch = createBatch({
1821
encoder: createIdentityEncoder(),
@@ -63,7 +66,7 @@ export function createFlagEvalEVPHook(configuration: FlaggingConfiguration): Hoo
6366
return {
6467
after: (hookContext: HookContext, details: EvaluationDetails<FlagValue>) => {
6568
try {
66-
aggregator.addEvaluation(hookContext.context, details)
69+
aggregator.addEvaluation(getEvaluationContext(hookContext.context), details)
6770
} catch (error) {
6871
addTelemetryDebug('Error adding evaluation to aggregator', {
6972
'error.message': error instanceof Error ? error.message : String(error),

packages/browser/src/openfeature/provider.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
import { evaluate } from '../evaluation'
2727
import { createExposureLoggingHook } from './exposures'
2828
import { createFlagEvalEVPHook } from './flagEvaluations'
29-
import { createRumTrackingHook } from './rumIntegration'
29+
import { createRumTrackingHook, enrichEvaluationContextWithRumUser } from './rumIntegration'
3030

3131
/**
3232
* @deprecated Use FlaggingInitConfiguration instead
@@ -64,6 +64,18 @@ export class DatadogProvider implements Provider {
6464
/** Provider-level configuration */
6565
private readonly configuration?: FlaggingConfiguration
6666

67+
/** Controls both directions of the provider's RUM integration. */
68+
private readonly isRumIntegrationEnabled: boolean
69+
70+
// TODO: Migrate this manual context plumbing to a provider `before` hook once
71+
// @openfeature/web-sdk supports returned EvaluationContext values for web hooks.
72+
// Watch upstream packages/web/src/hooks/hook.ts for the before return changing from `void`,
73+
// and packages/web/src/client/internal/open-feature-client.ts for `beforeHooks` merging that
74+
// result before calling the resolver and subsequent hooks. Return this stored context, not a
75+
// fresh RUM lookup, so targeting, flag configuration, and telemetry stay on the same identity.
76+
/** Effective context associated with the active flags configuration. */
77+
private evaluationContext: EvaluationContext = {}
78+
6779
status: ProviderStatus
6880

6981
private flagsConfiguration: FlagsConfiguration = {}
@@ -98,15 +110,15 @@ export class DatadogProvider implements Provider {
98110
this.hooks = []
99111
this.events = new OpenFeatureEventEmitter()
100112

101-
const isRumFeatureFlagTrackingEnabled = options.enableRumFeatureFlagTracking ?? true
102-
if (isRumFeatureFlagTrackingEnabled) {
113+
this.isRumIntegrationEnabled = options.enableRumFeatureFlagTracking ?? true
114+
if (this.isRumIntegrationEnabled) {
103115
this.hooks.push(createRumTrackingHook())
104116
}
105117

106118
// Add EVP flag evaluation hook.
107119
const isEvaluationTrackingEnabled = options.enableFlagEvaluationTracking ?? true
108120
if (isEvaluationTrackingEnabled && this.configuration) {
109-
this.hooks.push(createFlagEvalEVPHook(this.configuration))
121+
this.hooks.push(createFlagEvalEVPHook(this.configuration, () => this.evaluationContext))
110122
}
111123

112124
// Add proper exposure logging hook (creates batch internally)
@@ -116,7 +128,7 @@ export class DatadogProvider implements Provider {
116128
chromeStorage: chromeStorageIfAvailable(),
117129
storageKeySuffix: 'dd-of-browser',
118130
})
119-
this.hooks.push(createExposureLoggingHook(this.configuration, this.exposureCache))
131+
this.hooks.push(createExposureLoggingHook(this.configuration, this.exposureCache, () => this.evaluationContext))
120132
}
121133

122134
if (hasIndexedDB()) {
@@ -137,6 +149,8 @@ export class DatadogProvider implements Provider {
137149
}
138150

139151
private setContext(context: EvaluationContext): Promise<void> {
152+
const evaluationContext = this.isRumIntegrationEnabled ? enrichEvaluationContextWithRumUser(context) : context
153+
140154
if (this.status === ProviderStatus.NOT_READY) {
141155
// we're initializing, no status changes necessary
142156
} else {
@@ -155,7 +169,7 @@ export class DatadogProvider implements Provider {
155169
// Important: OF SDK awaits for all onContextChange calls to exit
156170
// before marking the provider as ready. Make sure to respect
157171
// `signal`, so we don't block OF SDK unnecessarily.
158-
this.latestContextUpdate = this.retrieveFlagsConfiguration(context, { signal })
172+
this.latestContextUpdate = this.retrieveFlagsConfiguration(evaluationContext, { signal })
159173
.then((result) =>
160174
// New configuration might require clearing exposure
161175
// cache. One example of this is updating experiment
@@ -184,6 +198,7 @@ export class DatadogProvider implements Provider {
184198
// scheduling).
185199

186200
this.flagsConfiguration = config
201+
this.evaluationContext = evaluationContext
187202
this.status = fromCache ? ProviderStatus.STALE : ProviderStatus.READY
188203
this.events.emit(ProviderEvents.ConfigurationChanged)
189204

@@ -268,34 +283,34 @@ export class DatadogProvider implements Provider {
268283
resolveBooleanEvaluation(
269284
flagKey: string,
270285
defaultValue: boolean,
271-
context: EvaluationContext,
286+
_context: EvaluationContext,
272287
_logger: Logger
273288
): ResolutionDetails<boolean> {
274-
return evaluate(this.flagsConfiguration, 'boolean', flagKey, defaultValue, context)
289+
return evaluate(this.flagsConfiguration, 'boolean', flagKey, defaultValue, this.evaluationContext)
275290
}
276291

277292
resolveStringEvaluation(
278293
flagKey: string,
279294
defaultValue: string,
280-
context: EvaluationContext,
295+
_context: EvaluationContext,
281296
_logger: Logger
282297
): ResolutionDetails<string> {
283-
return evaluate(this.flagsConfiguration, 'string', flagKey, defaultValue, context)
298+
return evaluate(this.flagsConfiguration, 'string', flagKey, defaultValue, this.evaluationContext)
284299
}
285300

286301
resolveNumberEvaluation(
287302
flagKey: string,
288303
defaultValue: number,
289-
context: EvaluationContext,
304+
_context: EvaluationContext,
290305
_logger: Logger
291306
): ResolutionDetails<number> {
292-
return evaluate(this.flagsConfiguration, 'number', flagKey, defaultValue, context)
307+
return evaluate(this.flagsConfiguration, 'number', flagKey, defaultValue, this.evaluationContext)
293308
}
294309

295310
resolveObjectEvaluation<T extends JsonValue>(
296311
flagKey: string,
297312
defaultValue: T,
298-
context: EvaluationContext,
313+
_context: EvaluationContext,
299314
_logger: Logger
300315
): ResolutionDetails<T> {
301316
// type safety: OpenFeature interface requires us to return a
@@ -304,6 +319,12 @@ export class DatadogProvider implements Provider {
304319
// type-sound way because there's no runtime information passed to
305320
// learn what type the user expects. So it's up to the user to
306321
// make sure they pass the appropriate type.
307-
return evaluate(this.flagsConfiguration, 'object', flagKey, defaultValue, context) as ResolutionDetails<T>
322+
return evaluate(
323+
this.flagsConfiguration,
324+
'object',
325+
flagKey,
326+
defaultValue,
327+
this.evaluationContext
328+
) as ResolutionDetails<T>
308329
}
309330
}

packages/browser/src/openfeature/rumIntegration.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,41 @@
1-
import { getGlobalObject } from '@datadog/browser-core'
2-
import type { EvaluationDetails, FlagValue, Hook, HookContext } from '@openfeature/web-sdk'
1+
import { type Context, getGlobalObject } from '@datadog/browser-core'
2+
import type { EvaluationContext, EvaluationDetails, FlagValue, Hook, HookContext } from '@openfeature/web-sdk'
33

44
export interface DDRum {
55
// biome-ignore lint/suspicious/noExplicitAny: DD RUM interface
66
addFeatureFlagEvaluation: (flagKey: string, value: any) => void
7+
getUser?: () => Context
8+
}
9+
10+
export function enrichEvaluationContextWithRumUser(context: EvaluationContext): EvaluationContext {
11+
try {
12+
const globalObject = getGlobalObject<{ DD_RUM?: DDRum }>()
13+
const user = globalObject.DD_RUM?.getUser?.()
14+
if (!user) {
15+
return context
16+
}
17+
18+
const { id, ...attributes } = user
19+
const rumUserContext: EvaluationContext = {}
20+
21+
if (typeof id === 'string') {
22+
rumUserContext.targetingKey = id
23+
}
24+
25+
for (const [key, value] of Object.entries(attributes)) {
26+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
27+
rumUserContext[key] = value
28+
}
29+
}
30+
31+
return {
32+
...rumUserContext,
33+
// RUM provides defaults; context explicitly supplied through OpenFeature remains authoritative.
34+
...context,
35+
}
36+
} catch {
37+
return context
38+
}
739
}
840

941
export function createRumTrackingHook(): Hook {

0 commit comments

Comments
 (0)