Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remote_configuration_id resolves the ID in the opposite order from getRemoteConfigurationId. I'd use the helper here too:

remote_configuration_id: getRemoteConfigurationId(configuration),

Nobody would pass both options, but this way there's a single source of truth for the resolution. What do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree, will fix

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DataDog fix this

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can only run on private repositories.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bruh

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