Skip to content

Commit c1b5954

Browse files
authored
✨ implement remote configuration async loading & caching (#4606)
1 parent 314dec9 commit c1b5954

11 files changed

Lines changed: 1097 additions & 239 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ generated-docs/
1717
.env*
1818
!.env.example
1919
.rum-ai-toolkit/
20+
.idea/
2021

2122
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
2223
.pnp.*

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

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
mockEventBridge,
2525
mockSyntheticsWorkerValues,
2626
createFakeTelemetryObject,
27+
registerCleanupTask,
2728
replaceMockable,
2829
replaceMockableWithSpy,
2930
createStartSessionManagerMock,
@@ -421,7 +422,7 @@ describe('preStartRum', () => {
421422
})
422423
})
423424

424-
describe('remote configuration', () => {
425+
describe('remote configuration sync loading', () => {
425426
let interceptor: ReturnType<typeof interceptRequests>
426427

427428
beforeEach(() => {
@@ -446,6 +447,77 @@ describe('preStartRum', () => {
446447
await collectAsyncCalls(doStartRumSpy, 1)
447448
expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50)
448449
})
450+
451+
it('should start with the remote configuration when remoteConfiguration.sync is true', async () => {
452+
interceptor.withFetch(() =>
453+
Promise.resolve({
454+
ok: true,
455+
json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }),
456+
})
457+
)
458+
const { strategy, doStartRumSpy } = createPreStartStrategyWithDefaults()
459+
strategy.init(
460+
{
461+
...DEFAULT_INIT_CONFIGURATION,
462+
remoteConfiguration: { id: '123', sync: true },
463+
},
464+
PUBLIC_API
465+
)
466+
await collectAsyncCalls(doStartRumSpy, 1)
467+
expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50)
468+
})
469+
})
470+
471+
describe('remote configuration async loading', () => {
472+
const REMOTE_CONFIGURATION_ID = '123'
473+
let interceptor: ReturnType<typeof interceptRequests>
474+
475+
beforeEach(() => {
476+
localStorage.clear()
477+
478+
interceptor = interceptRequests()
479+
interceptor.withFetch(() =>
480+
Promise.resolve({
481+
ok: true,
482+
json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }),
483+
})
484+
)
485+
486+
registerCleanupTask(() => {
487+
localStorage.clear()
488+
})
489+
})
490+
491+
it('should start synchronously with init configuration on cache miss', async () => {
492+
const { strategy, doStartRumSpy } = createPreStartStrategyWithDefaults()
493+
494+
strategy.init(
495+
{
496+
...DEFAULT_INIT_CONFIGURATION,
497+
remoteConfiguration: { id: REMOTE_CONFIGURATION_ID },
498+
sessionSampleRate: 25,
499+
},
500+
PUBLIC_API
501+
)
502+
503+
await collectAsyncCalls(doStartRumSpy, 1)
504+
expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toBe(25)
505+
})
506+
507+
it('should trigger a background fetch to the remote configuration endpoint', async () => {
508+
const { strategy } = createPreStartStrategyWithDefaults()
509+
510+
strategy.init(
511+
{
512+
...DEFAULT_INIT_CONFIGURATION,
513+
remoteConfiguration: { id: REMOTE_CONFIGURATION_ID },
514+
},
515+
PUBLIC_API
516+
)
517+
518+
await interceptor.waitForAllFetchCalls()
519+
expect(interceptor.requests.some((r) => r.url.includes(REMOTE_CONFIGURATION_ID))).toBeTrue()
520+
})
449521
})
450522

451523
describe('plugins', () => {
@@ -515,8 +587,14 @@ describe('preStartRum', () => {
515587
let interceptor: ReturnType<typeof interceptRequests>
516588

517589
beforeEach(() => {
590+
localStorage.clear()
591+
518592
interceptor = interceptRequests()
519593
initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, service: 'my-service', version: '1.4.2', env: 'dev' }
594+
595+
registerCleanupTask(() => {
596+
localStorage.clear()
597+
})
520598
})
521599

522600
it('is undefined before init', () => {
@@ -544,7 +622,7 @@ describe('preStartRum', () => {
544622
expect(strategy.initConfiguration).toEqual(initConfiguration)
545623
})
546624

547-
it('returns the initConfiguration with the remote configuration when a remoteConfigurationId is provided', (done) => {
625+
it('returns the initConfiguration with the remote configuration when a remoteConfigurationId is provided (sync loading)', (done) => {
548626
interceptor.withFetch(() =>
549627
Promise.resolve({
550628
ok: true,
@@ -565,6 +643,24 @@ describe('preStartRum', () => {
565643
PUBLIC_API
566644
)
567645
})
646+
647+
it('exposes the user configuration when remoteConfiguration.id is provided (async loading, cache miss)', () => {
648+
interceptor.withFetch(() =>
649+
Promise.resolve({
650+
ok: true,
651+
json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }),
652+
})
653+
)
654+
655+
const { strategy } = createPreStartStrategyWithDefaults()
656+
const userInitConfiguration: RumInitConfiguration = {
657+
...DEFAULT_INIT_CONFIGURATION,
658+
remoteConfiguration: { id: '123' },
659+
}
660+
strategy.init(userInitConfiguration, PUBLIC_API)
661+
662+
expect(strategy.initConfiguration).toEqual(userInitConfiguration)
663+
})
568664
})
569665

570666
describe('buffers API calls before starting RUM', () => {

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

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@ import {
3737
import type { Hooks } from '../domain/hooks'
3838
import { createHooks } from '../domain/hooks'
3939
import type { RumConfiguration, RumInitConfiguration } from '../domain/configuration'
40+
4041
import {
41-
validateAndBuildRumConfiguration,
4242
fetchAndApplyRemoteConfiguration,
43+
getRemoteConfiguration,
44+
getRemoteConfigurationId,
45+
validateAndBuildRumConfiguration,
4346
serializeRumConfiguration,
4447
} from '../domain/configuration'
4548
import type { ViewOptions } from '../domain/view/trackViews'
@@ -243,14 +246,23 @@ export function createPreStartStrategy(
243246

244247
callPluginsMethod(initConfiguration.plugins, 'onInit', { initConfiguration, publicApi })
245248

246-
if (initConfiguration.remoteConfigurationId) {
247-
fetchAndApplyRemoteConfiguration(initConfiguration, { user: userContext, context: globalContext })
248-
.then((initConfiguration) => {
249-
if (initConfiguration) {
250-
doInit(initConfiguration, errorStack)
251-
}
252-
})
253-
.catch(monitorError)
249+
const hasRemoteConfiguration = getRemoteConfigurationId(initConfiguration)
250+
251+
if (hasRemoteConfiguration) {
252+
const supportedContextManagers = { user: userContext, context: globalContext }
253+
const isSyncLoading = !!initConfiguration.remoteConfigurationId || !!initConfiguration.remoteConfiguration?.sync
254+
255+
if (isSyncLoading) {
256+
fetchAndApplyRemoteConfiguration(initConfiguration, supportedContextManagers)
257+
.then((resolvedInitConfiguration) => {
258+
if (resolvedInitConfiguration) {
259+
doInit(resolvedInitConfiguration, errorStack)
260+
}
261+
})
262+
.catch(monitorError)
263+
} else {
264+
doInit(getRemoteConfiguration(initConfiguration, supportedContextManagers), errorStack)
265+
}
254266
} else {
255267
doInit(initConfiguration, errorStack)
256268
}

packages/rum-core/src/domain/configuration/configuration.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,7 @@ describe('serializeRumConfiguration', () => {
825825
trackResources: true,
826826
trackLongTasks: true,
827827
remoteConfigurationId: '123',
828+
remoteConfiguration: { id: '123', sync: false },
828829
remoteConfigurationProxy: 'config',
829830
plugins: [{ name: 'foo', getConfigurationTelemetry: () => ({ bar: true }) }],
830831
trackFeatureFlagsForEvents: ['vital'],
@@ -845,7 +846,8 @@ describe('serializeRumConfiguration', () => {
845846
: Key extends 'trackLongTasks'
846847
? 'track_long_task' // We forgot the s, keeping this for backward compatibility
847848
: // The following options are not reported as telemetry. Please avoid adding more of them.
848-
Key extends 'applicationId' | 'subdomain'
849+
// `remoteConfiguration` is covered by the legacy `remote_configuration_id` field.
850+
Key extends 'applicationId' | 'subdomain' | 'remoteConfiguration'
849851
? never
850852
: CamelToSnakeCase<Key>
851853
// By specifying the type here, we can ensure that serializeConfiguration is returning an

packages/rum-core/src/domain/configuration/configuration.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { RumEventDomainContext } from '../../domainContext.types'
1616
import type { RumEvent } from '../../rumEvent.types'
1717
import type { RumPlugin } from '../plugins'
1818
import type { PropagatorType, TracingOption } from '../tracing/tracer.types'
19+
import { getRemoteConfigurationId } from './remoteConfiguration'
1920

2021
export const DEFAULT_PROPAGATOR_TYPES: PropagatorType[] = ['tracecontext', 'datadog']
2122

@@ -138,12 +139,22 @@ export interface RumInitConfiguration extends InitConfiguration {
138139
compressIntakeRequests?: boolean | undefined
139140

140141
/**
141-
* [Internal option] Id of the remote configuration
142+
* [Internal option] Id of the remote configuration.
143+
* Prefer `remoteConfiguration.id` for the non-blocking cache-and-reload path.
142144
*
143145
* @internal
144146
*/
145147
remoteConfigurationId?: string | undefined
146148

149+
/**
150+
* [Internal option] Remote configuration descriptor. By default the SDK reads a cached
151+
* configuration synchronously and refreshes it in the background. Set `sync: true` to fall back
152+
* to the legacy blocking fetch.
153+
*
154+
* @internal
155+
*/
156+
remoteConfiguration?: { id: string; sync?: boolean } | undefined
157+
147158
/**
148159
* [Internal option] set a proxy URL for the remote configuration
149160
*
@@ -667,7 +678,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {
667678
...plugin.getConfigurationTelemetry?.(),
668679
})),
669680
track_feature_flags_for_events: configuration.trackFeatureFlagsForEvents,
670-
remote_configuration_id: configuration.remoteConfigurationId,
681+
remote_configuration_id: getRemoteConfigurationId(configuration),
671682
profiling_sample_rate: configuration.profilingSampleRate,
672683
use_remote_configuration_proxy: !!configuration.remoteConfigurationProxy,
673684
track_resource_headers: getTrackResourceHeadersTelemetryValue(configuration.trackResourceHeaders),
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './configuration'
22
export * from './remoteConfiguration'
3+
export * from './remoteConfigurationCache'

packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import {
1616
applyRemoteConfiguration,
1717
buildEndpoint,
1818
fetchRemoteConfiguration,
19+
getRemoteConfiguration,
1920
} from './remoteConfiguration'
21+
import { buildCacheKey } from './remoteConfigurationCache'
2022

2123
const DEFAULT_INIT_CONFIGURATION: RumInitConfiguration = {
2224
clientToken: 'xxx',
@@ -749,4 +751,121 @@ describe('remoteConfiguration', () => {
749751
expect(buildEndpoint({ remoteConfigurationProxy: '/config' } as RumInitConfiguration)).toEqual('/config')
750752
})
751753
})
754+
755+
describe('async loading (getRemoteConfiguration)', () => {
756+
const REMOTE_CONFIGURATION_ID = 'rc-test-id'
757+
const CACHE_KEY = buildCacheKey(REMOTE_CONFIGURATION_ID)
758+
const FRESH_RUM_CONFIG: RumRemoteConfiguration = { applicationId: 'fresh-app' }
759+
const CACHED_RUM_CONFIG: RumRemoteConfiguration = { applicationId: 'cached-app' }
760+
761+
let initConfiguration: RumInitConfiguration
762+
let supportedContextManagers: {
763+
user: ReturnType<typeof createContextManager>
764+
context: ReturnType<typeof createContextManager>
765+
}
766+
let interceptor: ReturnType<typeof interceptRequests>
767+
let displaySpy: jasmine.Spy
768+
769+
function withCachedEntry(config: RumRemoteConfiguration) {
770+
localStorage.setItem(CACHE_KEY, JSON.stringify({ version: 1, config, fetchedAt: 1000 }))
771+
}
772+
773+
function withFetchSuccess(config: RumRemoteConfiguration = FRESH_RUM_CONFIG) {
774+
interceptor.withFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ rum: config }) }))
775+
}
776+
777+
function withFetchFailure() {
778+
interceptor.withFetch(() => Promise.reject(new Error('Network error')))
779+
}
780+
781+
async function flushBackgroundSync() {
782+
await interceptor.waitForAllFetchCalls()
783+
await new Promise<void>((resolve) => setTimeout(resolve))
784+
}
785+
786+
beforeEach(() => {
787+
initConfiguration = {
788+
...DEFAULT_INIT_CONFIGURATION,
789+
applicationId: 'init-app',
790+
remoteConfiguration: { id: REMOTE_CONFIGURATION_ID },
791+
}
792+
supportedContextManagers = { user: createContextManager(), context: createContextManager() }
793+
interceptor = interceptRequests()
794+
displaySpy = spyOn(display, 'error')
795+
796+
registerCleanupTask(() => {
797+
localStorage.clear()
798+
})
799+
})
800+
801+
it('should return init configuration on cache miss', async () => {
802+
withFetchSuccess()
803+
804+
const result = getRemoteConfiguration(initConfiguration, supportedContextManagers)
805+
806+
expect(result).toBe(initConfiguration)
807+
await flushBackgroundSync()
808+
})
809+
810+
it('should apply cached configuration to init on cache hit', async () => {
811+
withCachedEntry(CACHED_RUM_CONFIG)
812+
withFetchSuccess()
813+
814+
const result = getRemoteConfiguration(initConfiguration, supportedContextManagers)
815+
816+
expect(result.applicationId).toBe('cached-app')
817+
expect(result.clientToken).toBe('xxx')
818+
await flushBackgroundSync()
819+
})
820+
821+
it('should return init configuration on cache error and remove the corrupted entry', async () => {
822+
localStorage.setItem(CACHE_KEY, 'not-json')
823+
withFetchSuccess()
824+
825+
const result = getRemoteConfiguration(initConfiguration, supportedContextManagers)
826+
827+
expect(result).toBe(initConfiguration)
828+
expect(localStorage.getItem(CACHE_KEY)).toBeNull()
829+
await flushBackgroundSync()
830+
})
831+
832+
it('should write the fetched configuration to cache on background fetch success', async () => {
833+
withFetchSuccess()
834+
835+
getRemoteConfiguration(initConfiguration, supportedContextManagers)
836+
await flushBackgroundSync()
837+
838+
const stored = JSON.parse(localStorage.getItem(CACHE_KEY)!)
839+
expect(stored.config).toEqual(FRESH_RUM_CONFIG)
840+
expect(stored.version).toBe(1)
841+
})
842+
843+
it('should not overwrite cache when background fetch fails', async () => {
844+
withCachedEntry(CACHED_RUM_CONFIG)
845+
withFetchFailure()
846+
847+
getRemoteConfiguration(initConfiguration, supportedContextManagers)
848+
await flushBackgroundSync()
849+
850+
const stored = JSON.parse(localStorage.getItem(CACHE_KEY)!)
851+
expect(stored.config).toEqual(CACHED_RUM_CONFIG)
852+
expect(displaySpy).toHaveBeenCalled()
853+
})
854+
855+
it('should always trigger a background fetch regardless of cache state', async () => {
856+
withCachedEntry(CACHED_RUM_CONFIG)
857+
const fetchSpy = withFetchSuccessReturningSpy()
858+
859+
getRemoteConfiguration(initConfiguration, supportedContextManagers)
860+
await flushBackgroundSync()
861+
862+
expect(fetchSpy).toHaveBeenCalledTimes(1)
863+
864+
function withFetchSuccessReturningSpy() {
865+
return interceptor.withFetch(() =>
866+
Promise.resolve({ ok: true, json: () => Promise.resolve({ rum: FRESH_RUM_CONFIG }) })
867+
)
868+
}
869+
})
870+
})
752871
})

0 commit comments

Comments
 (0)