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/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..cb9d44997f 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 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 { 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..ff6f585692 --- /dev/null +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.spec.ts @@ -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 + + 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).__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 new file mode 100644 index 0000000000..8e3dd194b1 --- /dev/null +++ b/packages/browser-core/src/domain/remoteConfiguration/remoteConfigurationFetch.ts @@ -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> +} + +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 +} + +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 function fetchRemoteConfiguration( + options: RemoteConfigurationEndpointOptions +): Promise { + 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 + } + }) + inflightFetches.set(endpoint, promise) + } + + return inflightFetches.get(endpoint)! +} + +async function doFetchRemoteConfiguration(endpoint: string): Promise { + 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.') } + } +} 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-logs/src/boot/preStartLogs.spec.ts b/packages/browser-logs/src/boot/preStartLogs.spec.ts index 4d3901f6ac..5d1293709c 100644 --- a/packages/browser-logs/src/boot/preStartLogs.spec.ts +++ b/packages/browser-logs/src/boot/preStartLogs.spec.ts @@ -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' @@ -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() diff --git a/packages/browser-logs/src/boot/preStartLogs.ts b/packages/browser-logs/src/boot/preStartLogs.ts index e61062986d..71dddf1d05 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' @@ -65,16 +67,49 @@ 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 } trackingConsentStateSubscription.unsubscribe() - const startLogsResult = doStartLogs(cachedConfiguration, sessionManager, hooks) - bufferApiCalls.subscribe((callback) => callback(startLogsResult)) - bufferApiCalls.unbuffer() + const hasRemoteConfiguration = getRemoteConfigurationId(cachedInitConfiguration) + + if (hasRemoteConfiguration) { + const isSyncLoading = + !!cachedInitConfiguration.remoteConfigurationId || !!cachedInitConfiguration.remoteConfiguration?.sync + + if (isSyncLoading) { + void fetchAndApplyLogsRemoteConfiguration(cachedInitConfiguration) + .then((resolvedInitConfig) => { + if (resolvedInitConfig) { + doInit(resolvedInitConfig) + } + }) + .catch(monitorError) + return + } + + const resolvedInitConfig = getLogsRemoteConfiguration(cachedInitConfiguration) + if (!resolvedInitConfig) { + return + } + doInit(resolvedInitConfig) + } else { + doInit(cachedInitConfiguration) + } } return { @@ -122,7 +157,6 @@ export function createPreStartStrategy( } sessionManager = newSessionManager startTelemetrySessionContext(hooks.assembleTelemetry, sessionManager) - addTelemetryConfiguration(serializeLogsConfiguration(initConfiguration)) tryStartLogs() }) .catch(monitorError) 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..8d2c998e5a 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,31 @@ export interface LogsInitConfiguration extends InitConfiguration { * @category Data Collection */ forwardReports?: RawReportType[] | 'all' | undefined + + /** + * 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. 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 + */ + remoteConfiguration?: { id: string; sync?: boolean; required?: boolean } | undefined + + /** + * Proxy URL for fetching the remote configuration. + * + * @category Remote Configuration + */ + remoteConfigurationProxy?: string | undefined } /** @@ -89,6 +115,7 @@ export interface LogsConfiguration extends Configuration { forwardConsoleLogs: ConsoleApiName[] forwardReports: RawReportType[] requestErrorResponseLengthLimit: number + remoteConfigurationId: string | undefined } /** @@ -123,6 +150,7 @@ export function validateAndBuildLogsConfiguration( forwardConsoleLogs, forwardReports, requestErrorResponseLengthLimit: DEFAULT_REQUEST_ERROR_RESPONSE_LENGTH_LIMIT, + remoteConfigurationId: getRemoteConfigurationId(initConfiguration), ...baseConfiguration, } } @@ -151,6 +179,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..3242a8e105 --- /dev/null +++ b/packages/browser-logs/src/domain/remoteConfiguration.spec.ts @@ -0,0 +1,129 @@ +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, + 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 }, + } + + beforeEach(() => { + // 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((_resolve) => undefined)) + registerCleanupTask(() => 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 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 new file mode 100644 index 0000000000..3249a841e2 --- /dev/null +++ b/packages/browser-logs/src/domain/remoteConfiguration.ts @@ -0,0 +1,75 @@ +import type { RemoteConfiguration } from '@datadog/browser-core' +import { + createConfigurationCache, + display, + fetchRemoteConfiguration, + getRemoteConfigurationId, + mockable, + 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 + mockable(fetchRemoteConfiguration)(initConfiguration) + .then((fetchResult) => { + if (!fetchResult.ok) { + display.error(fetchResult.error) + } else { + 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/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..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 } 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 +21,6 @@ import { fetchRemoteConfiguration, getRemoteConfiguration, } from './remoteConfiguration' -import { buildCacheKey, CACHE_VERSION } from './remoteConfigurationCache' const DEFAULT_INIT_CONFIGURATION: RumInitConfiguration = { clientToken: 'xxx', @@ -793,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 1137f288fc..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 } from '@datadog/browser-core' +import type { + createContextManager, + Context, + FetchRemoteConfigurationResult, + RemoteConfiguration, + DynamicOption, + ContextItem, +} from '@datadog/browser-core' import { display, mapValues, @@ -6,19 +13,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 +303,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 + const result = await fetchRemoteConfigurationCore(configuration) + if (!result.ok) { + return result } - 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, - } + if (result.value.rum || result.value.profiling) { + return result } - return { - ok: false, - error: new Error('No remote configuration for RUM.'), - } -} - -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 +342,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'), }, ] 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`` +} 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, + }) +}