Skip to content
2 changes: 1 addition & 1 deletion developer-extension/src/content-scripts/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ function restoreFunctions(
typeof resultValue === 'object' &&
!Array.isArray(resultValue)
) {
result[key] = restoreFunctions(originalValue, resultValue)
result[key] = restoreFunctions(originalValue as SDKInitConfiguration, resultValue)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './remoteConfigurationCache'
export type { RumSdkConfig, DynamicOption, SerializedRegex, ContextItem } from './remoteConfiguration.types'
export * from './remoteConfigurationFetch'
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ export interface RumSdkConfig {
*/
sampleRate?: number
}
/**
* Logs feature Remote Configuration properties
*/
logs?: {
/**
* Whether to forward console.error calls as Datadog log events
*/
forwardErrorsToLogs?: boolean
/**
* Console methods to forward as Datadog log events
*/
forwardConsoleLogs?: 'all' | ('log' | 'debug' | 'info' | 'warn' | 'error')[]
/**
* Reporting API types to forward as Datadog log events
*/
forwardReports?: 'all' | ('intervention' | 'deprecation' | 'csp_violation')[]
}
/**
* RUM feature Remote Configuration properties
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { timeStampNow } from '@datadog/js-core/time'
import { tryJsonParse } from '@datadog/browser-core'
import type { TimeStamp } from '@datadog/js-core/time'
import type { RemoteConfiguration } from './remoteConfiguration'
import { tryJsonParse } from '../../tools/utils/objectUtils'

export const CACHE_VERSION = 2
export const CACHE_KEY_PREFIX = 'dd_rc_'

interface CachedRemoteConfiguration {
interface CachedRemoteConfiguration<T> {
version: number
config: RemoteConfiguration
config: T
fetchedAt: TimeStamp
}

export type CacheReadStatus = 'hit' | 'miss' | 'error'

export type CacheReadResult =
export type CacheReadResult<T> =
| {
status: Exclude<CacheReadStatus, 'hit'>
}
| { status: Extract<CacheReadStatus, 'hit'>; config: RemoteConfiguration }
| { status: Extract<CacheReadStatus, 'hit'>; config: T }

export const CACHE_STATUS_TO_METRIC_MAP: Record<CacheReadStatus, 'success' | 'missing' | 'failure'> = {
hit: 'success',
Expand All @@ -30,7 +29,7 @@ export function buildCacheKey(remoteConfigurationId: string): string {
return `${CACHE_KEY_PREFIX}${remoteConfigurationId}`
}

function isValidCacheEntry(value: unknown): value is CachedRemoteConfiguration {
function isValidCacheEntry(value: unknown): value is CachedRemoteConfiguration<unknown> {
if (typeof value !== 'object' || value === null) {
return false
}
Expand All @@ -41,11 +40,11 @@ function isValidCacheEntry(value: unknown): value is CachedRemoteConfiguration {
return hasVersion && hasConfig
}

export function createConfigurationCache({ remoteConfigurationId }: { remoteConfigurationId: string }) {
export function createConfigurationCache<T>({ remoteConfigurationId }: { remoteConfigurationId: string }) {
const key = buildCacheKey(remoteConfigurationId)

return {
read(): CacheReadResult {
read(): CacheReadResult<T> {
let raw: string | null

try {
Expand All @@ -71,7 +70,7 @@ export function createConfigurationCache({ remoteConfigurationId }: { remoteConf
return { status: 'error' }
}

return { status: 'hit', config: parsed.config }
return { status: 'hit', config: parsed.config as T }
},
remove() {
try {
Expand All @@ -80,8 +79,8 @@ export function createConfigurationCache({ remoteConfigurationId }: { remoteConf
// Ignore
}
},
write(config: RemoteConfiguration) {
const entry: CachedRemoteConfiguration = {
write(config: T) {
const entry: CachedRemoteConfiguration<T> = {
version: CACHE_VERSION,
config,
fetchedAt: timeStampNow(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { interceptRequests } from '@datadog/browser-core/test'
import { fetchRemoteConfiguration } from './remoteConfigurationFetch'

describe('fetchRemoteConfiguration', () => {
const options = { site: 'datadoghq.com', remoteConfigurationId: 'test-id' }
let interceptor: ReturnType<typeof interceptRequests>

beforeEach(() => {
interceptor = interceptRequests()
})

it('returns ok:true with the parsed config on success', async () => {
const config = { rum: { applicationId: 'abc', sessionSampleRate: 50 } }
interceptor.withFetch(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(config),
})
)

const result = await fetchRemoteConfiguration(options)
expect(result).toEqual({ ok: true, value: config })
})

it('returns ok:false on HTTP error (non-ok response)', async () => {
interceptor.withFetch(() => Promise.resolve({ ok: false, status: 404 }))

const result = await fetchRemoteConfiguration(options)
expect(result.ok).toBeFalse()
expect((result as { ok: false; error: Error }).error).toBeInstanceOf(Error)
})

it('returns ok:false on network failure (fetch throws)', async () => {
interceptor.withFetch(() => Promise.reject(new Error('Network error')))

const result = await fetchRemoteConfiguration(options)
expect(result.ok).toBeFalse()
expect((result as { ok: false; error: Error }).error).toBeInstanceOf(Error)
})

it('returns ok:false when response body is not valid JSON', async () => {
interceptor.withFetch(() =>
Promise.resolve({
ok: true,
json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')),
})
)

const result = await fetchRemoteConfiguration(options)
expect(result.ok).toBeFalse()
expect((result as { ok: false; error: Error }).error).toBeInstanceOf(Error)
})

it('removes the window registry entry after all fetches settle', async () => {
const config = { rum: { applicationId: 'abc', sessionSampleRate: 50 } }
interceptor.withFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve(config) }))

await fetchRemoteConfiguration(options)

expect((window as unknown as Record<string, unknown>).__ddRcInflight).toBeUndefined()
})

it('deduplicates concurrent calls for the same endpoint', async () => {
let fetchCount = 0
const config = { rum: { applicationId: 'abc', sessionSampleRate: 50 } }
interceptor.withFetch(() => {
fetchCount++
return Promise.resolve({ ok: true, json: () => Promise.resolve(config) })
})

const [result1, result2] = await Promise.all([fetchRemoteConfiguration(options), fetchRemoteConfiguration(options)])

expect(fetchCount).toBe(1)
expect(result1).toEqual({ ok: true, value: config })
expect(result2).toEqual({ ok: true, value: config })
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { buildEndpointUrl } from '@datadog/js-core/transport'
import { globalObject } from '@datadog/js-core/util'
import { fetch } from '../../browser/fetch'
import type { RumSdkConfig } from './remoteConfiguration.types'

export type RemoteConfiguration = RumSdkConfig

const REMOTE_CONFIGURATION_VERSION = 'v1'

export interface RemoteConfigurationEndpointOptions {
site?: string | undefined
remoteConfigurationId?: string | undefined
remoteConfigurationProxy?: string | undefined
remoteConfiguration?: { id?: string } | undefined
}

export type FetchRemoteConfigurationResult = { ok: true; value: RemoteConfiguration } | { ok: false; error: Error }

// Typed interface for the global inflight fetch registry so deduplication
// works across separate SDK bundles (e.g. RUM and Logs loaded as separate CDN
// scripts on the same page) and in service-worker environments where `window`
// is not available.
interface GlobalWithInflightFetches {
__ddRcInflight?: Map<string, Promise<FetchRemoteConfigurationResult>>
}

function getInflightFetches(): Map<string, Promise<FetchRemoteConfigurationResult>> {
const global = globalObject as GlobalWithInflightFetches
if (!global.__ddRcInflight) {
global.__ddRcInflight = new Map()
}
return global.__ddRcInflight
}

export function getRemoteConfigurationId(options: RemoteConfigurationEndpointOptions): string | undefined {
return options.remoteConfiguration?.id ?? options.remoteConfigurationId
}

export function buildEndpoint(options: RemoteConfigurationEndpointOptions): string {
if (options.remoteConfigurationProxy) {
return options.remoteConfigurationProxy
}
const id = getRemoteConfigurationId(options)!
return buildEndpointUrl({

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.

The generic fetch doesn't validate whether the response has a rum or logs section. That's intentional — each SDK wraps it with its own guard. RUM checks rum || profiling before proceeding; Logs just skips applying if logs is absent. What do you think?

site: options.site!,
path: `/${REMOTE_CONFIGURATION_VERSION}/${encodeURIComponent(id)}.json`,
subdomain: 'sdk-configuration',
})
}

export function fetchRemoteConfiguration(
options: RemoteConfigurationEndpointOptions
): Promise<FetchRemoteConfigurationResult> {
const endpoint = buildEndpoint(options)
const inflightFetches = getInflightFetches()

if (!inflightFetches.has(endpoint)) {
const promise = doFetchRemoteConfiguration(endpoint).finally(() => {
inflightFetches.delete(endpoint)
if (inflightFetches.size === 0) {
delete (globalObject as GlobalWithInflightFetches).__ddRcInflight

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.

💬 suggestion: ‏Encapsulate this in the getInflightFetches() method (it would return an object that proxies .delete()), this way the __ddRcInflight is hidden.

}
})
inflightFetches.set(endpoint, promise)
}

return inflightFetches.get(endpoint)!
}

async function doFetchRemoteConfiguration(endpoint: string): Promise<FetchRemoteConfigurationResult> {
let response: Response | undefined
try {
response = await fetch(endpoint)
} catch {
response = undefined
}
if (!response?.ok) {
return { ok: false, error: new Error('Error fetching the remote configuration.') }
}
try {
const value: RemoteConfiguration = await response.json()
return { ok: true, value }
} catch {
return { ok: false, error: new Error('Error parsing the remote configuration.') }
}
}
1 change: 1 addition & 0 deletions packages/browser-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,4 @@ export * from './tools/stackTrace/handlingStack'
export * from './domain/tags'
export { correctedChildSampleRate, isSampled, resetSampleDecisionCache, sampleUsingKnuthFactor } from './domain/sampler'
export { startTelemetrySessionContext } from './domain/contexts/telemetrySessionContext'
export * from './domain/remoteConfiguration'
31 changes: 31 additions & 0 deletions packages/browser-logs/src/boot/preStartLogs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ import {
replaceMockable,
replaceMockableWithSpy,
createStartSessionManagerMock,
registerCleanupTask,
} from '@datadog/browser-core/test'
import type { TrackingConsentState } from '@datadog/browser-core'
import {
TrackingConsent,
createTrackingConsentState,
display,
fetchRemoteConfiguration,
startTelemetry,
startSessionManager,
CACHE_VERSION,
buildCacheKey,
} from '@datadog/browser-core'
import type { CommonContext } from '../rawLogsEvent.types'
import type { HybridInitConfiguration, LogsInitConfiguration } from '../domain/configuration'
Expand Down Expand Up @@ -253,6 +257,33 @@ describe('preStartLogs', () => {
})
})

describe('remote configuration', () => {
const RC_ID = 'test-rc-id'

beforeEach(() => {
replaceMockableWithSpy(fetchRemoteConfiguration).and.returnValue(new Promise((_resolve) => undefined))
})

it('applies cached remote config overrides before starting', async () => {
localStorage.setItem(
buildCacheKey(RC_ID),
JSON.stringify({
version: CACHE_VERSION,
config: { logs: { forwardErrorsToLogs: false } },
fetchedAt: Date.now(),
})
)
registerCleanupTask(() => localStorage.removeItem(buildCacheKey(RC_ID)))

const { strategy, doStartLogsSpy } = createPreStartStrategyWithDefaults()
strategy.init({ clientToken: 'xxx', remoteConfiguration: { id: RC_ID } })
await collectAsyncCalls(doStartLogsSpy, 1)

const [configuration] = doStartLogsSpy.calls.argsFor(0)
expect(configuration.forwardErrorsToLogs).toBe(false)
})
})

describe('telemetry', () => {
it('starts telemetry during init() by default', async () => {
const { strategy, startTelemetrySpy } = createPreStartStrategyWithDefaults()
Expand Down
Loading
Loading