Skip to content

Commit bb7dcdf

Browse files
🐛 collect feature flag evaluations before rum start in a map instead of a buffer
Previously, `addFeatureFlagEvaluation` calls made before `startRum` were buffered and replayed after init. This caused the `BoundedBuffer` limit to be reached when many flags were evaluated early, silently dropping other buffered calls (e.g. `setUser`). Instead, pre-start evaluations are now collected into a `FeatureFlagCollection` (a `Map<string, ContextValue>`) and passed to `startFeatureFlagContexts`, which seeds the first view's context with it. Subsequent views start with an empty collection as before. This trades per-evaluation ordering for reliability.
1 parent d485df2 commit bb7dcdf

8 files changed

Lines changed: 119 additions & 31 deletions

File tree

packages/rum-core/src/boot/preStartRum.spec.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -681,17 +681,13 @@ describe('preStartRum', () => {
681681
})
682682

683683
it('addFeatureFlagEvaluation', async () => {
684-
const addFeatureFlagEvaluationSpy = jasmine.createSpy()
685-
doStartRumSpy.and.returnValue({
686-
addFeatureFlagEvaluation: addFeatureFlagEvaluationSpy,
687-
} as unknown as StartRumResult)
688-
689684
const key = 'foo'
690685
const value = 'bar'
691686
strategy.addFeatureFlagEvaluation(key, value)
692687
strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API)
693-
await collectAsyncCalls(addFeatureFlagEvaluationSpy, 1)
694-
expect(addFeatureFlagEvaluationSpy).toHaveBeenCalledOnceWith(key, value)
688+
await collectAsyncCalls(doStartRumSpy, 1)
689+
const initialFeatureFlagCollection: Map<string, unknown> = doStartRumSpy.calls.argsFor(0)[6]
690+
expect(initialFeatureFlagCollection.get(key)).toEqual(value)
695691
})
696692

697693
it('startDurationVital', async () => {

packages/rum-core/src/boot/preStartRum.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ import type { ViewOptions } from '../domain/view/trackViews'
5050
import type { FeatureOperationOptions, FailureReason } from '../domain/vital/vitalCollection'
5151
import { callPluginsMethod } from '../domain/plugins'
5252
import { startTrackingConsentContext } from '../domain/contexts/trackingConsentContext'
53+
import { createFeatureFlagCollection } from '../domain/contexts/featureFlagContext'
54+
import type { FeatureFlagCollection } from '../domain/contexts/featureFlagContext'
5355
import type { StartRumResult } from './startRum'
5456
import type { RumPublicApiOptions, Strategy } from './rumPublicApi'
5557

@@ -59,7 +61,8 @@ export type DoStartRum = (
5961
deflateWorker: DeflateWorker | undefined,
6062
initialViewOptions: ViewOptions | undefined,
6163
telemetry: Telemetry,
62-
hooks: Hooks
64+
hooks: Hooks,
65+
initialFeatureFlagCollection: FeatureFlagCollection
6366
) => StartRumResult
6467

6568
export function createPreStartStrategy(
@@ -68,6 +71,7 @@ export function createPreStartStrategy(
6871
doStartRum: DoStartRum
6972
): Strategy {
7073
const bufferApiCalls = createBoundedBuffer<StartRumResult>()
74+
const initialFeatureFlagCollection = createFeatureFlagCollection()
7175

7276
// TODO next major: remove the globalContextManager, userContextManager and accountContextManager from preStartStrategy and use an empty context instead
7377
const globalContext = buildGlobalContextManager()
@@ -123,7 +127,8 @@ export function createPreStartStrategy(
123127
deflateWorker,
124128
initialViewOptions,
125129
telemetry,
126-
hooks
130+
hooks,
131+
initialFeatureFlagCollection
127132
)
128133

129134
bufferApiCalls.drain(startRumResult)
@@ -324,8 +329,13 @@ export function createPreStartStrategy(
324329
bufferApiCalls.add((startRumResult) => startRumResult.addError(providedError))
325330
},
326331

332+
// Intentionally not using bufferApiCalls here: feature flag evaluations can be called very
333+
// frequently before RUM starts (e.g. by flag evaluation frameworks on page load), which would
334+
// exhaust the BoundedBuffer and silently drop other buffered calls such as setUser.
335+
// Trade-off: we lose per-call granularity (only the last value per key is kept), but we avoid
336+
// storing an unbounded call history in memory.
327337
addFeatureFlagEvaluation(key, value) {
328-
bufferApiCalls.add((startRumResult) => startRumResult.addFeatureFlagEvaluation(key, value))
338+
initialFeatureFlagCollection.set(key, value)
329339
},
330340

331341
startDurationVital(name, options) {

packages/rum-core/src/boot/rumPublicApi.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -639,10 +639,10 @@ describe('rum public api', () => {
639639
it('should add feature flag evaluation when ff feature_flags enabled', async () => {
640640
rumPublicApi.init(DEFAULT_INIT_CONFIGURATION)
641641
rumPublicApi.addFeatureFlagEvaluation('feature', 'foo')
642-
await collectAsyncCalls(startRumSpy, 1)
642+
const calls = await collectAsyncCalls(startRumSpy, 1)
643643

644-
expect(addFeatureFlagEvaluationSpy).toHaveBeenCalledTimes(1)
645-
expect(addFeatureFlagEvaluationSpy.calls.argsFor(0)).toEqual(['feature', 'foo'])
644+
const initialFeatureFlagCollection: Map<string, unknown> = calls.argsFor(0)[5]
645+
expect(initialFeatureFlagCollection.get('feature')).toBe('foo')
646646
expect(displaySpy).not.toHaveBeenCalled()
647647
})
648648
})
@@ -1217,7 +1217,7 @@ describe('rum public api', () => {
12171217

12181218
rumPublicApi.init(DEFAULT_INIT_CONFIGURATION)
12191219
const calls = await collectAsyncCalls(startRumSpy, 1)
1220-
const sdkName = calls.argsFor(0)[9]
1220+
const sdkName = calls.argsFor(0)[10]
12211221
expect(sdkName).toBe('rum-slim')
12221222
})
12231223
})

packages/rum-core/src/boot/rumPublicApi.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,15 @@ export function makeRumPublicApi(
609609
let strategy = createPreStartStrategy(
610610
options,
611611
trackingConsentState,
612-
(configuration, sessionManager, deflateWorker, initialViewOptions, telemetry, hooks) => {
612+
(
613+
configuration,
614+
sessionManager,
615+
deflateWorker,
616+
initialViewOptions,
617+
telemetry,
618+
hooks,
619+
initialFeatureFlagCollection
620+
) => {
613621
const createEncoder =
614622
deflateWorker && options.createDeflateEncoder
615623
? (streamId: DeflateEncoderStreamId) => options.createDeflateEncoder!(configuration, deflateWorker, streamId)
@@ -621,6 +629,7 @@ export function makeRumPublicApi(
621629
recorderApi,
622630
profilerApi,
623631
initialViewOptions,
632+
initialFeatureFlagCollection,
624633
createEncoder,
625634
bufferedDataObservable,
626635
telemetry,

packages/rum-core/src/boot/startRum.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ function startRumStub(
5151
sessionManager,
5252
noopRecorderApi,
5353
undefined,
54+
new Map(),
5455
new Observable(),
5556
undefined,
5657
reportError
@@ -161,6 +162,7 @@ describe('view events', () => {
161162
noopRecorderApi,
162163
noopProfilerApi,
163164
undefined,
165+
new Map(),
164166
createIdentityEncoder,
165167
new BufferedObservable<BufferedData>(100),
166168
createFakeTelemetryObject(),

packages/rum-core/src/boot/startRum.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { startUrlContexts } from '../domain/contexts/urlContexts'
3333
import { createLocationChangeObservable } from '../browser/locationChangeObservable'
3434
import type { RumConfiguration } from '../domain/configuration'
3535
import type { ViewOptions } from '../domain/view/trackViews'
36+
import type { FeatureFlagCollection } from '../domain/contexts/featureFlagContext'
3637
import { startFeatureFlagContexts } from '../domain/contexts/featureFlagContext'
3738
import { startCustomerDataTelemetry } from '../domain/startCustomerDataTelemetry'
3839
import { startPageStateHistory } from '../domain/contexts/pageStateHistory'
@@ -61,6 +62,7 @@ export function startRum(
6162
recorderApi: RecorderApi,
6263
profilerApi: ProfilerApi,
6364
initialViewOptions: ViewOptions | undefined,
65+
initialFeatureFlagCollection: FeatureFlagCollection,
6466
createEncoder: (streamId: DeflateEncoderStreamId) => Encoder,
6567
bufferedDataObservable: BufferedObservable<BufferedData>,
6668
telemetry: Telemetry,
@@ -116,6 +118,7 @@ export function startRum(
116118
sessionManager,
117119
recorderApi,
118120
initialViewOptions,
121+
initialFeatureFlagCollection,
119122
bufferedDataObservable,
120123
sdkName,
121124
reportError
@@ -146,6 +149,7 @@ export function startRumEventCollection(
146149
sessionManager: SessionManager,
147150
recorderApi: RecorderApi,
148151
initialViewOptions: ViewOptions | undefined,
152+
initialFeatureFlagCollection: FeatureFlagCollection,
149153
bufferedDataObservable: Observable<BufferedData>,
150154
sdkName: SdkName | undefined,
151155
reportError: (error: RawError) => void
@@ -164,7 +168,7 @@ export function startRumEventCollection(
164168
cleanupTasks.push(() => viewHistory.stop())
165169
const urlContexts = startUrlContexts(lifeCycle, hooks, locationChangeObservable)
166170
cleanupTasks.push(() => urlContexts.stop())
167-
const featureFlagContexts = startFeatureFlagContexts(lifeCycle, hooks, configuration)
171+
const featureFlagContexts = startFeatureFlagContexts(lifeCycle, hooks, configuration, initialFeatureFlagCollection)
168172
startSessionContext(hooks, configuration, sessionManager, recorderApi, viewHistory)
169173
startConnectivityContext(hooks)
170174
const globalContext = startGlobalContext(hooks, configuration, 'rum', true)

packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { RumEventType } from '../../rawRumEvent.types'
99
import type { AssembleHookParams, Hooks } from '../hooks'
1010
import { createHooks } from '../hooks'
1111
import type { FeatureFlagContexts } from './featureFlagContext'
12-
import { startFeatureFlagContexts } from './featureFlagContext'
12+
import { createFeatureFlagCollection, startFeatureFlagContexts } from './featureFlagContext'
1313

1414
describe('featureFlagContexts', () => {
1515
const lifeCycle = new LifeCycle()
@@ -22,9 +22,14 @@ describe('featureFlagContexts', () => {
2222
clock = mockClock()
2323
hooks = createHooks()
2424
trackFeatureFlagsForEvents = []
25-
featureFlagContexts = startFeatureFlagContexts(lifeCycle, hooks, {
26-
trackFeatureFlagsForEvents,
27-
} as unknown as RumConfiguration)
25+
featureFlagContexts = startFeatureFlagContexts(
26+
lifeCycle,
27+
hooks,
28+
{
29+
trackFeatureFlagsForEvents,
30+
} as unknown as RumConfiguration,
31+
new Map()
32+
)
2833
})
2934

3035
describe('assemble hook', () => {
@@ -154,6 +159,60 @@ describe('featureFlagContexts', () => {
154159
expect(defaultRumEventAttributes).toBeUndefined()
155160
})
156161

162+
it('should initialize the first view with the initial collection', () => {
163+
const initialCollection = createFeatureFlagCollection()
164+
initialCollection.set('feature', 'initial')
165+
featureFlagContexts = startFeatureFlagContexts(
166+
lifeCycle,
167+
hooks,
168+
{
169+
trackFeatureFlagsForEvents,
170+
} as unknown as RumConfiguration,
171+
initialCollection
172+
)
173+
174+
lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, {
175+
startClocks: relativeToClocks(0 as RelativeTime),
176+
} as ViewCreatedEvent)
177+
178+
expect(
179+
hooks.triggerHook(HookNames.Assemble, {
180+
eventType: 'view',
181+
startTime: 0 as RelativeTime,
182+
} as AssembleHookParams)
183+
).toEqual({ type: 'view', feature_flags: { feature: 'initial' } })
184+
})
185+
186+
it('should not carry over the initial collection to subsequent views', () => {
187+
const initialCollection = createFeatureFlagCollection()
188+
initialCollection.set('feature', 'initial')
189+
featureFlagContexts = startFeatureFlagContexts(
190+
lifeCycle,
191+
hooks,
192+
{
193+
trackFeatureFlagsForEvents,
194+
} as unknown as RumConfiguration,
195+
initialCollection
196+
)
197+
198+
lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, {
199+
startClocks: relativeToClocks(0 as RelativeTime),
200+
} as ViewCreatedEvent)
201+
lifeCycle.notify(LifeCycleEventType.AFTER_VIEW_ENDED, {
202+
endClocks: relativeToClocks(10 as RelativeTime),
203+
} as ViewEndedEvent)
204+
lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, {
205+
startClocks: relativeToClocks(10 as RelativeTime),
206+
} as ViewCreatedEvent)
207+
208+
expect(
209+
hooks.triggerHook(HookNames.Assemble, {
210+
eventType: 'view',
211+
startTime: 10 as RelativeTime,
212+
} as AssembleHookParams)
213+
).toBeUndefined()
214+
})
215+
157216
it('should replace existing feature flag evaluations for the current view', () => {
158217
lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, {
159218
startClocks: relativeToClocks(0 as RelativeTime),

packages/rum-core/src/domain/contexts/featureFlagContext.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { ContextValue, Context } from '@datadog/browser-core'
2-
import { HookNames, SESSION_TIME_OUT_DELAY, SKIPPED, createValueHistory, isEmptyObject } from '@datadog/browser-core'
1+
import type { ContextValue } from '@datadog/browser-core'
2+
import { HookNames, SESSION_TIME_OUT_DELAY, SKIPPED, createValueHistory } from '@datadog/browser-core'
33
import type { LifeCycle } from '../lifeCycle'
44
import { LifeCycleEventType } from '../lifeCycle'
55
import { RumEventType } from '../../rawRumEvent.types'
@@ -9,12 +9,16 @@ import type { DefaultRumEventAttributes, Hooks } from '../hooks'
99
export const FEATURE_FLAG_CONTEXT_TIME_OUT_DELAY = SESSION_TIME_OUT_DELAY
1010
export const BYTES_COMPUTATION_THROTTLING_DELAY = 200
1111

12-
export type FeatureFlagContext = Context
12+
export type FeatureFlagCollection = Map<string, ContextValue>
1313

1414
export interface FeatureFlagContexts {
1515
addFeatureFlagEvaluation: (key: string, value: ContextValue) => void
1616
}
1717

18+
export function createFeatureFlagCollection(): FeatureFlagCollection {
19+
return new Map()
20+
}
21+
1822
/**
1923
* Start feature flag contexts
2024
*
@@ -26,14 +30,18 @@ export interface FeatureFlagContexts {
2630
export function startFeatureFlagContexts(
2731
lifeCycle: LifeCycle,
2832
hooks: Hooks,
29-
configuration: RumConfiguration
33+
configuration: RumConfiguration,
34+
initialCollection: FeatureFlagCollection
3035
): FeatureFlagContexts {
31-
const featureFlagContexts = createValueHistory<FeatureFlagContext>({
36+
const featureFlagContexts = createValueHistory<FeatureFlagCollection>({
3237
expireDelay: FEATURE_FLAG_CONTEXT_TIME_OUT_DELAY,
3338
})
3439

40+
let isFirstView = true
3541
lifeCycle.subscribe(LifeCycleEventType.BEFORE_VIEW_CREATED, ({ startClocks }) => {
36-
featureFlagContexts.add({}, startClocks.relative)
42+
const collection = isFirstView ? initialCollection : createFeatureFlagCollection()
43+
isFirstView = false
44+
featureFlagContexts.add(collection, startClocks.relative)
3745
})
3846

3947
lifeCycle.subscribe(LifeCycleEventType.AFTER_VIEW_ENDED, ({ endClocks }) => {
@@ -50,21 +58,21 @@ export function startFeatureFlagContexts(
5058
}
5159

5260
const featureFlagContext = featureFlagContexts.find(startTime)
53-
if (!featureFlagContext || isEmptyObject(featureFlagContext)) {
61+
if (!featureFlagContext || featureFlagContext.size === 0) {
5462
return SKIPPED
5563
}
5664

5765
return {
5866
type: eventType,
59-
feature_flags: featureFlagContext,
67+
feature_flags: Object.fromEntries(featureFlagContext),
6068
}
6169
})
6270

6371
return {
6472
addFeatureFlagEvaluation: (key: string, value: ContextValue) => {
65-
const currentContext = featureFlagContexts.find()
66-
if (currentContext) {
67-
currentContext[key] = value
73+
const currentCollection = featureFlagContexts.find()
74+
if (currentCollection) {
75+
currentCollection.set(key, value)
6876
}
6977
},
7078
}

0 commit comments

Comments
 (0)