From b63668fa9d0525ac5592bacd7b12e80bf11a6533 Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Thu, 16 Jul 2026 13:32:14 +0200 Subject: [PATCH 1/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Move=20remote=20config?= =?UTF-8?q?=20infrastructure=20from=20browser-rum-core=20to=20browser-core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/domain/remoteConfiguration/index.ts | 3 + .../remoteConfiguration.types.ts | 17 +++++ .../remoteConfigurationCache.ts | 23 ++++--- .../remoteConfigurationFetch.spec.ts | 40 ++++++++++++ .../remoteConfigurationFetch.ts | 48 ++++++++++++++ packages/browser-core/src/index.ts | 1 + .../src/domain/configuration/index.ts | 1 - .../configuration/remoteConfiguration.spec.ts | 3 +- .../configuration/remoteConfiguration.ts | 64 +++++-------------- .../remoteConfigurationCache.spec.ts | 2 +- remote-configuration/rum-sdk-config.json | 31 +++++++++ scripts/lib/generatedSchemaTypes.ts | 2 +- 12 files changed, 170 insertions(+), 65 deletions(-) create mode 100644 packages/browser-core/src/domain/remoteConfiguration/index.ts rename packages/{browser-rum-core/src/domain/configuration => browser-core/src/domain/remoteConfiguration}/remoteConfiguration.types.ts (89%) rename packages/{browser-rum-core/src/domain/configuration => browser-core/src/domain/remoteConfiguration}/remoteConfigurationCache.ts (75%) create mode 100644 packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts create mode 100644 packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts diff --git a/packages/browser-core/src/domain/remoteConfiguration/index.ts b/packages/browser-core/src/domain/remoteConfiguration/index.ts new file mode 100644 index 0000000000..64d36ad39d --- /dev/null +++ b/packages/browser-core/src/domain/remoteConfiguration/index.ts @@ -0,0 +1,3 @@ +export * from './remoteConfigurationCache' +export type { RumSdkConfig, DynamicOption, SerializedRegex, ContextItem } from './remoteConfiguration.types' +export * from './remoteConfigurationFetch' diff --git a/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.types.ts b/packages/browser-core/src/domain/remoteConfiguration/remoteConfiguration.types.ts similarity index 89% rename from packages/browser-rum-core/src/domain/configuration/remoteConfiguration.types.ts rename to packages/browser-core/src/domain/remoteConfiguration/remoteConfiguration.types.ts index 07376b506a..64a1be1652 100644 --- a/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.types.ts +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfiguration.types.ts @@ -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 */ diff --git a/packages/browser-rum-core/src/domain/configuration/remoteConfigurationCache.ts b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationCache.ts similarity index 75% rename from packages/browser-rum-core/src/domain/configuration/remoteConfigurationCache.ts rename to packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationCache.ts index f0c5202dbd..29b284af55 100644 --- a/packages/browser-rum-core/src/domain/configuration/remoteConfigurationCache.ts +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationCache.ts @@ -1,24 +1,23 @@ import { timeStampNow } from '@datadog/js-core/time' -import { tryJsonParse } from '@datadog/browser-core' +import { tryJsonParse } from '../../tools/utils/objectUtils' import type { TimeStamp } from '@datadog/js-core/time' -import type { RemoteConfiguration } from './remoteConfiguration' export const CACHE_VERSION = 2 export const CACHE_KEY_PREFIX = 'dd_rc_' -interface CachedRemoteConfiguration { +interface CachedRemoteConfiguration { version: number - config: RemoteConfiguration + config: T fetchedAt: TimeStamp } export type CacheReadStatus = 'hit' | 'miss' | 'error' -export type CacheReadResult = +export type CacheReadResult = | { status: Exclude } - | { status: Extract; config: RemoteConfiguration } + | { status: Extract; config: T } export const CACHE_STATUS_TO_METRIC_MAP: Record = { hit: 'success', @@ -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 { if (typeof value !== 'object' || value === null) { return false } @@ -41,11 +40,11 @@ function isValidCacheEntry(value: unknown): value is CachedRemoteConfiguration { return hasVersion && hasConfig } -export function createConfigurationCache({ remoteConfigurationId }: { remoteConfigurationId: string }) { +export function createConfigurationCache({ remoteConfigurationId }: { remoteConfigurationId: string }) { const key = buildCacheKey(remoteConfigurationId) return { - read(): CacheReadResult { + read(): CacheReadResult { let raw: string | null try { @@ -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 { @@ -80,8 +79,8 @@ export function createConfigurationCache({ remoteConfigurationId }: { remoteConf // Ignore } }, - write(config: RemoteConfiguration) { - const entry: CachedRemoteConfiguration = { + write(config: T) { + const entry: CachedRemoteConfiguration = { version: CACHE_VERSION, config, fetchedAt: timeStampNow(), diff --git a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts new file mode 100644 index 0000000000..8183021aa9 --- /dev/null +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts @@ -0,0 +1,40 @@ +import { interceptRequests } from '@datadog/browser-core/test' +import { fetchRemoteConfiguration } from './remoteConfigurationFetch' + +describe('fetchRemoteConfiguration', () => { + const options = { site: 'datadoghq.com', remoteConfigurationId: 'test-id' } + let interceptor: ReturnType + + 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) + }) +}) diff --git a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts new file mode 100644 index 0000000000..fb328f562d --- /dev/null +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts @@ -0,0 +1,48 @@ +import { buildEndpointUrl } from '@datadog/js-core/transport' +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 } + +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({ + site: options.site!, + path: `/${REMOTE_CONFIGURATION_VERSION}/${encodeURIComponent(id)}.json`, + subdomain: 'sdk-configuration', + }) +} + +export async function fetchRemoteConfiguration( + options: RemoteConfigurationEndpointOptions +): Promise { + let response: Response | undefined + try { + response = await fetch(buildEndpoint(options)) + } catch { + response = undefined + } + if (!response?.ok) { + return { ok: false, error: new Error('Error fetching the remote configuration.') } + } + const value: RemoteConfiguration = await response.json() + return { ok: true, value } +} diff --git a/packages/browser-core/src/index.ts b/packages/browser-core/src/index.ts index a681e3770f..764333b7c9 100644 --- a/packages/browser-core/src/index.ts +++ b/packages/browser-core/src/index.ts @@ -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' diff --git a/packages/browser-rum-core/src/domain/configuration/index.ts b/packages/browser-rum-core/src/domain/configuration/index.ts index 16961aa71a..133c5671a2 100644 --- a/packages/browser-rum-core/src/domain/configuration/index.ts +++ b/packages/browser-rum-core/src/domain/configuration/index.ts @@ -1,3 +1,2 @@ export * from './configuration' export * from './remoteConfiguration' -export * from './remoteConfigurationCache' diff --git a/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 0135b119f2..820f3c8f12 100644 --- a/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,5 +1,5 @@ import { ONE_MINUTE } from '@datadog/js-core/time' -import { DefaultPrivacyLevel, display, setCookie, deleteCookie, createContextManager } from '@datadog/browser-core' +import { DefaultPrivacyLevel, display, setCookie, deleteCookie, createContextManager, buildCacheKey, CACHE_VERSION } from '@datadog/browser-core' import { INTAKE_SITE_US1 } from '@datadog/js-core/transport' import { interceptRequests, registerCleanupTask } from '@datadog/browser-core/test' import { appendElement } from '../../../test' @@ -13,7 +13,6 @@ import { fetchRemoteConfiguration, getRemoteConfiguration, } from './remoteConfiguration' -import { buildCacheKey, CACHE_VERSION } from './remoteConfigurationCache' const DEFAULT_INIT_CONFIGURATION: RumInitConfiguration = { clientToken: 'xxx', diff --git a/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.ts index 1137f288fc..edce6ae29d 100644 --- a/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.ts @@ -1,4 +1,4 @@ -import type { createContextManager, Context } from '@datadog/browser-core' +import type { createContextManager, Context, FetchRemoteConfigurationResult, RemoteConfiguration, DynamicOption, ContextItem } from '@datadog/browser-core' import { display, mapValues, @@ -6,19 +6,20 @@ import { addTelemetryMetrics, TelemetryMetrics, monitorError, - fetch, + fetchRemoteConfiguration as fetchRemoteConfigurationCore, + buildEndpoint, + getRemoteConfigurationId, + CACHE_STATUS_TO_METRIC_MAP, + createConfigurationCache, } from '@datadog/browser-core' +export { buildEndpoint, getRemoteConfigurationId } import { isIndexableObject } from '@datadog/js-core/util' -import { buildEndpointUrl } from '@datadog/js-core/transport' import { extractRegexMatch } from '../extractRegexMatch' import type { RumInitConfiguration } from './configuration' -import type { RumSdkConfig, DynamicOption, ContextItem } from './remoteConfiguration.types' import { parseJsonPath } from './jsonPathParser' -import { CACHE_STATUS_TO_METRIC_MAP, createConfigurationCache } from './remoteConfigurationCache' -export type RemoteConfiguration = RumSdkConfig +export type { RemoteConfiguration } export type RumRemoteConfiguration = Exclude -const REMOTE_CONFIGURATION_VERSION = 'v1' const SUPPORTED_RUM_FIELDS: Array = [ 'applicationId', 'service', @@ -295,55 +296,22 @@ function extractValue(extractor: SerializedRegex, candidate: string) { return extractRegexMatch(candidate, resolvedExtractor) } -type FetchRemoteConfigurationResult = { ok: true; value: RemoteConfiguration } | { ok: false; error: Error } - export async function fetchRemoteConfiguration( configuration: RumInitConfiguration ): Promise { - let response: Response | undefined - try { - response = await fetch(buildEndpoint(configuration)) - } catch { - response = undefined - } - if (!response?.ok) { - return { - ok: false, - error: new Error('Error fetching the remote configuration.'), - } - } - const remoteConfiguration: RemoteConfiguration = await response.json() - if (remoteConfiguration.rum || remoteConfiguration.profiling) { - return { - ok: true, - value: remoteConfiguration, - } + const result = await fetchRemoteConfigurationCore(configuration) + if (!result.ok) { + return result } - return { - ok: false, - error: new Error('No remote configuration for RUM.'), + if (result.value.rum || result.value.profiling) { + return result } -} - -export function getRemoteConfigurationId(configuration: RumInitConfiguration): string | undefined { - return configuration.remoteConfiguration?.id ?? configuration.remoteConfigurationId -} - -export function buildEndpoint(configuration: RumInitConfiguration) { - if (configuration.remoteConfigurationProxy) { - return configuration.remoteConfigurationProxy - } - const id = getRemoteConfigurationId(configuration)! - return buildEndpointUrl({ - site: configuration.site, - path: `/${REMOTE_CONFIGURATION_VERSION}/${encodeURIComponent(id)}.json`, - subdomain: 'sdk-configuration', - }) + return { ok: false, error: new Error('No remote configuration for RUM.') } } function doBackgroundCacheSync( initConfiguration: RumInitConfiguration, - cache: ReturnType, + cache: ReturnType>, metrics: ReturnType ) { fetchRemoteConfiguration(initConfiguration) @@ -367,7 +335,7 @@ export function getRemoteConfiguration( initConfiguration: RumInitConfiguration, supportedContextManagers: SupportedContextManagers ): RumInitConfiguration | undefined { - const configurationCache = createConfigurationCache({ + const configurationCache = createConfigurationCache({ remoteConfigurationId: getRemoteConfigurationId(initConfiguration)!, }) const metrics = initMetrics() diff --git a/packages/browser-rum-core/src/domain/configuration/remoteConfigurationCache.spec.ts b/packages/browser-rum-core/src/domain/configuration/remoteConfigurationCache.spec.ts index 73011d84ea..9dff0ee9a4 100644 --- a/packages/browser-rum-core/src/domain/configuration/remoteConfigurationCache.spec.ts +++ b/packages/browser-rum-core/src/domain/configuration/remoteConfigurationCache.spec.ts @@ -1,7 +1,7 @@ import { registerCleanupTask, mockClock } from '@datadog/browser-core/test' import type { Clock } from '@datadog/browser-core/test' +import { buildCacheKey, createConfigurationCache, CACHE_VERSION, CACHE_KEY_PREFIX } from '@datadog/browser-core' import type { RemoteConfiguration } from './remoteConfiguration' -import { buildCacheKey, createConfigurationCache, CACHE_VERSION, CACHE_KEY_PREFIX } from './remoteConfigurationCache' const REMOTE_CONFIGURATION_ID = 'test-id' const CACHE_KEY = `${CACHE_KEY_PREFIX}${REMOTE_CONFIGURATION_ID}` diff --git a/remote-configuration/rum-sdk-config.json b/remote-configuration/rum-sdk-config.json index bb3c3e19d6..ee51897bbd 100644 --- a/remote-configuration/rum-sdk-config.json +++ b/remote-configuration/rum-sdk-config.json @@ -151,6 +151,37 @@ } } }, + "logs": { + "type": "object", + "description": "Logs feature Remote Configuration properties", + "additionalProperties": false, + "properties": { + "forwardErrorsToLogs": { + "type": "boolean", + "description": "Whether to forward console.error calls as Datadog log events" + }, + "forwardConsoleLogs": { + "description": "Console methods to forward as Datadog log events", + "oneOf": [ + { "type": "string", "const": "all" }, + { + "type": "array", + "items": { "type": "string", "enum": ["log", "debug", "info", "warn", "error"] } + } + ] + }, + "forwardReports": { + "description": "Reporting API types to forward as Datadog log events", + "oneOf": [ + { "type": "string", "const": "all" }, + { + "type": "array", + "items": { "type": "string", "enum": ["intervention", "deprecation", "csp_violation"] } + } + ] + } + } + }, "rum": { "type": "object", "description": "RUM feature Remote Configuration properties", diff --git a/scripts/lib/generatedSchemaTypes.ts b/scripts/lib/generatedSchemaTypes.ts index 6e7c5c63df..55f6fe89b6 100644 --- a/scripts/lib/generatedSchemaTypes.ts +++ b/scripts/lib/generatedSchemaTypes.ts @@ -34,7 +34,7 @@ export const SCHEMAS: SchemaConfig[] = [ options: { additionalProperties: false }, }, { - typesPath: 'packages/browser-rum-core/src/domain/configuration/remoteConfiguration.types.ts', + typesPath: 'packages/browser-core/src/domain/remoteConfiguration/remoteConfiguration.types.ts', schemaPath: path.join(rootDir, 'remote-configuration/rum-sdk-config.json'), }, ] From f7c8f90df9bfe732f4c9ff50e9c2553a6e54c023 Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Thu, 16 Jul 2026 13:50:55 +0200 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20Add=20remote=20configuration=20?= =?UTF-8?q?support=20for=20logs=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/content-scripts/main.ts | 2 +- .../remoteConfigurationCache.ts | 2 +- .../remoteConfigurationFetch.spec.ts | 13 ++ .../remoteConfigurationFetch.ts | 8 +- .../src/boot/preStartLogs.spec.ts | 28 ++++ .../browser-logs/src/boot/preStartLogs.ts | 39 +++++- .../src/domain/configuration.spec.ts | 20 ++- .../browser-logs/src/domain/configuration.ts | 26 ++++ .../src/domain/remoteConfiguration.spec.ts | 123 ++++++++++++++++++ .../src/domain/remoteConfiguration.ts | 72 ++++++++++ .../configuration/remoteConfiguration.spec.ts | 14 +- .../configuration/remoteConfiguration.ts | 9 +- .../logs/remoteConfiguration.scenario.ts | 29 +++++ 13 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 packages/browser-logs/src/domain/remoteConfiguration.spec.ts create mode 100644 packages/browser-logs/src/domain/remoteConfiguration.ts create mode 100644 test/e2e/scenario/logs/remoteConfiguration.scenario.ts diff --git a/developer-extension/src/content-scripts/main.ts b/developer-extension/src/content-scripts/main.ts index 5ef0abfae5..8129ca05ca 100644 --- a/developer-extension/src/content-scripts/main.ts +++ b/developer-extension/src/content-scripts/main.ts @@ -152,7 +152,7 @@ function restoreFunctions( typeof resultValue === 'object' && !Array.isArray(resultValue) ) { - result[key] = restoreFunctions(originalValue, resultValue) + result[key] = restoreFunctions(originalValue as SDKInitConfiguration, resultValue) } } diff --git a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationCache.ts b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationCache.ts index 29b284af55..cb9d44997f 100644 --- a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationCache.ts +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationCache.ts @@ -1,6 +1,6 @@ import { timeStampNow } from '@datadog/js-core/time' -import { tryJsonParse } from '../../tools/utils/objectUtils' import type { TimeStamp } from '@datadog/js-core/time' +import { tryJsonParse } from '../../tools/utils/objectUtils' export const CACHE_VERSION = 2 export const CACHE_KEY_PREFIX = 'dd_rc_' diff --git a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts index 8183021aa9..e7d6ffe6fe 100644 --- a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts @@ -37,4 +37,17 @@ describe('fetchRemoteConfiguration', () => { 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) + }) }) diff --git a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts index fb328f562d..8410d7bd76 100644 --- a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts @@ -43,6 +43,10 @@ export async function fetchRemoteConfiguration( if (!response?.ok) { return { ok: false, error: new Error('Error fetching the remote configuration.') } } - const value: RemoteConfiguration = await response.json() - return { ok: true, value } + try { + const value: RemoteConfiguration = await response.json() + return { ok: true, value } + } catch { + return { ok: false, error: new Error('Error parsing the remote configuration.') } + } } diff --git a/packages/browser-logs/src/boot/preStartLogs.spec.ts b/packages/browser-logs/src/boot/preStartLogs.spec.ts index 4d3901f6ac..40b322a47e 100644 --- a/packages/browser-logs/src/boot/preStartLogs.spec.ts +++ b/packages/browser-logs/src/boot/preStartLogs.spec.ts @@ -17,6 +17,8 @@ import { display, startTelemetry, startSessionManager, + CACHE_VERSION, + buildCacheKey, } from '@datadog/browser-core' import type { CommonContext } from '../rawLogsEvent.types' import type { HybridInitConfiguration, LogsInitConfiguration } from '../domain/configuration' @@ -253,6 +255,32 @@ describe('preStartLogs', () => { }) }) + describe('remote configuration', () => { + const RC_ID = 'test-rc-id' + + afterEach(() => { + localStorage.removeItem(buildCacheKey(RC_ID)) + }) + + 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(), + }) + ) + + 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() diff --git a/packages/browser-logs/src/boot/preStartLogs.ts b/packages/browser-logs/src/boot/preStartLogs.ts index e61062986d..b39dfac89d 100644 --- a/packages/browser-logs/src/boot/preStartLogs.ts +++ b/packages/browser-logs/src/boot/preStartLogs.ts @@ -22,11 +22,13 @@ import { mockable, startTelemetrySessionContext, setAllowUntrustedEvents, + getRemoteConfigurationId, } from '@datadog/browser-core' import type { Hooks } from '../domain/hooks' import { createHooks } from '../domain/hooks' import type { LogsConfiguration, LogsInitConfiguration } from '../domain/configuration' import { serializeLogsConfiguration, validateAndBuildLogsConfiguration } from '../domain/configuration' +import { fetchAndApplyLogsRemoteConfiguration, getLogsRemoteConfiguration } from '../domain/remoteConfiguration' import type { CommonContext } from '../rawLogsEvent.types' import { startTrackingConsentContext } from '../domain/contexts/trackingConsentContext' import type { Strategy } from './logsPublicApi' @@ -71,7 +73,42 @@ export function createPreStartStrategy( } trackingConsentStateSubscription.unsubscribe() - const startLogsResult = doStartLogs(cachedConfiguration, sessionManager, hooks) + + let configurationToUse = cachedConfiguration + + const hasRemoteConfiguration = getRemoteConfigurationId(cachedInitConfiguration) + + if (hasRemoteConfiguration) { + const isSyncLoading = + !!cachedInitConfiguration.remoteConfigurationId || !!cachedInitConfiguration.remoteConfiguration?.sync + + if (isSyncLoading) { + void fetchAndApplyLogsRemoteConfiguration(cachedInitConfiguration) + .then((resolvedInitConfig) => { + if (resolvedInitConfig) { + const resolvedLogsConfig = validateAndBuildLogsConfiguration(resolvedInitConfig) + if (resolvedLogsConfig) { + const startLogsResult = doStartLogs(resolvedLogsConfig, sessionManager!, hooks) + bufferApiCalls.subscribe((callback) => callback(startLogsResult)) + bufferApiCalls.unbuffer() + } + } + }) + .catch(monitorError) + return + } + + const resolvedInitConfig = getLogsRemoteConfiguration(cachedInitConfiguration) + if (!resolvedInitConfig) { + return + } + const resolvedLogsConfig = validateAndBuildLogsConfiguration(resolvedInitConfig) + if (resolvedLogsConfig) { + configurationToUse = resolvedLogsConfig + } + } + + const startLogsResult = doStartLogs(configurationToUse, sessionManager, hooks) bufferApiCalls.subscribe((callback) => callback(startLogsResult)) bufferApiCalls.unbuffer() diff --git a/packages/browser-logs/src/domain/configuration.spec.ts b/packages/browser-logs/src/domain/configuration.spec.ts index 406d3798cc..f8cb520727 100644 --- a/packages/browser-logs/src/domain/configuration.spec.ts +++ b/packages/browser-logs/src/domain/configuration.spec.ts @@ -17,6 +17,15 @@ import { const DEFAULT_INIT_CONFIGURATION = { clientToken: 'xxx' } describe('validateAndBuildLogsConfiguration', () => { + it('should accept remoteConfigurationId', () => { + const result = validateAndBuildLogsConfiguration({ + clientToken: 'xxx', + remoteConfigurationId: 'rc-test-id', + }) + expect(result).toBeDefined() + expect(result!.remoteConfigurationId).toBe('rc-test-id') + }) + describe('forwardErrorsToLogs', () => { it('defaults to true if the option is not provided', () => { expect(validateAndBuildLogsConfiguration(DEFAULT_INIT_CONFIGURATION)!.forwardErrorsToLogs).toBeTrue() @@ -124,11 +133,18 @@ describe('serializeLogsConfiguration', () => { forwardErrorsToLogs: true, forwardConsoleLogs: 'all', forwardReports: 'all', + remoteConfigurationId: 'rc-test-id', + remoteConfiguration: { id: 'rc-test-id' }, + remoteConfigurationProxy: 'https://proxy.example.com', } type MapLogsInitConfigurationKey = Key extends keyof InitConfiguration ? MapInitConfigurationKey - : CamelToSnakeCase + : Key extends 'remoteConfiguration' + ? never + : Key extends 'remoteConfigurationProxy' + ? 'use_remote_configuration_proxy' + : CamelToSnakeCase // By specifying the type here, we can ensure that serializeConfiguration is returning an // object containing all expected properties. @@ -141,6 +157,8 @@ describe('serializeLogsConfiguration', () => { forward_errors_to_logs: true, forward_console_logs: 'all', forward_reports: 'all', + remote_configuration_id: 'rc-test-id', + use_remote_configuration_proxy: true, }) }) }) diff --git a/packages/browser-logs/src/domain/configuration.ts b/packages/browser-logs/src/domain/configuration.ts index 3d3517706a..bdb7ba9da0 100644 --- a/packages/browser-logs/src/domain/configuration.ts +++ b/packages/browser-logs/src/domain/configuration.ts @@ -8,6 +8,7 @@ import { removeDuplicates, RawReportType, objectValues, + getRemoteConfigurationId, } from '@datadog/browser-core' import type { LogsEvent } from '../logsEvent.types' import type { LogsEventDomainContext } from '../domainContext.types' @@ -71,6 +72,27 @@ export interface LogsInitConfiguration extends InitConfiguration { * @category Data Collection */ forwardReports?: RawReportType[] | 'all' | undefined + + /** + * The ID of the remote configuration to apply. Use this for the non-blocking cache-and-reload path. + * + * @category Remote Configuration + */ + remoteConfigurationId?: string | undefined + + /** + * Remote configuration options. + * + * @category Remote Configuration + */ + remoteConfiguration?: { id: string; sync?: boolean; required?: boolean } | undefined + + /** + * Proxy URL for fetching the remote configuration. + * + * @category Remote Configuration + */ + remoteConfigurationProxy?: string | undefined } /** @@ -89,6 +111,7 @@ export interface LogsConfiguration extends Configuration { forwardConsoleLogs: ConsoleApiName[] forwardReports: RawReportType[] requestErrorResponseLengthLimit: number + remoteConfigurationId: string | undefined } /** @@ -123,6 +146,7 @@ export function validateAndBuildLogsConfiguration( forwardConsoleLogs, forwardReports, requestErrorResponseLengthLimit: DEFAULT_REQUEST_ERROR_RESPONSE_LENGTH_LIMIT, + remoteConfigurationId: getRemoteConfigurationId(initConfiguration), ...baseConfiguration, } } @@ -151,6 +175,8 @@ export function serializeLogsConfiguration(configuration: LogsInitConfiguration) forward_errors_to_logs: configuration.forwardErrorsToLogs, forward_console_logs: configuration.forwardConsoleLogs, forward_reports: configuration.forwardReports, + remote_configuration_id: getRemoteConfigurationId(configuration), + use_remote_configuration_proxy: configuration.remoteConfigurationProxy !== undefined || undefined, ...baseSerializedInitConfiguration, } satisfies RawTelemetryConfiguration } diff --git a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts new file mode 100644 index 0000000000..df188b1e05 --- /dev/null +++ b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts @@ -0,0 +1,123 @@ +import { CACHE_VERSION, buildCacheKey } from '@datadog/browser-core' +import { interceptRequests, registerCleanupTask } from '@datadog/browser-core/test' +import type { LogsInitConfiguration } from './configuration' +import { + applyLogsRemoteConfiguration, + fetchAndApplyLogsRemoteConfiguration, + getLogsRemoteConfiguration, +} from './remoteConfiguration' + +const DEFAULT_LOGS_INIT: LogsInitConfiguration = { + clientToken: 'xxx', + site: 'datadoghq.com', + remoteConfiguration: { id: 'test-id' }, +} + +describe('applyLogsRemoteConfiguration', () => { + it('should apply forwardErrorsToLogs when present', () => { + const result = applyLogsRemoteConfiguration(DEFAULT_LOGS_INIT, { + logs: { forwardErrorsToLogs: false }, + }) + expect(result.forwardErrorsToLogs).toBeFalse() + }) + + it('should apply forwardConsoleLogs when present', () => { + const result = applyLogsRemoteConfiguration(DEFAULT_LOGS_INIT, { + logs: { forwardConsoleLogs: ['warn', 'error'] }, + }) + expect(result.forwardConsoleLogs).toEqual(['warn', 'error']) + }) + + it('should apply forwardReports when present', () => { + const result = applyLogsRemoteConfiguration(DEFAULT_LOGS_INIT, { + logs: { forwardReports: 'all' }, + }) + expect(result.forwardReports).toBe('all') + }) + + it('should not overwrite fields absent from the remote config', () => { + const initWithReports: LogsInitConfiguration = { + clientToken: 'xxx', + forwardReports: ['deprecation'], + } + const result = applyLogsRemoteConfiguration(initWithReports, { logs: {} }) + expect(result.forwardReports).toEqual(['deprecation']) + }) + + it('should skip logs fields when the logs section is absent', () => { + const initWithErrors: LogsInitConfiguration = { + clientToken: 'xxx', + forwardErrorsToLogs: true, + } + const result = applyLogsRemoteConfiguration(initWithErrors, { profiling: { sampleRate: 10 } }) + expect(result.forwardErrorsToLogs).toBe(true) + }) +}) + +describe('getLogsRemoteConfiguration', () => { + const RC_ID = 'test-id' + const initConfiguration: LogsInitConfiguration = { + clientToken: 'xxx', + site: 'datadoghq.com', + remoteConfiguration: { id: RC_ID }, + } + + afterEach(() => { + localStorage.removeItem(buildCacheKey(RC_ID)) + }) + + it('returns the initConfiguration with remote overrides applied on cache hit', () => { + localStorage.setItem( + buildCacheKey(RC_ID), + JSON.stringify({ + version: CACHE_VERSION, + config: { logs: { forwardErrorsToLogs: false } }, + fetchedAt: Date.now(), + }) + ) + + const result = getLogsRemoteConfiguration(initConfiguration) + expect(result!.forwardErrorsToLogs).toBeFalse() + }) + + it('returns the initConfiguration unchanged on cache miss when not required', () => { + const result = getLogsRemoteConfiguration(initConfiguration) + expect(result).toEqual(initConfiguration) + }) + + it('returns undefined on cache miss when required', () => { + const requiredInit: LogsInitConfiguration = { + ...initConfiguration, + remoteConfiguration: { id: RC_ID, required: true }, + } + const result = getLogsRemoteConfiguration(requiredInit) + expect(result).toBeUndefined() + }) +}) + +describe('fetchAndApplyLogsRemoteConfiguration', () => { + let interceptor: ReturnType + + beforeEach(() => { + interceptor = interceptRequests() + }) + + it('returns the init configuration with overrides applied on success', async () => { + interceptor.withFetch(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ logs: { forwardErrorsToLogs: false } }), + }) + ) + + const result = await fetchAndApplyLogsRemoteConfiguration(DEFAULT_LOGS_INIT) + expect(result!.forwardErrorsToLogs).toBeFalse() + }) + + it('returns undefined when the fetch fails', async () => { + interceptor.withFetch(() => Promise.resolve({ ok: false, status: 500 })) + + const result = await fetchAndApplyLogsRemoteConfiguration(DEFAULT_LOGS_INIT) + expect(result).toBeUndefined() + }) +}) diff --git a/packages/browser-logs/src/domain/remoteConfiguration.ts b/packages/browser-logs/src/domain/remoteConfiguration.ts new file mode 100644 index 0000000000..d1eb95ec55 --- /dev/null +++ b/packages/browser-logs/src/domain/remoteConfiguration.ts @@ -0,0 +1,72 @@ +import { + type RemoteConfiguration, + createConfigurationCache, + display, + fetchRemoteConfiguration, + getRemoteConfigurationId, + monitorError, +} from '@datadog/browser-core' +import type { LogsInitConfiguration } from './configuration' + +const SUPPORTED_LOGS_FIELDS: Array = [ + 'forwardErrorsToLogs', + 'forwardConsoleLogs', + 'forwardReports', +] + +export function applyLogsRemoteConfiguration( + initConfiguration: LogsInitConfiguration, + remoteConfiguration: RemoteConfiguration +): LogsInitConfiguration { + if (!remoteConfiguration.logs) { + return initConfiguration + } + const logsRemoteConfiguration = remoteConfiguration.logs as Record + const appliedConfiguration = { ...initConfiguration } as LogsInitConfiguration & Record + SUPPORTED_LOGS_FIELDS.forEach((field: string) => { + if (field in logsRemoteConfiguration) { + appliedConfiguration[field] = logsRemoteConfiguration[field] + } + }) + return appliedConfiguration +} + +export function getLogsRemoteConfiguration( + initConfiguration: LogsInitConfiguration +): LogsInitConfiguration | undefined { + const cache = createConfigurationCache({ + remoteConfigurationId: getRemoteConfigurationId(initConfiguration)!, + }) + + const cacheResult = cache.read() + + // Background sync — update the cache for the next page load + fetchRemoteConfiguration(initConfiguration) + .then((fetchResult) => { + if (fetchResult.ok) { + cache.write(fetchResult.value) + } + }) + .catch(monitorError) + + if (cacheResult.status === 'hit') { + return applyLogsRemoteConfiguration(initConfiguration, cacheResult.config) + } + + if (initConfiguration.remoteConfiguration?.required) { + return undefined + } + + return initConfiguration +} + +export async function fetchAndApplyLogsRemoteConfiguration( + initConfiguration: LogsInitConfiguration +): Promise { + const fetchResult = await fetchRemoteConfiguration(initConfiguration) + if (!fetchResult.ok) { + display.error(fetchResult.error) + return undefined + } + return applyLogsRemoteConfiguration(initConfiguration, fetchResult.value) +} diff --git a/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 820f3c8f12..512ee3403b 100644 --- a/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,5 +1,13 @@ import { ONE_MINUTE } from '@datadog/js-core/time' -import { DefaultPrivacyLevel, display, setCookie, deleteCookie, createContextManager, buildCacheKey, CACHE_VERSION } from '@datadog/browser-core' +import { + DefaultPrivacyLevel, + display, + setCookie, + deleteCookie, + createContextManager, + buildCacheKey, + CACHE_VERSION, +} from '@datadog/browser-core' import { INTAKE_SITE_US1 } from '@datadog/js-core/transport' import { interceptRequests, registerCleanupTask } from '@datadog/browser-core/test' import { appendElement } from '../../../test' @@ -792,13 +800,13 @@ describe('remoteConfiguration', () => { describe('buildEndpoint', () => { it('should return the remote configuration endpoint', () => { const remoteConfigurationId = '0e008b1b-8600-4709-9d1d-f4edcfdf5587' - expect(buildEndpoint({ site: INTAKE_SITE_US1, remoteConfigurationId } as RumInitConfiguration)).toEqual( + expect(buildEndpoint({ site: INTAKE_SITE_US1, remoteConfigurationId })).toEqual( `https://sdk-configuration.browser-intake-datadoghq.com/v1/${remoteConfigurationId}.json` ) }) it('should return the remote configuration proxy', () => { - expect(buildEndpoint({ remoteConfigurationProxy: '/config' } as RumInitConfiguration)).toEqual('/config') + expect(buildEndpoint({ remoteConfigurationProxy: '/config' })).toEqual('/config') }) }) diff --git a/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.ts index edce6ae29d..a0cde0177a 100644 --- a/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/browser-rum-core/src/domain/configuration/remoteConfiguration.ts @@ -1,4 +1,11 @@ -import type { createContextManager, Context, FetchRemoteConfigurationResult, RemoteConfiguration, DynamicOption, ContextItem } from '@datadog/browser-core' +import type { + createContextManager, + Context, + FetchRemoteConfigurationResult, + RemoteConfiguration, + DynamicOption, + ContextItem, +} from '@datadog/browser-core' import { display, mapValues, diff --git a/test/e2e/scenario/logs/remoteConfiguration.scenario.ts b/test/e2e/scenario/logs/remoteConfiguration.scenario.ts new file mode 100644 index 0000000000..212c4f9e88 --- /dev/null +++ b/test/e2e/scenario/logs/remoteConfiguration.scenario.ts @@ -0,0 +1,29 @@ +import type { RemoteConfiguration } from '@datadog/browser-core' +import { test, expect } from '@playwright/test' +import { createTest, html } from '../../lib/framework' + +const RC_ID = 'e2e' +const CACHE_KEY = `dd_rc_${RC_ID}` + +test.describe('logs remote configuration', () => { + createTest('should apply forwardErrorsToLogs: false from cached remote configuration') + .withLogs({ remoteConfiguration: { id: RC_ID } }) + .withHead(seedCache({ logs: { forwardErrorsToLogs: false } })) + .run(async ({ intakeRegistry, flushEvents, flushBrowserLogs, page }) => { + await page.evaluate(() => { + console.error('test error') + }) + + await flushEvents() + flushBrowserLogs() + + expect(intakeRegistry.logsEvents.filter((e) => e.message === 'test error')).toHaveLength(0) + }) +}) + +function seedCache(remoteConfig: RemoteConfiguration) { + const entry = JSON.stringify({ version: 2, config: remoteConfig, fetchedAt: 1000 }) + return html`` +} From 8640c841ec90e21769e83043947ba710ef8bc838 Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Mon, 27 Jul 2026 12:44:48 +0200 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20Deduplicate=20concurrent=20remo?= =?UTF-8?q?te=20config=20fetches=20across=20SDK=20bundles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remoteConfigurationFetch.spec.ts | 24 +++++++++++++ .../remoteConfigurationFetch.ts | 34 +++++++++++++++++-- .../src/domain/remoteConfiguration.spec.ts | 8 +++-- .../src/domain/remoteConfiguration.ts | 2 +- .../scenario/remoteConfiguration.scenario.ts | 30 ++++++++++++++++ 5 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 test/e2e/scenario/remoteConfiguration.scenario.ts diff --git a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts index e7d6ffe6fe..ff6f585692 100644 --- a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts @@ -50,4 +50,28 @@ describe('fetchRemoteConfiguration', () => { 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).__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 }) + }) }) diff --git a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts index 8410d7bd76..aae6bd20c7 100644 --- a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts @@ -31,12 +31,42 @@ export function buildEndpoint(options: RemoteConfigurationEndpointOptions): stri }) } -export async function fetchRemoteConfiguration( +// Use a window-level registry so deduplication works across separate SDK bundles +// (e.g. RUM and Logs loaded as separate CDN scripts on the same page). +const INFLIGHT_FETCHES_KEY = '__ddRcInflight' + +function getInflightFetches(): Map> { + const win = window as unknown as Record + if (!win[INFLIGHT_FETCHES_KEY]) { + win[INFLIGHT_FETCHES_KEY] = new Map>() + } + return win[INFLIGHT_FETCHES_KEY] as Map> +} + +export function fetchRemoteConfiguration( options: RemoteConfigurationEndpointOptions ): Promise { + const endpoint = buildEndpoint(options) + const inflightFetches = getInflightFetches() + + if (!inflightFetches.has(endpoint)) { + const win = window as unknown as Record + const promise = doFetchRemoteConfiguration(endpoint).finally(() => { + inflightFetches.delete(endpoint) + if (inflightFetches.size === 0) { + delete win[INFLIGHT_FETCHES_KEY] + } + }) + inflightFetches.set(endpoint, promise) + } + + return inflightFetches.get(endpoint)! +} + +async function doFetchRemoteConfiguration(endpoint: string): Promise { let response: Response | undefined try { - response = await fetch(buildEndpoint(options)) + response = await fetch(endpoint) } catch { response = undefined } diff --git a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts index df188b1e05..fad57289b7 100644 --- a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts +++ b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts @@ -1,5 +1,5 @@ -import { CACHE_VERSION, buildCacheKey } from '@datadog/browser-core' -import { interceptRequests, registerCleanupTask } from '@datadog/browser-core/test' +import { CACHE_VERSION, buildCacheKey, display } from '@datadog/browser-core' +import { interceptRequests } from '@datadog/browser-core/test' import type { LogsInitConfiguration } from './configuration' import { applyLogsRemoteConfiguration, @@ -114,10 +114,12 @@ describe('fetchAndApplyLogsRemoteConfiguration', () => { expect(result!.forwardErrorsToLogs).toBeFalse() }) - it('returns undefined when the fetch fails', async () => { + it('returns undefined and displays an error when the fetch fails', async () => { + spyOn(display, 'error') interceptor.withFetch(() => Promise.resolve({ ok: false, status: 500 })) const result = await fetchAndApplyLogsRemoteConfiguration(DEFAULT_LOGS_INIT) expect(result).toBeUndefined() + expect(display.error).toHaveBeenCalled() }) }) diff --git a/packages/browser-logs/src/domain/remoteConfiguration.ts b/packages/browser-logs/src/domain/remoteConfiguration.ts index d1eb95ec55..e620e0651e 100644 --- a/packages/browser-logs/src/domain/remoteConfiguration.ts +++ b/packages/browser-logs/src/domain/remoteConfiguration.ts @@ -1,5 +1,5 @@ +import type { RemoteConfiguration } from '@datadog/browser-core' import { - type RemoteConfiguration, createConfigurationCache, display, fetchRemoteConfiguration, diff --git a/test/e2e/scenario/remoteConfiguration.scenario.ts b/test/e2e/scenario/remoteConfiguration.scenario.ts new file mode 100644 index 0000000000..ef274a820b --- /dev/null +++ b/test/e2e/scenario/remoteConfiguration.scenario.ts @@ -0,0 +1,30 @@ +import type { Page } from '@playwright/test' +import { test, expect } from '@playwright/test' +import { createTest } from '../lib/framework' + +const RC_ID = 'e2e' + +test.describe('remote configuration', () => { + createTest('should issue a single fetch when both RUM and Logs share the same remote config ID') + .withRum({ remoteConfigurationId: RC_ID, sessionSampleRate: 100 }) + .withLogs({ remoteConfigurationId: RC_ID }) + .withRemoteConfiguration({ + rum: { applicationId: RC_ID, sessionSampleRate: 100 }, + logs: { forwardErrorsToLogs: true }, + }) + .run(async ({ page }) => { + await waitForRumRemoteConfigurationApplied(page) + + const configRequestCount = await page.evaluate( + () => window.performance.getEntriesByType('resource').filter((e) => e.name.endsWith('/config')).length + ) + + expect(configRequestCount).toBe(1) + }) +}) + +async function waitForRumRemoteConfigurationApplied(page: Page) { + await page.waitForFunction((rcId) => window.DD_RUM?.getInitConfiguration()?.applicationId === rcId, RC_ID, { + timeout: 10000, + }) +} From 61208b12501a1af87f0beac6eb8a896eec739ddb Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Tue, 28 Jul 2026 11:53:01 +0200 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=90=9B=20address=20Codex=20review=20f?= =?UTF-8?q?eedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remoteConfigurationFetch.ts | 32 +++++++++++-------- .../src/boot/preStartLogs.spec.ts | 6 ++-- .../browser-logs/src/domain/configuration.ts | 8 +++-- .../src/domain/remoteConfiguration.spec.ts | 8 +++-- .../src/domain/remoteConfiguration.ts | 4 ++- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts index aae6bd20c7..8e3dd194b1 100644 --- a/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts @@ -1,4 +1,5 @@ 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' @@ -15,6 +16,22 @@ export interface RemoteConfigurationEndpointOptions { 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> +} + +function getInflightFetches(): Map> { + 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 } @@ -31,18 +48,6 @@ export function buildEndpoint(options: RemoteConfigurationEndpointOptions): stri }) } -// Use a window-level registry so deduplication works across separate SDK bundles -// (e.g. RUM and Logs loaded as separate CDN scripts on the same page). -const INFLIGHT_FETCHES_KEY = '__ddRcInflight' - -function getInflightFetches(): Map> { - const win = window as unknown as Record - if (!win[INFLIGHT_FETCHES_KEY]) { - win[INFLIGHT_FETCHES_KEY] = new Map>() - } - return win[INFLIGHT_FETCHES_KEY] as Map> -} - export function fetchRemoteConfiguration( options: RemoteConfigurationEndpointOptions ): Promise { @@ -50,11 +55,10 @@ export function fetchRemoteConfiguration( const inflightFetches = getInflightFetches() if (!inflightFetches.has(endpoint)) { - const win = window as unknown as Record const promise = doFetchRemoteConfiguration(endpoint).finally(() => { inflightFetches.delete(endpoint) if (inflightFetches.size === 0) { - delete win[INFLIGHT_FETCHES_KEY] + delete (globalObject as GlobalWithInflightFetches).__ddRcInflight } }) inflightFetches.set(endpoint, promise) diff --git a/packages/browser-logs/src/boot/preStartLogs.spec.ts b/packages/browser-logs/src/boot/preStartLogs.spec.ts index 40b322a47e..2154133918 100644 --- a/packages/browser-logs/src/boot/preStartLogs.spec.ts +++ b/packages/browser-logs/src/boot/preStartLogs.spec.ts @@ -9,6 +9,7 @@ import { replaceMockable, replaceMockableWithSpy, createStartSessionManagerMock, + registerCleanupTask, } from '@datadog/browser-core/test' import type { TrackingConsentState } from '@datadog/browser-core' import { @@ -258,10 +259,6 @@ describe('preStartLogs', () => { describe('remote configuration', () => { const RC_ID = 'test-rc-id' - afterEach(() => { - localStorage.removeItem(buildCacheKey(RC_ID)) - }) - it('applies cached remote config overrides before starting', async () => { localStorage.setItem( buildCacheKey(RC_ID), @@ -271,6 +268,7 @@ describe('preStartLogs', () => { fetchedAt: Date.now(), }) ) + registerCleanupTask(() => localStorage.removeItem(buildCacheKey(RC_ID))) const { strategy, doStartLogsSpy } = createPreStartStrategyWithDefaults() strategy.init({ clientToken: 'xxx', remoteConfiguration: { id: RC_ID } }) diff --git a/packages/browser-logs/src/domain/configuration.ts b/packages/browser-logs/src/domain/configuration.ts index bdb7ba9da0..8d2c998e5a 100644 --- a/packages/browser-logs/src/domain/configuration.ts +++ b/packages/browser-logs/src/domain/configuration.ts @@ -74,14 +74,18 @@ export interface LogsInitConfiguration extends InitConfiguration { forwardReports?: RawReportType[] | 'all' | undefined /** - * The ID of the remote configuration to apply. Use this for the non-blocking cache-and-reload path. + * The ID of the remote configuration to apply. Triggers synchronous loading — the SDK waits for + * the fetch to complete before starting. Use `remoteConfiguration: { id }` for the non-blocking + * cache-and-reload path instead. * * @category Remote Configuration */ remoteConfigurationId?: string | undefined /** - * Remote configuration options. + * Remote configuration options. Use `{ id }` for the non-blocking cache-and-reload path (the SDK + * starts immediately using a cached value and refreshes in the background). Use `{ id, sync: true }` + * to block startup on a live fetch. * * @category Remote Configuration */ diff --git a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts index fad57289b7..8307c2feca 100644 --- a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts +++ b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts @@ -1,5 +1,5 @@ import { CACHE_VERSION, buildCacheKey, display } from '@datadog/browser-core' -import { interceptRequests } from '@datadog/browser-core/test' +import { interceptRequests, registerCleanupTask } from '@datadog/browser-core/test' import type { LogsInitConfiguration } from './configuration' import { applyLogsRemoteConfiguration, @@ -62,8 +62,9 @@ describe('getLogsRemoteConfiguration', () => { remoteConfiguration: { id: RC_ID }, } - afterEach(() => { - localStorage.removeItem(buildCacheKey(RC_ID)) + beforeEach(() => { + // Prevent background sync from firing real network requests + interceptRequests() }) it('returns the initConfiguration with remote overrides applied on cache hit', () => { @@ -75,6 +76,7 @@ describe('getLogsRemoteConfiguration', () => { fetchedAt: Date.now(), }) ) + registerCleanupTask(() => localStorage.removeItem(buildCacheKey(RC_ID))) const result = getLogsRemoteConfiguration(initConfiguration) expect(result!.forwardErrorsToLogs).toBeFalse() diff --git a/packages/browser-logs/src/domain/remoteConfiguration.ts b/packages/browser-logs/src/domain/remoteConfiguration.ts index e620e0651e..c7e22e0150 100644 --- a/packages/browser-logs/src/domain/remoteConfiguration.ts +++ b/packages/browser-logs/src/domain/remoteConfiguration.ts @@ -43,7 +43,9 @@ export function getLogsRemoteConfiguration( // Background sync — update the cache for the next page load fetchRemoteConfiguration(initConfiguration) .then((fetchResult) => { - if (fetchResult.ok) { + if (!fetchResult.ok) { + display.error(fetchResult.error) + } else { cache.write(fetchResult.value) } }) From 62c3b5273e600e111c8c57e0f431ebd839cd735b Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Tue, 28 Jul 2026 12:09:39 +0200 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=90=9B=20report=20remote-overridden?= =?UTF-8?q?=20config=20values=20in=20telemetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../browser-logs/src/boot/preStartLogs.ts | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/browser-logs/src/boot/preStartLogs.ts b/packages/browser-logs/src/boot/preStartLogs.ts index b39dfac89d..71dddf1d05 100644 --- a/packages/browser-logs/src/boot/preStartLogs.ts +++ b/packages/browser-logs/src/boot/preStartLogs.ts @@ -67,6 +67,17 @@ export function createPreStartStrategy( const hooks = createHooks() const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartLogs) + function doInit(initConfig: LogsInitConfiguration) { + const configuration = validateAndBuildLogsConfiguration(initConfig) + if (!configuration) { + return + } + addTelemetryConfiguration(serializeLogsConfiguration(initConfig)) + const startLogsResult = doStartLogs(configuration, sessionManager!, hooks) + bufferApiCalls.subscribe((callback) => callback(startLogsResult)) + bufferApiCalls.unbuffer() + } + function tryStartLogs() { if (!cachedConfiguration || !cachedInitConfiguration || !sessionManager) { return @@ -74,8 +85,6 @@ export function createPreStartStrategy( trackingConsentStateSubscription.unsubscribe() - let configurationToUse = cachedConfiguration - const hasRemoteConfiguration = getRemoteConfigurationId(cachedInitConfiguration) if (hasRemoteConfiguration) { @@ -86,12 +95,7 @@ export function createPreStartStrategy( void fetchAndApplyLogsRemoteConfiguration(cachedInitConfiguration) .then((resolvedInitConfig) => { if (resolvedInitConfig) { - const resolvedLogsConfig = validateAndBuildLogsConfiguration(resolvedInitConfig) - if (resolvedLogsConfig) { - const startLogsResult = doStartLogs(resolvedLogsConfig, sessionManager!, hooks) - bufferApiCalls.subscribe((callback) => callback(startLogsResult)) - bufferApiCalls.unbuffer() - } + doInit(resolvedInitConfig) } }) .catch(monitorError) @@ -102,16 +106,10 @@ export function createPreStartStrategy( if (!resolvedInitConfig) { return } - const resolvedLogsConfig = validateAndBuildLogsConfiguration(resolvedInitConfig) - if (resolvedLogsConfig) { - configurationToUse = resolvedLogsConfig - } + doInit(resolvedInitConfig) + } else { + doInit(cachedInitConfiguration) } - - const startLogsResult = doStartLogs(configurationToUse, sessionManager, hooks) - - bufferApiCalls.subscribe((callback) => callback(startLogsResult)) - bufferApiCalls.unbuffer() } return { @@ -159,7 +157,6 @@ export function createPreStartStrategy( } sessionManager = newSessionManager startTelemetrySessionContext(hooks.assembleTelemetry, sessionManager) - addTelemetryConfiguration(serializeLogsConfiguration(initConfiguration)) tryStartLogs() }) .catch(monitorError) From 913742dbfd2afd7bba9fd906b239b809c1e89f0e Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Tue, 28 Jul 2026 16:05:03 +0200 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=90=9B=20fix=20background=20sync=20di?= =?UTF-8?q?splay.error=20leaking=20into=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/browser-logs/src/boot/preStartLogs.spec.ts | 5 +++++ .../browser-logs/src/domain/remoteConfiguration.spec.ts | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/browser-logs/src/boot/preStartLogs.spec.ts b/packages/browser-logs/src/boot/preStartLogs.spec.ts index 2154133918..03ac888f34 100644 --- a/packages/browser-logs/src/boot/preStartLogs.spec.ts +++ b/packages/browser-logs/src/boot/preStartLogs.spec.ts @@ -10,6 +10,7 @@ import { replaceMockableWithSpy, createStartSessionManagerMock, registerCleanupTask, + interceptRequests, } from '@datadog/browser-core/test' import type { TrackingConsentState } from '@datadog/browser-core' import { @@ -259,6 +260,10 @@ describe('preStartLogs', () => { describe('remote configuration', () => { const RC_ID = 'test-rc-id' + beforeEach(() => { + interceptRequests().withFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })) + }) + it('applies cached remote config overrides before starting', async () => { localStorage.setItem( buildCacheKey(RC_ID), diff --git a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts index 8307c2feca..9314a03a4b 100644 --- a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts +++ b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts @@ -63,8 +63,11 @@ describe('getLogsRemoteConfiguration', () => { } beforeEach(() => { - // Prevent background sync from firing real network requests - interceptRequests() + // Mock background sync fetch to fail so cache.write is never called and + // the localStorage state stays predictable across tests. + spyOn(display, 'error') + interceptRequests().withFetch(() => Promise.resolve({ ok: false, status: 404 })) + registerCleanupTask(() => localStorage.removeItem(buildCacheKey(RC_ID))) }) it('returns the initConfiguration with remote overrides applied on cache hit', () => { @@ -76,7 +79,6 @@ describe('getLogsRemoteConfiguration', () => { fetchedAt: Date.now(), }) ) - registerCleanupTask(() => localStorage.removeItem(buildCacheKey(RC_ID))) const result = getLogsRemoteConfiguration(initConfiguration) expect(result!.forwardErrorsToLogs).toBeFalse() From f58a48f9da096ee35e4305d4ae53d4a39b755bc0 Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Wed, 29 Jul 2026 10:45:48 +0200 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=90=9B=20use=20mockable=20to=20preven?= =?UTF-8?q?t=20background=20sync=20from=20leaking=20into=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/browser-logs/src/boot/preStartLogs.spec.ts | 4 ++-- .../src/domain/remoteConfiguration.spec.ts | 12 ++++++------ .../browser-logs/src/domain/remoteConfiguration.ts | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/browser-logs/src/boot/preStartLogs.spec.ts b/packages/browser-logs/src/boot/preStartLogs.spec.ts index 03ac888f34..bbda0100a3 100644 --- a/packages/browser-logs/src/boot/preStartLogs.spec.ts +++ b/packages/browser-logs/src/boot/preStartLogs.spec.ts @@ -10,13 +10,13 @@ import { replaceMockableWithSpy, createStartSessionManagerMock, registerCleanupTask, - interceptRequests, } from '@datadog/browser-core/test' import type { TrackingConsentState } from '@datadog/browser-core' import { TrackingConsent, createTrackingConsentState, display, + fetchRemoteConfiguration, startTelemetry, startSessionManager, CACHE_VERSION, @@ -261,7 +261,7 @@ describe('preStartLogs', () => { const RC_ID = 'test-rc-id' beforeEach(() => { - interceptRequests().withFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })) + replaceMockableWithSpy(fetchRemoteConfiguration).and.returnValue(new Promise(() => {})) }) it('applies cached remote config overrides before starting', async () => { diff --git a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts index 9314a03a4b..f2b307a094 100644 --- a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts +++ b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts @@ -1,5 +1,5 @@ -import { CACHE_VERSION, buildCacheKey, display } from '@datadog/browser-core' -import { interceptRequests, registerCleanupTask } from '@datadog/browser-core/test' +import { CACHE_VERSION, buildCacheKey, display, fetchRemoteConfiguration } from '@datadog/browser-core' +import { interceptRequests, registerCleanupTask, replaceMockableWithSpy } from '@datadog/browser-core/test' import type { LogsInitConfiguration } from './configuration' import { applyLogsRemoteConfiguration, @@ -63,10 +63,10 @@ describe('getLogsRemoteConfiguration', () => { } beforeEach(() => { - // Mock background sync fetch to fail so cache.write is never called and - // the localStorage state stays predictable across tests. - spyOn(display, 'error') - interceptRequests().withFetch(() => Promise.resolve({ ok: false, status: 404 })) + // Prevent background sync from firing so localStorage state stays predictable across tests + // Return a never-resolving Promise so the background sync .then() never fires, + // preventing display.error or cache.write from being called after test cleanup. + replaceMockableWithSpy(fetchRemoteConfiguration).and.returnValue(new Promise(() => {})) registerCleanupTask(() => localStorage.removeItem(buildCacheKey(RC_ID))) }) diff --git a/packages/browser-logs/src/domain/remoteConfiguration.ts b/packages/browser-logs/src/domain/remoteConfiguration.ts index c7e22e0150..3249a841e2 100644 --- a/packages/browser-logs/src/domain/remoteConfiguration.ts +++ b/packages/browser-logs/src/domain/remoteConfiguration.ts @@ -4,6 +4,7 @@ import { display, fetchRemoteConfiguration, getRemoteConfigurationId, + mockable, monitorError, } from '@datadog/browser-core' import type { LogsInitConfiguration } from './configuration' @@ -41,7 +42,7 @@ export function getLogsRemoteConfiguration( const cacheResult = cache.read() // Background sync — update the cache for the next page load - fetchRemoteConfiguration(initConfiguration) + mockable(fetchRemoteConfiguration)(initConfiguration) .then((fetchResult) => { if (!fetchResult.ok) { display.error(fetchResult.error) From cf70e66de2c1e93a6267f4b1ee0529d76462dee9 Mon Sep 17 00:00:00 2001 From: Adrian de la Rosa Date: Wed, 29 Jul 2026 11:35:37 +0200 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=90=9B=20fix=20lint=20error=20in=20ne?= =?UTF-8?q?ver-resolving=20Promise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/browser-logs/src/boot/preStartLogs.spec.ts | 2 +- packages/browser-logs/src/domain/remoteConfiguration.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser-logs/src/boot/preStartLogs.spec.ts b/packages/browser-logs/src/boot/preStartLogs.spec.ts index bbda0100a3..5d1293709c 100644 --- a/packages/browser-logs/src/boot/preStartLogs.spec.ts +++ b/packages/browser-logs/src/boot/preStartLogs.spec.ts @@ -261,7 +261,7 @@ describe('preStartLogs', () => { const RC_ID = 'test-rc-id' beforeEach(() => { - replaceMockableWithSpy(fetchRemoteConfiguration).and.returnValue(new Promise(() => {})) + replaceMockableWithSpy(fetchRemoteConfiguration).and.returnValue(new Promise((_resolve) => undefined)) }) it('applies cached remote config overrides before starting', async () => { diff --git a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts index f2b307a094..3242a8e105 100644 --- a/packages/browser-logs/src/domain/remoteConfiguration.spec.ts +++ b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts @@ -66,7 +66,7 @@ describe('getLogsRemoteConfiguration', () => { // Prevent background sync from firing so localStorage state stays predictable across tests // Return a never-resolving Promise so the background sync .then() never fires, // preventing display.error or cache.write from being called after test cleanup. - replaceMockableWithSpy(fetchRemoteConfiguration).and.returnValue(new Promise(() => {})) + replaceMockableWithSpy(fetchRemoteConfiguration).and.returnValue(new Promise((_resolve) => undefined)) registerCleanupTask(() => localStorage.removeItem(buildCacheKey(RC_ID))) })