Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ generated-docs/
.env*
!.env.example
.rum-ai-toolkit/
.idea/

# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
.pnp.*
Expand Down
100 changes: 98 additions & 2 deletions packages/rum-core/src/boot/preStartRum.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
mockEventBridge,
mockSyntheticsWorkerValues,
createFakeTelemetryObject,
registerCleanupTask,
replaceMockable,
replaceMockableWithSpy,
createStartSessionManagerMock,
Expand Down Expand Up @@ -421,7 +422,7 @@ describe('preStartRum', () => {
})
})

describe('remote configuration', () => {
describe('remote configuration sync loading', () => {
let interceptor: ReturnType<typeof interceptRequests>

beforeEach(() => {
Expand All @@ -446,6 +447,77 @@ describe('preStartRum', () => {
await collectAsyncCalls(doStartRumSpy, 1)
expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50)
})

it('should start with the remote configuration when remoteConfiguration.sync is true', async () => {
interceptor.withFetch(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }),
})
)
const { strategy, doStartRumSpy } = createPreStartStrategyWithDefaults()
strategy.init(
{
...DEFAULT_INIT_CONFIGURATION,
remoteConfiguration: { id: '123', sync: true },
},
PUBLIC_API
)
await collectAsyncCalls(doStartRumSpy, 1)
expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50)
})
})

describe('remote configuration async loading', () => {
const REMOTE_CONFIGURATION_ID = '123'
let interceptor: ReturnType<typeof interceptRequests>

beforeEach(() => {
localStorage.clear()

interceptor = interceptRequests()
interceptor.withFetch(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }),
})
)

registerCleanupTask(() => {
localStorage.clear()
})
})

it('should start synchronously with init configuration on cache miss', async () => {
const { strategy, doStartRumSpy } = createPreStartStrategyWithDefaults()

strategy.init(
{
...DEFAULT_INIT_CONFIGURATION,
remoteConfiguration: { id: REMOTE_CONFIGURATION_ID },
sessionSampleRate: 25,
},
PUBLIC_API
)

await collectAsyncCalls(doStartRumSpy, 1)
expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toBe(25)
})

it('should trigger a background fetch to the remote configuration endpoint', async () => {
const { strategy } = createPreStartStrategyWithDefaults()

strategy.init(
{
...DEFAULT_INIT_CONFIGURATION,
remoteConfiguration: { id: REMOTE_CONFIGURATION_ID },
},
PUBLIC_API
)

await interceptor.waitForAllFetchCalls()
expect(interceptor.requests.some((r) => r.url.includes(REMOTE_CONFIGURATION_ID))).toBeTrue()
})
})

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

beforeEach(() => {
localStorage.clear()

interceptor = interceptRequests()
initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, service: 'my-service', version: '1.4.2', env: 'dev' }

registerCleanupTask(() => {
localStorage.clear()
})
})

it('is undefined before init', () => {
Expand Down Expand Up @@ -544,7 +622,7 @@ describe('preStartRum', () => {
expect(strategy.initConfiguration).toEqual(initConfiguration)
})

it('returns the initConfiguration with the remote configuration when a remoteConfigurationId is provided', (done) => {
it('returns the initConfiguration with the remote configuration when a remoteConfigurationId is provided (sync loading)', (done) => {
interceptor.withFetch(() =>
Promise.resolve({
ok: true,
Expand All @@ -565,6 +643,24 @@ describe('preStartRum', () => {
PUBLIC_API
)
})

it('exposes the user configuration when remoteConfiguration.id is provided (async loading, cache miss)', () => {
interceptor.withFetch(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }),
})
)

const { strategy } = createPreStartStrategyWithDefaults()
const userInitConfiguration: RumInitConfiguration = {
...DEFAULT_INIT_CONFIGURATION,
remoteConfiguration: { id: '123' },
}
strategy.init(userInitConfiguration, PUBLIC_API)

expect(strategy.initConfiguration).toEqual(userInitConfiguration)
})
})

describe('buffers API calls before starting RUM', () => {
Expand Down
30 changes: 21 additions & 9 deletions packages/rum-core/src/boot/preStartRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ import {
import type { Hooks } from '../domain/hooks'
import { createHooks } from '../domain/hooks'
import type { RumConfiguration, RumInitConfiguration } from '../domain/configuration'

import {
validateAndBuildRumConfiguration,
fetchAndApplyRemoteConfiguration,
getRemoteConfiguration,
getRemoteConfigurationId,
validateAndBuildRumConfiguration,
serializeRumConfiguration,
} from '../domain/configuration'
import type { ViewOptions } from '../domain/view/trackViews'
Expand Down Expand Up @@ -243,14 +246,23 @@ export function createPreStartStrategy(

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

if (initConfiguration.remoteConfigurationId) {
fetchAndApplyRemoteConfiguration(initConfiguration, { user: userContext, context: globalContext })
.then((initConfiguration) => {
if (initConfiguration) {
doInit(initConfiguration, errorStack)
}
})
.catch(monitorError)
const hasRemoteConfiguration = getRemoteConfigurationId(initConfiguration)

if (hasRemoteConfiguration) {
const supportedContextManagers = { user: userContext, context: globalContext }
const isSyncLoading = !!initConfiguration.remoteConfigurationId || !!initConfiguration.remoteConfiguration?.sync

if (isSyncLoading) {
fetchAndApplyRemoteConfiguration(initConfiguration, supportedContextManagers)
.then((resolvedInitConfiguration) => {
if (resolvedInitConfiguration) {
doInit(resolvedInitConfiguration, errorStack)
}
})
.catch(monitorError)
} else {
doInit(getRemoteConfiguration(initConfiguration, supportedContextManagers), errorStack)
}
} else {
doInit(initConfiguration, errorStack)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,7 @@ describe('serializeRumConfiguration', () => {
trackResources: true,
trackLongTasks: true,
remoteConfigurationId: '123',
remoteConfiguration: { id: '123', sync: false },
remoteConfigurationProxy: 'config',
plugins: [{ name: 'foo', getConfigurationTelemetry: () => ({ bar: true }) }],
trackFeatureFlagsForEvents: ['vital'],
Expand All @@ -845,7 +846,8 @@ describe('serializeRumConfiguration', () => {
: Key extends 'trackLongTasks'
? 'track_long_task' // We forgot the s, keeping this for backward compatibility
: // The following options are not reported as telemetry. Please avoid adding more of them.
Key extends 'applicationId' | 'subdomain'
// `remoteConfiguration` is covered by the legacy `remote_configuration_id` field.
Key extends 'applicationId' | 'subdomain' | 'remoteConfiguration'
? never
: CamelToSnakeCase<Key>
// By specifying the type here, we can ensure that serializeConfiguration is returning an
Expand Down
14 changes: 12 additions & 2 deletions packages/rum-core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,22 @@ export interface RumInitConfiguration extends InitConfiguration {
compressIntakeRequests?: boolean | undefined

/**
* [Internal option] Id of the remote configuration
* [Internal option] Id of the remote configuration.
* Prefer `remoteConfiguration.id` for the non-blocking cache-and-reload path.
*
* @internal
*/
remoteConfigurationId?: string | undefined

/**
* [Internal option] Remote configuration descriptor. By default the SDK reads a cached
* configuration synchronously and refreshes it in the background. Set `sync: true` to fall back
* to the legacy blocking fetch.
*
* @internal
*/
remoteConfiguration?: { id: string; sync?: boolean } | undefined

/**
* [Internal option] set a proxy URL for the remote configuration
*
Expand Down Expand Up @@ -666,7 +676,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {
...plugin.getConfigurationTelemetry?.(),
})),
track_feature_flags_for_events: configuration.trackFeatureFlagsForEvents,
remote_configuration_id: configuration.remoteConfigurationId,
remote_configuration_id: configuration.remoteConfigurationId ?? configuration.remoteConfiguration?.id,
profiling_sample_rate: configuration.profilingSampleRate,
use_remote_configuration_proxy: !!configuration.remoteConfigurationProxy,
track_resource_headers: getTrackResourceHeadersTelemetryValue(configuration.trackResourceHeaders),
Expand Down
1 change: 1 addition & 0 deletions packages/rum-core/src/domain/configuration/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './configuration'
export * from './remoteConfiguration'
export * from './remoteConfigurationCache'
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import {
applyRemoteConfiguration,
buildEndpoint,
fetchRemoteConfiguration,
getRemoteConfiguration,
} from './remoteConfiguration'
import { buildCacheKey } from './remoteConfigurationCache'

const DEFAULT_INIT_CONFIGURATION: RumInitConfiguration = {
clientToken: 'xxx',
Expand Down Expand Up @@ -749,4 +751,121 @@ describe('remoteConfiguration', () => {
expect(buildEndpoint({ remoteConfigurationProxy: '/config' } as RumInitConfiguration)).toEqual('/config')
})
})

describe('async loading (getRemoteConfiguration)', () => {
const REMOTE_CONFIGURATION_ID = 'rc-test-id'
const CACHE_KEY = buildCacheKey(REMOTE_CONFIGURATION_ID)
const FRESH_RUM_CONFIG: RumRemoteConfiguration = { applicationId: 'fresh-app' }
const CACHED_RUM_CONFIG: RumRemoteConfiguration = { applicationId: 'cached-app' }

let initConfiguration: RumInitConfiguration
let supportedContextManagers: {
user: ReturnType<typeof createContextManager>
context: ReturnType<typeof createContextManager>
}
let interceptor: ReturnType<typeof interceptRequests>
let displaySpy: jasmine.Spy

function withCachedEntry(config: RumRemoteConfiguration) {
localStorage.setItem(CACHE_KEY, JSON.stringify({ version: 1, config, fetchedAt: 1000 }))
}

function withFetchSuccess(config: RumRemoteConfiguration = FRESH_RUM_CONFIG) {
interceptor.withFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ rum: config }) }))
}

function withFetchFailure() {
interceptor.withFetch(() => Promise.reject(new Error('Network error')))
}

async function flushBackgroundSync() {
await interceptor.waitForAllFetchCalls()
await new Promise<void>((resolve) => setTimeout(resolve))
}

beforeEach(() => {
initConfiguration = {
...DEFAULT_INIT_CONFIGURATION,
applicationId: 'init-app',
remoteConfiguration: { id: REMOTE_CONFIGURATION_ID },
}
supportedContextManagers = { user: createContextManager(), context: createContextManager() }
interceptor = interceptRequests()
displaySpy = spyOn(display, 'error')

registerCleanupTask(() => {
localStorage.clear()
})
})

it('should return init configuration on cache miss', async () => {
withFetchSuccess()

const result = getRemoteConfiguration(initConfiguration, supportedContextManagers)

expect(result).toBe(initConfiguration)
await flushBackgroundSync()
})

it('should apply cached configuration to init on cache hit', async () => {
withCachedEntry(CACHED_RUM_CONFIG)
withFetchSuccess()

const result = getRemoteConfiguration(initConfiguration, supportedContextManagers)

expect(result.applicationId).toBe('cached-app')
expect(result.clientToken).toBe('xxx')
await flushBackgroundSync()
})

it('should return init configuration on cache error and remove the corrupted entry', async () => {
localStorage.setItem(CACHE_KEY, 'not-json')
withFetchSuccess()

const result = getRemoteConfiguration(initConfiguration, supportedContextManagers)

expect(result).toBe(initConfiguration)
expect(localStorage.getItem(CACHE_KEY)).toBeNull()
await flushBackgroundSync()
})

it('should write the fetched configuration to cache on background fetch success', async () => {
withFetchSuccess()

getRemoteConfiguration(initConfiguration, supportedContextManagers)
await flushBackgroundSync()

const stored = JSON.parse(localStorage.getItem(CACHE_KEY)!)
expect(stored.config).toEqual(FRESH_RUM_CONFIG)
expect(stored.version).toBe(1)
})

it('should not overwrite cache when background fetch fails', async () => {
withCachedEntry(CACHED_RUM_CONFIG)
withFetchFailure()

getRemoteConfiguration(initConfiguration, supportedContextManagers)
await flushBackgroundSync()

const stored = JSON.parse(localStorage.getItem(CACHE_KEY)!)
expect(stored.config).toEqual(CACHED_RUM_CONFIG)
expect(displaySpy).toHaveBeenCalled()
})

it('should always trigger a background fetch regardless of cache state', async () => {
withCachedEntry(CACHED_RUM_CONFIG)
const fetchSpy = withFetchSuccessReturningSpy()

getRemoteConfiguration(initConfiguration, supportedContextManagers)
await flushBackgroundSync()

expect(fetchSpy).toHaveBeenCalledTimes(1)

function withFetchSuccessReturningSpy() {
return interceptor.withFetch(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ rum: FRESH_RUM_CONFIG }) })
)
}
})
})
})
Loading
Loading