diff --git a/package.json b/package.json index 4bd64bc278..175f326b1d 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@types/cors": "2.8.19", "@types/express": "5.0.5", "@types/jasmine": "3.10.18", - "@types/node": "24.10.0", + "@types/node": "24.10.1", "@types/node-forge": "1.3.14", "ajv": "8.17.1", "browserstack-local": "1.5.8", diff --git a/packages/remote-configuration/package.json b/packages/remote-configuration/package.json new file mode 100644 index 0000000000..aa1fa7e4bf --- /dev/null +++ b/packages/remote-configuration/package.json @@ -0,0 +1,26 @@ +{ + "name": "@datadog/browser-remote-configuration", + "version": "6.24.1", + "license": "Apache-2.0", + "main": "cjs/entries/main.js", + "module": "esm/entries/main.js", + "types": "cjs/entries/main.d.ts", + "scripts": { + "build": "node ../../scripts/build/build-package.ts --modules --bundle datadog-remote-configuration.js", + "build:bundle": "node ../../scripts/build/build-package.ts --bundle datadog-remote-configuration.js" + }, + "dependencies": { + "@datadog/browser-core": "6.24.1" + }, + "repository": { + "type": "git", + "url": "https://github.com/DataDog/browser-sdk.git", + "directory": "packages/remote-configuration" + }, + "volta": { + "extends": "../../package.json" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/rum-core/src/domain/configuration/jsonPathParser.ts b/packages/remote-configuration/src/domain/jsonPathParser.ts similarity index 100% rename from packages/rum-core/src/domain/configuration/jsonPathParser.ts rename to packages/remote-configuration/src/domain/jsonPathParser.ts diff --git a/packages/remote-configuration/src/domain/remote/fetch.ts b/packages/remote-configuration/src/domain/remote/fetch.ts new file mode 100644 index 0000000000..50b157e533 --- /dev/null +++ b/packages/remote-configuration/src/domain/remote/fetch.ts @@ -0,0 +1,49 @@ +import { display } from '@datadog/browser-core' +import type { Site } from '@datadog/browser-core' + +import type { RemoteConfiguration } from './process' + +interface Options { + id: string + proxy?: string + site?: Site + version?: string +} + +function buildEndpoint(options: Options) { + if (options.proxy) { + return options.proxy + } + + const { id, site = 'datadoghq.com', version = 'v1' } = options + + const lastBit = site.lastIndexOf('.') + const domain = site.slice(0, lastBit).replace(/[.]/g, '-') + site.slice(lastBit) + + return `https://sdk-configuration.browser-intake-${domain}/${version}/${encodeURIComponent(id)}.json` +} + +async function fetch(options: Options): Promise { + const endpoint = buildEndpoint(options) + + try { + const response = await globalThis.fetch(endpoint) + + if (!response.ok) { + throw new Error(`Status code ${response.statusText}`) + } + + // workaround for rum + const remote = (await response.json()) as { rum: RemoteConfiguration } + + return remote.rum + } catch (e) { + const message = `Error fetching remote configuration from ${endpoint}: ${e as Error}` + + display.error(message) + throw new Error(message) + } +} + +export { fetch } +export type { Options as FetchOptions } diff --git a/packages/remote-configuration/src/domain/remote/index.ts b/packages/remote-configuration/src/domain/remote/index.ts new file mode 100644 index 0000000000..b99e6eccd3 --- /dev/null +++ b/packages/remote-configuration/src/domain/remote/index.ts @@ -0,0 +1,15 @@ +import type { InitConfiguration } from '@datadog/browser-core' + +import type { FetchOptions } from './fetch' +import { fetch } from './fetch' +import { process } from './process' + +type Options = FetchOptions +async function remoteConfiguration(options: Options): Promise { + const remote = await fetch(options) + + return process(remote) +} + +export { remoteConfiguration } +export type { Options as RemoteConfigurationOptions } diff --git a/packages/remote-configuration/src/domain/remote/process.ts b/packages/remote-configuration/src/domain/remote/process.ts new file mode 100644 index 0000000000..0152496b22 --- /dev/null +++ b/packages/remote-configuration/src/domain/remote/process.ts @@ -0,0 +1,190 @@ +import type { InitConfiguration } from '@datadog/browser-core' +import { getCookie, display } from '@datadog/browser-core' + +import { parseJsonPath } from '../jsonPathParser' + +interface RemoteConfiguration extends Record<(typeof SUPPORTED_FIELDS)[number], any> {} + +// XOR for exactly one of n types +type XOR = T extends [infer Only] + ? Only + : T extends [infer First, infer Second, ...infer Rest] + ? XOR<[XORHelper, ...Rest]> + : never + +// Helper: XOR for two types +type XORHelper = + | (T & { [K in Exclude]?: never }) + | (U & { [K in Exclude]?: never }) + +interface SerializedCookieStrategy { + name: string + strategy: 'cookie' +} +interface SerializedDOMStrategy { + attribute?: string + selector: string + strategy: 'dom' +} +interface SerializedJSStrategy { + path: string + strategy: 'js' +} +type SerializedDynamic = { rcSerializedType: 'dynamic' } & SerializedExtractor & SerializedDynamicStrategy +type SerializedDynamicStrategy = XOR<[SerializedCookieStrategy, SerializedDOMStrategy, SerializedJSStrategy]> +interface SerializedExtractor { + extractor?: SerializedRegex +} +interface SerializedRegex { + rcSerializedType: 'regex' + value: string +} +interface SerializedString { + rcSerializedType: 'string' + value: string +} +type SerializedOption = SerializedString | SerializedRegex | SerializedDynamic + +const SUPPORTED_FIELDS = [ + 'allowedTracingUrls', + 'allowedTrackingOrigins', + 'applicationId', + 'clientToken', + 'defaultPrivacyLevel', + 'enablePrivacyForActionName', + 'env', + 'service', + 'sessionReplaySampleRate', + 'sessionSampleRate', + 'traceSampleRate', + 'trackSessionAcrossSubdomains', + 'version', +] as const + +function isForbiddenElementAttribute(element: Element, attribute: string) { + return element instanceof HTMLInputElement && element.getAttribute('type') === 'password' && attribute === 'value' +} + +function isObject(property: unknown): property is { [key: string]: unknown } { + return typeof property === 'object' && property !== null +} + +function isSerializedOption(value: object): value is SerializedOption { + return 'rcSerializedType' in value +} + +function mapValues, R>( + object: O, + fn: (value: O[keyof O]) => R +): { [K in keyof O]: R } { + const entries = Object.entries(object) as Array<[keyof O, O[keyof O]]> + + return Object.fromEntries(entries.map(([key, value]) => [key, fn(value)])) as { [K in keyof O]: R } +} + +function resolveCookie(option: O) { + return getCookie(option.name) +} + +function resolveDOM(option: O) { + const { attribute, selector } = option + + let element: Element | null = null + try { + element = document.querySelector(selector) + } catch { + display.error(`Invalid selector in the remote configuration: '${selector}'`) + } + + if (!element) { + return + } + + if (attribute && isForbiddenElementAttribute(element, attribute)) { + display.error(`Forbidden element selected by the remote configuration: '${selector}'`) + return + } + + return attribute !== undefined ? element.getAttribute(attribute) : element.textContent +} + +function resolveJS(option: O) { + const { path } = option + const keys = parseJsonPath(path) + + if (keys.length === 0) { + display.error(`Invalid JSON path in the remote configuration: '${path}'`) + return + } + + try { + return keys.reduce( + (current, key) => { + if (!(key in current)) { + throw new Error('Unknown key') + } + + return current[key] as Record + }, + window as unknown as Record + ) + } catch (error) { + display.error(`Error accessing: '${path}'`, error) + return + } +} + +function resolveDynamic(option: SerializedDynamic) { + const { strategy } = option + + switch (strategy) { + case 'cookie': + return () => resolveCookie(option) + + case 'dom': + return () => resolveDOM(option) + + case 'js': + return () => resolveJS(option) + } +} + +function resolve(property: unknown): any { + if (Array.isArray(property)) { + return property.map(resolve) + } + + if (isObject(property)) { + if (isSerializedOption(property)) { + const { rcSerializedType: type } = property + + switch (type) { + case 'string': + return property.value + + case 'regex': + try { + return new RegExp(property.value) + } catch { + display.error(`Invalid regex in the remote configuration: '${property.value}'`) + // Return a regex that never matches anything + return /(?!)/ + } + + case 'dynamic': + return resolveDynamic(property) + } + } + + return mapValues(property, resolve) + } + + return property +} + +function process(config: RemoteConfiguration): InitConfiguration { + return mapValues(config, resolve) +} + +export type { RemoteConfiguration } +export { process } diff --git a/packages/remote-configuration/src/entries/main.ts b/packages/remote-configuration/src/entries/main.ts new file mode 100644 index 0000000000..b61f703d1e --- /dev/null +++ b/packages/remote-configuration/src/entries/main.ts @@ -0,0 +1,11 @@ +/** + * Datadog Browser Remote Configuration SDK + * + * Enables dynamic configuration of Datadog browser SDKs from a remote endpoint. + * Supports cookie, DOM, and JavaScript path-based value resolution strategies. + * + * @packageDocumentation + */ + +export type { RemoteConfigurationOptions } from '../domain/remote' +export { remoteConfiguration } from '../domain/remote' diff --git a/packages/rum-core/package.json b/packages/rum-core/package.json index 820b8447bf..5b0d3ef007 100644 --- a/packages/rum-core/package.json +++ b/packages/rum-core/package.json @@ -9,7 +9,8 @@ "build": "node ../../scripts/build/build-package.ts --modules" }, "dependencies": { - "@datadog/browser-core": "6.24.1" + "@datadog/browser-core": "6.24.1", + "@datadog/browser-remote-configuration": "6.24.1" }, "devDependencies": { "ajv": "8.17.1" diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 0ea06776e4..b2bb368568 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -16,15 +16,10 @@ import { buildAccountContextManager, buildGlobalContextManager, buildUserContextManager, - monitorError, sanitize, } from '@datadog/browser-core' import type { RumConfiguration, RumInitConfiguration } from '../domain/configuration' -import { - validateAndBuildRumConfiguration, - fetchAndApplyRemoteConfiguration, - serializeRumConfiguration, -} from '../domain/configuration' +import { validateAndBuildRumConfiguration, serializeRumConfiguration } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import type { DurationVital, @@ -192,17 +187,7 @@ export function createPreStartStrategy( callPluginsMethod(initConfiguration.plugins, 'onInit', { initConfiguration, publicApi }) - if (initConfiguration.remoteConfigurationId) { - fetchAndApplyRemoteConfiguration(initConfiguration, { user: userContext, context: globalContext }) - .then((initConfiguration) => { - if (initConfiguration) { - doInit(initConfiguration, errorStack) - } - }) - .catch(monitorError) - } else { - doInit(initConfiguration, errorStack) - } + doInit(initConfiguration, errorStack) }, get initConfiguration() { diff --git a/packages/rum-core/src/domain/configuration/index.ts b/packages/rum-core/src/domain/configuration/index.ts index 133c5671a2..126119df2b 100644 --- a/packages/rum-core/src/domain/configuration/index.ts +++ b/packages/rum-core/src/domain/configuration/index.ts @@ -1,2 +1 @@ export * from './configuration' -export * from './remoteConfiguration' diff --git a/packages/rum-core/src/domain/configuration/jsonPathParser.spec.ts b/packages/rum-core/src/domain/configuration/jsonPathParser.spec.ts deleted file mode 100644 index 636a0803e4..0000000000 --- a/packages/rum-core/src/domain/configuration/jsonPathParser.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { parseJsonPath } from './jsonPathParser' - -describe('parseJsonPath', () => { - it('should extract selectors from dot notation', () => { - expect(parseJsonPath('a')).toEqual(['a']) - expect(parseJsonPath('foo.bar')).toEqual(['foo', 'bar']) - expect(parseJsonPath('foo.bar.qux')).toEqual(['foo', 'bar', 'qux']) - }) - - it('should parse extract selectors from bracket notation', () => { - expect(parseJsonPath(String.raw`['a']`)).toEqual(['a']) - expect(parseJsonPath(String.raw`["a"]`)).toEqual(['a']) - expect(parseJsonPath(String.raw`['foo']["bar"]`)).toEqual(['foo', 'bar']) - expect(parseJsonPath(String.raw`['foo']["bar"]['qux']`)).toEqual(['foo', 'bar', 'qux']) - }) - - it('should extract selectors from mixed notations', () => { - expect(parseJsonPath(String.raw`['foo'].bar['qux']`)).toEqual(['foo', 'bar', 'qux']) - }) - - it('should extract name and index selectors', () => { - expect(parseJsonPath('[0]')).toEqual(['0']) - expect(parseJsonPath('foo[12]')).toEqual(['foo', '12']) - expect(parseJsonPath(String.raw`['foo'][12]`)).toEqual(['foo', '12']) - }) - - it('should extract name selectors replacing escaped sequence by equivalent character', () => { - expect(parseJsonPath(String.raw`['foo\n']`)).toEqual(['foo\n']) - expect(parseJsonPath(String.raw`['foo\b']`)).toEqual(['foo\b']) - expect(parseJsonPath(String.raw`['foo\t']`)).toEqual(['foo\t']) - expect(parseJsonPath(String.raw`['foo\f']`)).toEqual(['foo\f']) - expect(parseJsonPath(String.raw`['foo\r']`)).toEqual(['foo\r']) - expect(parseJsonPath(String.raw`["foo\u03A9"]`)).toEqual(['fooΩ']) - expect(parseJsonPath(String.raw`["\u03A9A"]`)).toEqual(['ΩA']) - expect(parseJsonPath(String.raw`["\t\u03A9\n"]`)).toEqual(['\tΩ\n']) - expect(parseJsonPath(String.raw`['foo\'']`)).toEqual([String.raw`foo'`]) - expect(parseJsonPath(String.raw`["foo\""]`)).toEqual([String.raw`foo"`]) - expect(parseJsonPath(String.raw`["foo\/"]`)).toEqual([String.raw`foo/`]) - }) - - it('should extract name selectors containing characters not supported in name shorthands', () => { - expect(parseJsonPath(String.raw`['foo[]']`)).toEqual([String.raw`foo[]`]) - expect(parseJsonPath(String.raw`['foo.']`)).toEqual([String.raw`foo.`]) - }) - - it('should return an empty array for an invalid path', () => { - expect(parseJsonPath('.foo')).toEqual([]) - expect(parseJsonPath('.')).toEqual([]) - expect(parseJsonPath('foo.')).toEqual([]) - expect(parseJsonPath('foo..bar')).toEqual([]) - expect(parseJsonPath('[1')).toEqual([]) - expect(parseJsonPath('foo]')).toEqual([]) - expect(parseJsonPath(String.raw`[['foo']`)).toEqual([]) - expect(parseJsonPath(String.raw`['foo'`)).toEqual([]) - expect(parseJsonPath(String.raw`['foo]`)).toEqual([]) - expect(parseJsonPath(String.raw`[foo']`)).toEqual([]) - expect(parseJsonPath(String.raw`['foo''bar']`)).toEqual([]) - expect(parseJsonPath(String.raw`['foo\o']`)).toEqual([]) - expect(parseJsonPath(String.raw`["\u03Z9"]`)).toEqual([]) - expect(parseJsonPath(String.raw`['foo\u12']`)).toEqual([]) - expect(parseJsonPath(String.raw`['foo']a`)).toEqual([]) - expect(parseJsonPath(String.raw`["foo']`)).toEqual([]) - }) -}) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts deleted file mode 100644 index e69006fc8a..0000000000 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ /dev/null @@ -1,681 +0,0 @@ -import { - DefaultPrivacyLevel, - INTAKE_SITE_US1, - display, - setCookie, - deleteCookie, - ONE_MINUTE, - createContextManager, -} from '@datadog/browser-core' -import { interceptRequests, registerCleanupTask } from '@datadog/browser-core/test' -import { appendElement } from '../../../test' -import type { RumInitConfiguration } from './configuration' -import { - type RumRemoteConfiguration, - initMetrics, - applyRemoteConfiguration, - buildEndpoint, - fetchRemoteConfiguration, -} from './remoteConfiguration' - -const DEFAULT_INIT_CONFIGURATION: RumInitConfiguration = { - clientToken: 'xxx', - applicationId: 'xxx', - service: 'xxx', - sessionReplaySampleRate: 100, - defaultPrivacyLevel: DefaultPrivacyLevel.MASK, -} - -describe('remoteConfiguration', () => { - describe('fetchRemoteConfiguration', () => { - const configuration = { remoteConfigurationId: 'xxx' } as RumInitConfiguration - let interceptor: ReturnType - - beforeEach(() => { - interceptor = interceptRequests() - }) - - it('should fetch the remote configuration', async () => { - interceptor.withFetch(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - rum: { - applicationId: 'xxx', - sessionSampleRate: 50, - sessionReplaySampleRate: 50, - defaultPrivacyLevel: 'allow', - }, - }), - }) - ) - - const fetchResult = await fetchRemoteConfiguration(configuration) - expect(fetchResult).toEqual({ - ok: true, - value: { - applicationId: 'xxx', - sessionSampleRate: 50, - sessionReplaySampleRate: 50, - defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, - }, - }) - }) - - it('should return an error if the fetching fails with a server error', async () => { - interceptor.withFetch(() => Promise.reject(new Error('Server error'))) - - const fetchResult = await fetchRemoteConfiguration(configuration) - expect(fetchResult).toEqual({ - ok: false, - error: new Error('Error fetching the remote configuration.'), - }) - }) - - it('should throw an error if the fetching fails with a client error', async () => { - interceptor.withFetch(() => - Promise.resolve({ - ok: false, - }) - ) - - const fetchResult = await fetchRemoteConfiguration(configuration) - expect(fetchResult).toEqual({ - ok: false, - error: new Error('Error fetching the remote configuration.'), - }) - }) - - it('should throw an error if the remote config does not contain rum config', async () => { - interceptor.withFetch(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), - }) - ) - - const fetchResult = await fetchRemoteConfiguration(configuration) - expect(fetchResult).toEqual({ - ok: false, - error: new Error('No remote configuration for RUM.'), - }) - }) - }) - - describe('applyRemoteConfiguration', () => { - const COOKIE_NAME = 'unit_rc' - const root = window as any - - let displaySpy: jasmine.Spy - let supportedContextManagers: { - user: ReturnType - context: ReturnType - } - let metrics: ReturnType - - function expectAppliedRemoteConfigurationToBe( - actual: Partial, - expected: Partial - ) { - const rumRemoteConfiguration: RumRemoteConfiguration = { - applicationId: 'yyy', - ...actual, - } - expect( - applyRemoteConfiguration(DEFAULT_INIT_CONFIGURATION, rumRemoteConfiguration, supportedContextManagers, metrics) - ).toEqual({ - ...DEFAULT_INIT_CONFIGURATION, - applicationId: 'yyy', - ...expected, - }) - } - - beforeEach(() => { - displaySpy = spyOn(display, 'error') - supportedContextManagers = { user: createContextManager(), context: createContextManager() } - metrics = initMetrics() - }) - - it('should override the initConfiguration options with the ones from the remote configuration', () => { - const rumRemoteConfiguration: RumRemoteConfiguration = { - applicationId: 'yyy', - sessionSampleRate: 1, - sessionReplaySampleRate: 1, - traceSampleRate: 1, - trackSessionAcrossSubdomains: true, - allowedTrackingOrigins: [ - { rcSerializedType: 'string', value: 'https://example.com' }, - { rcSerializedType: 'regex', value: '^https:\\/\\/app-\\w+\\.datadoghq\\.com' }, - ], - allowedTracingUrls: [ - { - match: { rcSerializedType: 'string', value: 'https://example.com' }, - propagatorTypes: ['b3', 'tracecontext'], - }, - { - match: { rcSerializedType: 'regex', value: '^https:\\/\\/app-\\w+\\.datadoghq\\.com' }, - propagatorTypes: ['datadog', 'b3multi'], - }, - ], - defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, - } - expect( - applyRemoteConfiguration(DEFAULT_INIT_CONFIGURATION, rumRemoteConfiguration, supportedContextManagers, metrics) - ).toEqual({ - applicationId: 'yyy', - clientToken: 'xxx', - service: 'xxx', - sessionSampleRate: 1, - sessionReplaySampleRate: 1, - traceSampleRate: 1, - trackSessionAcrossSubdomains: true, - allowedTrackingOrigins: ['https://example.com', /^https:\/\/app-\w+\.datadoghq\.com/], - allowedTracingUrls: [ - { match: 'https://example.com', propagatorTypes: ['b3', 'tracecontext'] }, - { match: /^https:\/\/app-\w+\.datadoghq\.com/, propagatorTypes: ['datadog', 'b3multi'] }, - ], - defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, - }) - }) - - it('should display an error if the remote config contains invalid regex', () => { - expectAppliedRemoteConfigurationToBe( - { allowedTrackingOrigins: [{ rcSerializedType: 'regex', value: 'Hello(?|!)' }] }, - { allowedTrackingOrigins: [undefined as any] } - ) - expect(displaySpy).toHaveBeenCalledWith("Invalid regex in the remote configuration: 'Hello(?|!)'") - }) - - it('should display an error if an unsupported `rcSerializedType` is provided', () => { - expectAppliedRemoteConfigurationToBe( - { allowedTrackingOrigins: [{ rcSerializedType: 'foo' as any, value: 'bar' }] }, - { allowedTrackingOrigins: [undefined as any] } - ) - expect(displaySpy).toHaveBeenCalledWith('Unsupported remote configuration: "rcSerializedType": "foo"') - }) - - it('should display an error if an unsupported `strategy` is provided', () => { - expectAppliedRemoteConfigurationToBe( - { version: { rcSerializedType: 'dynamic', strategy: 'foo' as any } as any }, - { version: undefined } - ) - expect(displaySpy).toHaveBeenCalledWith('Unsupported remote configuration: "strategy": "foo"') - }) - - describe('cookie strategy', () => { - it('should resolve a configuration value from a cookie', () => { - setCookie(COOKIE_NAME, 'my-version', ONE_MINUTE) - registerCleanupTask(() => deleteCookie(COOKIE_NAME)) - expectAppliedRemoteConfigurationToBe( - { version: { rcSerializedType: 'dynamic', strategy: 'cookie', name: COOKIE_NAME } }, - { version: 'my-version' } - ) - expect(metrics.get().cookie).toEqual({ success: 1 }) - }) - - it('should resolve to undefined if the cookie is missing', () => { - expectAppliedRemoteConfigurationToBe( - { version: { rcSerializedType: 'dynamic', strategy: 'cookie', name: COOKIE_NAME } }, - { version: undefined } - ) - expect(metrics.get().cookie).toEqual({ missing: 1 }) - }) - }) - - describe('dom strategy', () => { - beforeEach(() => { - appendElement(`
- version-123 - -
`) - }) - - it('should resolve a configuration value from an element text content', () => { - expectAppliedRemoteConfigurationToBe( - { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version1' } }, - { version: 'version-123' } - ) - expect(metrics.get().dom).toEqual({ success: 1 }) - }) - - it('should resolve a configuration value from an element text content and an extractor', () => { - expectAppliedRemoteConfigurationToBe( - { - version: { - rcSerializedType: 'dynamic', - strategy: 'dom', - selector: '#version1', - extractor: { rcSerializedType: 'regex', value: '\\d+' }, - }, - }, - { version: '123' } - ) - }) - - it('should resolve a configuration value from the first element matching the selector', () => { - expectAppliedRemoteConfigurationToBe( - { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '.version' } }, - { version: 'version-123' } - ) - }) - - it('should resolve to undefined and display an error if the selector is invalid', () => { - expectAppliedRemoteConfigurationToBe( - { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '' } }, - { version: undefined } - ) - expect(displaySpy).toHaveBeenCalledWith("Invalid selector in the remote configuration: ''") - expect(metrics.get().dom).toEqual({ failure: 1 }) - }) - - it('should resolve to undefined if the element is missing', () => { - expectAppliedRemoteConfigurationToBe( - { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#missing' } }, - { version: undefined } - ) - expect(metrics.get().dom).toEqual({ missing: 1 }) - }) - - it('should resolve a configuration value from an element attribute', () => { - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version2', attribute: 'data-version' }, - }, - { version: 'version-456' } - ) - }) - - it('should resolve to undefined if the element attribute is missing', () => { - expectAppliedRemoteConfigurationToBe( - { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version2', attribute: 'missing' } }, - { version: undefined } - ) - expect(metrics.get().dom).toEqual({ missing: 1 }) - }) - - it('should resolve to undefined if trying to access a password input value attribute', () => { - appendElement('') - expectAppliedRemoteConfigurationToBe( - { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#pwd', attribute: 'value' } }, - { version: undefined } - ) - expect(displaySpy).toHaveBeenCalledWith("Forbidden element selected by the remote configuration: '#pwd'") - }) - }) - - describe('js strategy', () => { - it('should resolve a value from a variable content', () => { - root.foo = 'bar' - registerCleanupTask(() => { - delete root.foo - }) - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo' }, - }, - { version: 'bar' } - ) - expect(metrics.get().js).toEqual({ success: 1 }) - }) - - it('should resolve a value from an object property', () => { - root.foo = { bar: { qux: '123' } } - registerCleanupTask(() => { - delete root.foo - }) - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo.bar.qux' }, - }, - { version: '123' } - ) - }) - - it('should resolve a string value with an extractor', () => { - root.foo = 'version-123' - registerCleanupTask(() => { - delete root.foo - }) - expectAppliedRemoteConfigurationToBe( - { - version: { - rcSerializedType: 'dynamic', - strategy: 'js', - path: 'foo', - extractor: { rcSerializedType: 'regex', value: '\\d+' }, - }, - }, - { version: '123' } - ) - }) - - it('should resolve to a non string value', () => { - root.foo = 23 - registerCleanupTask(() => { - delete root.foo - }) - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo' }, - }, - { version: 23 as any } - ) - }) - - it('should resolve a value from an object property containing an escapable character', () => { - root.foo = { 'bar\nqux': '123' } - registerCleanupTask(() => { - delete root.foo - }) - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'js', path: "foo['bar\\nqux']" }, - }, - { version: '123' } - ) - }) - - it('should not apply the extractor to a non string value', () => { - root.foo = 23 - registerCleanupTask(() => { - delete root.foo - }) - expectAppliedRemoteConfigurationToBe( - { - version: { - rcSerializedType: 'dynamic', - strategy: 'js', - path: 'foo', - extractor: { rcSerializedType: 'regex', value: '\\d+' }, - }, - }, - { version: 23 as any } - ) - }) - - it('should resolve a value from an array item', () => { - root.foo = { bar: [{ qux: '123' }] } - registerCleanupTask(() => { - delete root.foo - }) - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo.bar[0].qux' }, - }, - { version: '123' } - ) - }) - - it('should resolve to undefined and display an error if the JSON path is invalid', () => { - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'js', path: '.' }, - }, - { version: undefined } - ) - expect(displaySpy).toHaveBeenCalledWith("Invalid JSON path in the remote configuration: '.'") - expect(metrics.get().js).toEqual({ failure: 1 }) - }) - - it('should resolve to undefined and display an error if the variable access throws', () => { - root.foo = { - get bar() { - throw new Error('foo') - }, - } - registerCleanupTask(() => { - delete root.foo - }) - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo.bar' }, - }, - { version: undefined } - ) - expect(displaySpy).toHaveBeenCalledWith("Error accessing: 'foo.bar'", new Error('foo')) - expect(metrics.get().js).toEqual({ failure: 1 }) - }) - - it('should resolve to undefined if the variable does not exist', () => { - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'missing' }, - }, - { version: undefined } - ) - expect(metrics.get().js).toEqual({ missing: 1 }) - }) - - it('should resolve to undefined if the property does not exist', () => { - root.foo = {} - registerCleanupTask(() => { - delete root.foo - }) - - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo.missing' }, - }, - { version: undefined } - ) - expect(metrics.get().js).toEqual({ missing: 1 }) - }) - - it('should resolve to undefined if the array index does not exist', () => { - root.foo = [] - registerCleanupTask(() => { - delete root.foo - }) - - expectAppliedRemoteConfigurationToBe( - { - version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo[0]' }, - }, - { version: undefined } - ) - expect(metrics.get().js).toEqual({ missing: 1 }) - }) - }) - - describe('with extractor', () => { - beforeEach(() => { - setCookie(COOKIE_NAME, 'my-version-123', ONE_MINUTE) - }) - - afterEach(() => { - deleteCookie(COOKIE_NAME) - }) - - it('should resolve to the match on the value', () => { - expectAppliedRemoteConfigurationToBe( - { - version: { - rcSerializedType: 'dynamic', - strategy: 'cookie', - name: COOKIE_NAME, - extractor: { rcSerializedType: 'regex', value: '\\d+' }, - }, - }, - { version: '123' } - ) - }) - - it('should resolve to the capture group on the value', () => { - expectAppliedRemoteConfigurationToBe( - { - version: { - rcSerializedType: 'dynamic', - strategy: 'cookie', - name: COOKIE_NAME, - extractor: { rcSerializedType: 'regex', value: 'my-version-(\\d+)' }, - }, - }, - { version: '123' } - ) - }) - - it("should resolve to undefined if the value don't match", () => { - expectAppliedRemoteConfigurationToBe( - { - version: { - rcSerializedType: 'dynamic', - strategy: 'cookie', - name: COOKIE_NAME, - extractor: { rcSerializedType: 'regex', value: 'foo' }, - }, - }, - { version: undefined } - ) - }) - - it('should display an error if the extractor is not a valid regex', () => { - expectAppliedRemoteConfigurationToBe( - { - version: { - rcSerializedType: 'dynamic', - strategy: 'cookie', - name: COOKIE_NAME, - extractor: { rcSerializedType: 'regex', value: 'Hello(?|!)' }, - }, - }, - { version: undefined } - ) - expect(displaySpy).toHaveBeenCalledWith("Invalid regex in the remote configuration: 'Hello(?|!)'") - }) - }) - - describe('supported contexts', () => { - beforeEach(() => { - setCookie(COOKIE_NAME, 'first.second', ONE_MINUTE) - }) - - afterEach(() => { - deleteCookie(COOKIE_NAME) - }) - - it('should be resolved from the provided configuration', () => { - expectAppliedRemoteConfigurationToBe( - { - user: [ - { - key: 'id', - value: { - rcSerializedType: 'dynamic', - strategy: 'cookie', - name: COOKIE_NAME, - extractor: { rcSerializedType: 'regex', value: '(\\w+)\\.\\w+' }, - }, - }, - { - key: 'bar', - value: { - rcSerializedType: 'dynamic', - strategy: 'cookie', - name: COOKIE_NAME, - extractor: { rcSerializedType: 'regex', value: '\\w+\\.(\\w+)' }, - }, - }, - ], - }, - {} - ) - expect(supportedContextManagers.user.getContext()).toEqual({ - id: 'first', - bar: 'second', - }) - }) - - it('unresolved property should be set to undefined', () => { - expectAppliedRemoteConfigurationToBe( - { - context: [ - { - key: 'foo', - value: { - rcSerializedType: 'dynamic', - strategy: 'cookie', - name: 'missing-cookie', - }, - }, - ], - }, - {} - ) - expect(supportedContextManagers.context.getContext()).toEqual({ - foo: undefined, - }) - }) - }) - - describe('metrics', () => { - it('should report resolution stats', () => { - setCookie(COOKIE_NAME, 'my-version', ONE_MINUTE) - root.foo = '123' - registerCleanupTask(() => { - deleteCookie(COOKIE_NAME) - delete root.foo - }) - - expectAppliedRemoteConfigurationToBe( - { - context: [ - { - key: 'missing-cookie', - value: { - rcSerializedType: 'dynamic', - strategy: 'cookie', - name: 'missing-cookie', - }, - }, - { - key: 'existing-cookie', - value: { - rcSerializedType: 'dynamic', - strategy: 'cookie', - name: COOKIE_NAME, - }, - }, - { - key: 'existing-cookie2', - value: { - rcSerializedType: 'dynamic', - strategy: 'cookie', - name: COOKIE_NAME, - }, - }, - { - key: 'existing-js', - value: { - rcSerializedType: 'dynamic', - strategy: 'js', - path: 'foo', - }, - }, - ], - }, - {} - ) - expect(metrics.get()).toEqual( - jasmine.objectContaining({ - cookie: { success: 2, missing: 1 }, - js: { success: 1 }, - }) - ) - }) - }) - }) - - 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( - `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') - }) - }) -}) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts deleted file mode 100644 index 217ecfb2b0..0000000000 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ /dev/null @@ -1,304 +0,0 @@ -import type { createContextManager, Context } from '@datadog/browser-core' -import { - display, - buildEndpointHost, - mapValues, - getCookie, - addTelemetryMetrics, - TelemetryMetrics, -} from '@datadog/browser-core' -import type { RumInitConfiguration } from './configuration' -import type { RumSdkConfig, DynamicOption, ContextItem } from './remoteConfiguration.types' -import { parseJsonPath } from './jsonPathParser' - -export type RemoteConfiguration = RumSdkConfig -export type RumRemoteConfiguration = Exclude -const REMOTE_CONFIGURATION_VERSION = 'v1' -const SUPPORTED_FIELDS: Array = [ - 'applicationId', - 'service', - 'env', - 'version', - 'sessionSampleRate', - 'sessionReplaySampleRate', - 'defaultPrivacyLevel', - 'enablePrivacyForActionName', - 'traceSampleRate', - 'trackSessionAcrossSubdomains', - 'allowedTracingUrls', - 'allowedTrackingOrigins', -] - -// type needed for switch on union -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type SerializedRegex = { rcSerializedType: 'regex'; value: string } -type SerializedOption = { rcSerializedType: 'string'; value: string } | SerializedRegex | DynamicOption - -interface SupportedContextManagers { - user: ReturnType - context: ReturnType -} - -export interface RemoteConfigurationMetrics extends Context { - fetch: RemoteConfigurationMetricCounters - cookie?: RemoteConfigurationMetricCounters - dom?: RemoteConfigurationMetricCounters - js?: RemoteConfigurationMetricCounters -} - -interface RemoteConfigurationMetricCounters { - success?: number - missing?: number - failure?: number - [key: string]: number | undefined -} - -export async function fetchAndApplyRemoteConfiguration( - initConfiguration: RumInitConfiguration, - supportedContextManagers: SupportedContextManagers -) { - let rumInitConfiguration: RumInitConfiguration | undefined - const metrics = initMetrics() - const fetchResult = await fetchRemoteConfiguration(initConfiguration) - if (!fetchResult.ok) { - metrics.increment('fetch', 'failure') - display.error(fetchResult.error) - } else { - metrics.increment('fetch', 'success') - rumInitConfiguration = applyRemoteConfiguration( - initConfiguration, - fetchResult.value, - supportedContextManagers, - metrics - ) - } - // monitor-until: forever - addTelemetryMetrics(TelemetryMetrics.REMOTE_CONFIGURATION_METRIC_NAME, { metrics: metrics.get() }) - return rumInitConfiguration -} - -export function applyRemoteConfiguration( - initConfiguration: RumInitConfiguration, - rumRemoteConfiguration: RumRemoteConfiguration & { [key: string]: unknown }, - supportedContextManagers: SupportedContextManagers, - metrics: ReturnType -): RumInitConfiguration { - // intents: - // - explicitly set each supported field to limit risk in case an attacker can create configurations - // - check the existence in the remote config to avoid clearing a provided init field - const appliedConfiguration = { ...initConfiguration } as RumInitConfiguration & { [key: string]: unknown } - SUPPORTED_FIELDS.forEach((option: string) => { - if (option in rumRemoteConfiguration) { - appliedConfiguration[option] = resolveConfigurationProperty(rumRemoteConfiguration[option]) - } - }) - ;(Object.keys(supportedContextManagers) as Array).forEach((context) => { - if (rumRemoteConfiguration[context] !== undefined) { - resolveContextProperty(supportedContextManagers[context], rumRemoteConfiguration[context]) - } - }) - return appliedConfiguration - - // share context to access metrics - - function resolveConfigurationProperty(property: unknown): unknown { - if (Array.isArray(property)) { - return property.map(resolveConfigurationProperty) - } - if (isObject(property)) { - if (isSerializedOption(property)) { - const type = property.rcSerializedType - switch (type) { - case 'string': - return property.value - case 'regex': - return resolveRegex(property.value) - case 'dynamic': - return resolveDynamicOption(property) - default: - display.error(`Unsupported remote configuration: "rcSerializedType": "${type as string}"`) - return - } - } - return mapValues(property, resolveConfigurationProperty) - } - return property - } - - function resolveContextProperty( - contextManager: ReturnType, - contextItems: ContextItem[] - ) { - contextItems.forEach(({ key, value }) => { - contextManager.setContextProperty(key, resolveConfigurationProperty(value)) - }) - } - - function resolveDynamicOption(property: DynamicOption) { - const strategy = property.strategy - let resolvedValue: unknown - switch (strategy) { - case 'cookie': - resolvedValue = resolveCookieValue(property) - break - case 'dom': - resolvedValue = resolveDomValue(property) - break - case 'js': - resolvedValue = resolveJsValue(property) - break - default: - display.error(`Unsupported remote configuration: "strategy": "${strategy as string}"`) - return - } - const extractor = property.extractor - if (extractor !== undefined && typeof resolvedValue === 'string') { - return extractValue(extractor, resolvedValue) - } - return resolvedValue - } - - function resolveCookieValue({ name }: { name: string }) { - const value = getCookie(name) - metrics.increment('cookie', value !== undefined ? 'success' : 'missing') - return value - } - - function resolveDomValue({ selector, attribute }: { selector: string; attribute?: string }) { - let element: Element | null - try { - element = document.querySelector(selector) - } catch { - display.error(`Invalid selector in the remote configuration: '${selector}'`) - metrics.increment('dom', 'failure') - return - } - if (!element) { - metrics.increment('dom', 'missing') - return - } - if (isForbidden(element, attribute)) { - display.error(`Forbidden element selected by the remote configuration: '${selector}'`) - metrics.increment('dom', 'failure') - return - } - const domValue = attribute !== undefined ? element.getAttribute(attribute) : element.textContent - if (domValue === null) { - metrics.increment('dom', 'missing') - return - } - metrics.increment('dom', 'success') - return domValue - } - - function isForbidden(element: Element, attribute: string | undefined) { - return element.getAttribute('type') === 'password' && attribute === 'value' - } - - function resolveJsValue({ path }: { path: string }): unknown { - let current = window as unknown as { [key: string]: unknown } - const pathParts = parseJsonPath(path) - if (pathParts.length === 0) { - display.error(`Invalid JSON path in the remote configuration: '${path}'`) - metrics.increment('js', 'failure') - return - } - for (const pathPart of pathParts) { - if (!(pathPart in current)) { - metrics.increment('js', 'missing') - return - } - try { - current = current[pathPart] as { [key: string]: unknown } - } catch (e) { - display.error(`Error accessing: '${path}'`, e) - metrics.increment('js', 'failure') - return - } - } - metrics.increment('js', 'success') - return current - } -} - -export function initMetrics() { - const metrics: RemoteConfigurationMetrics = { fetch: {} } - return { - get: () => metrics, - increment: (metricName: 'fetch' | DynamicOption['strategy'], type: keyof RemoteConfigurationMetricCounters) => { - if (!metrics[metricName]) { - metrics[metricName] = {} - } - if (!metrics[metricName][type]) { - metrics[metricName][type] = 0 - } - metrics[metricName][type] = metrics[metricName][type] + 1 - }, - } -} - -function isObject(property: unknown): property is { [key: string]: unknown } { - return typeof property === 'object' && property !== null -} - -function isSerializedOption(value: object): value is SerializedOption { - return 'rcSerializedType' in value -} - -function resolveRegex(pattern: string): RegExp | undefined { - try { - return new RegExp(pattern) - } catch { - display.error(`Invalid regex in the remote configuration: '${pattern}'`) - } -} - -function extractValue(extractor: SerializedRegex, candidate: string) { - const resolvedExtractor = resolveRegex(extractor.value) - if (resolvedExtractor === undefined) { - return - } - const regexResult = resolvedExtractor.exec(candidate) - if (regexResult === null) { - return - } - const [match, capture] = regexResult - return capture ? capture : match -} - -type FetchRemoteConfigurationResult = { ok: true; value: RumRemoteConfiguration } | { 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 || !response.ok) { - return { - ok: false, - error: new Error('Error fetching the remote configuration.'), - } - } - const remoteConfiguration: RemoteConfiguration = await response.json() - if (remoteConfiguration.rum) { - return { - ok: true, - value: remoteConfiguration.rum, - } - } - return { - ok: false, - error: new Error('No remote configuration for RUM.'), - } -} - -export function buildEndpoint(configuration: RumInitConfiguration) { - if (configuration.remoteConfigurationProxy) { - return configuration.remoteConfigurationProxy - } - return `https://sdk-configuration.${buildEndpointHost('rum', configuration)}/${REMOTE_CONFIGURATION_VERSION}/${encodeURIComponent(configuration.remoteConfigurationId!)}.json` -} diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.types.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.types.ts deleted file mode 100644 index 176d25d6fd..0000000000 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.types.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* eslint-disable */ -/** - * DO NOT MODIFY IT BY HAND. Run `yarn json-schemas:sync` instead. - */ - -export type DynamicOption = - | { - rcSerializedType: 'dynamic' - strategy: 'js' - path: string - extractor?: SerializedRegex - [k: string]: unknown - } - | { - rcSerializedType: 'dynamic' - strategy: 'cookie' - name: string - extractor?: SerializedRegex - [k: string]: unknown - } - | { - rcSerializedType: 'dynamic' - strategy: 'dom' - selector: string - attribute?: string - extractor?: SerializedRegex - [k: string]: unknown - } - -/** - * RUM Browser & Mobile SDKs Remote Configuration properties - */ -export interface RumSdkConfig { - /** - * RUM feature Remote Configuration properties - */ - rum?: { - /** - * UUID of the application - */ - applicationId: string - /** - * The service name for this application - */ - service?: string - /** - * The environment for this application - */ - env?: string - /** - * The version for this application - */ - version?: - | { - rcSerializedType: 'dynamic' - strategy: 'js' - path: string - extractor?: SerializedRegex - [k: string]: unknown - } - | { - rcSerializedType: 'dynamic' - strategy: 'cookie' - name: string - extractor?: SerializedRegex - [k: string]: unknown - } - | { - rcSerializedType: 'dynamic' - strategy: 'dom' - selector: string - attribute?: string - extractor?: SerializedRegex - [k: string]: unknown - } - /** - * The percentage of sessions tracked - */ - sessionSampleRate?: number - /** - * The percentage of sessions with RUM & Session Replay pricing tracked - */ - sessionReplaySampleRate?: number - /** - * Session replay default privacy level - */ - defaultPrivacyLevel?: string - /** - * Privacy control for action name - */ - enablePrivacyForActionName?: boolean - /** - * URLs where tracing is allowed - */ - allowedTracingUrls?: { - match: MatchOption - /** - * List of propagator types - */ - propagatorTypes?: ('datadog' | 'b3' | 'b3multi' | 'tracecontext')[] | null - }[] - /** - * Origins where tracking is allowed - */ - allowedTrackingOrigins?: MatchOption[] - /** - * The percentage of traces sampled - */ - traceSampleRate?: number - /** - * Whether to track sessions across subdomains - */ - trackSessionAcrossSubdomains?: boolean - /** - * Function to define user information - */ - user?: ContextItem[] - /** - * Function to define global context - */ - context?: ContextItem[] - } -} -export interface SerializedRegex { - /** - * Remote config serialized type for regex extraction - */ - rcSerializedType: 'regex' - /** - * Regex pattern for value extraction - */ - value: string -} -export interface MatchOption { - /** - * Remote config serialized type of match - */ - rcSerializedType: 'string' | 'regex' - /** - * Match value - */ - value: string -} -export interface ContextItem { - key: string - value: DynamicOption -} diff --git a/packages/rum-core/src/index.ts b/packages/rum-core/src/index.ts index c00a6b0ff2..8fe320d175 100644 --- a/packages/rum-core/src/index.ts +++ b/packages/rum-core/src/index.ts @@ -42,12 +42,7 @@ export type { export type { ViewportDimension } from './browser/viewportObservable' export { initViewportObservable, getViewportDimension } from './browser/viewportObservable' export { getScrollX, getScrollY } from './browser/scroll' -export type { - RumInitConfiguration, - RumConfiguration, - FeatureFlagsForEvents, - RemoteConfiguration, -} from './domain/configuration' +export type { RumInitConfiguration, RumConfiguration, FeatureFlagsForEvents } from './domain/configuration' export { DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE } from './domain/action/actionNameConstants' export { STABLE_ATTRIBUTES } from './domain/getSelectorFromElement' export * from './browser/htmlDomUtils' diff --git a/packages/ssi/package.json b/packages/ssi/package.json new file mode 100644 index 0000000000..11e8f6b2df --- /dev/null +++ b/packages/ssi/package.json @@ -0,0 +1,31 @@ +{ + "name": "@datadog/ssi", + "version": "6.24.1", + "license": "Apache-2.0", + "main": "cjs/entries/main.js", + "module": "esm/entries/main.js", + "types": "cjs/entries/main.d.ts", + "scripts": { + "build": "node ../../scripts/build/build-package.ts --modules --bundle datadog-ssi.js", + "build:bundle": "node ../../scripts/build/build-package.ts --bundle datadog-ssi.js", + "dev": "node src/entries/main.ts" + }, + "dependencies": { + "@datadog/browser-core": "6.24.1", + "@datadog/browser-remote-configuration": "6.24.1" + }, + "devDependencies": { + "@types/node": "24.10.1" + }, + "repository": { + "type": "git", + "url": "https://github.com/DataDog/browser-sdk.git", + "directory": "packages/ssi" + }, + "volta": { + "extends": "../../package.json" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/ssi/src/domain/cdnUrlBuilder.ts b/packages/ssi/src/domain/cdnUrlBuilder.ts new file mode 100644 index 0000000000..fe70228f3d --- /dev/null +++ b/packages/ssi/src/domain/cdnUrlBuilder.ts @@ -0,0 +1,19 @@ +const SITE_TO_CDN_PREFIX: Record = { + 'datadoghq.com': 'us1', + 'us3.datadoghq.com': 'us3', + 'us5.datadoghq.com': 'us5', + 'datadoghq.eu': 'eu1', + 'ap1.datadoghq.com': 'ap1', + 'ddog-gov.com': '', +} + +export function buildCdnUrl(site: string, version: string = 'v6'): string { + const prefix = SITE_TO_CDN_PREFIX[site] || 'us1' + + if (site === 'ddog-gov.com') { + // US1-FED uses different pattern + return `https://www.datadoghq-browser-agent.com/datadog-rum-${version}.js` + } + + return `https://www.datadoghq-browser-agent.com/${prefix}/${version}/datadog-rum.js` +} diff --git a/packages/ssi/src/domain/remoteConfigFetcher.ts b/packages/ssi/src/domain/remoteConfigFetcher.ts new file mode 100644 index 0000000000..16fca41a57 --- /dev/null +++ b/packages/ssi/src/domain/remoteConfigFetcher.ts @@ -0,0 +1,35 @@ +export async function fetchCdnBundle(url: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10000) // 10 second timeout + + try { + const response = await fetch(url, { signal: controller.signal }) + + if (!response.ok) { + throw new Error(`Failed to fetch CDN bundle: ${response.status} ${response.statusText}`) + } + + return await response.text() + } catch (error) { + if (error instanceof Error) { + if (error.name === 'AbortError') { + throw new Error('CDN bundle fetch timed out after 10 seconds') + } + // Retry once after 1 second delay + await new Promise((resolve) => setTimeout(resolve, 1000)) + + try { + const retryResponse = await fetch(url) + if (!retryResponse.ok) { + throw new Error(`Failed to fetch CDN bundle on retry: ${retryResponse.status}`) + } + return await retryResponse.text() + } catch (retryError) { + throw new Error(`Failed to fetch CDN bundle after retry: ${error.message}`) + } + } + throw error + } finally { + clearTimeout(timeout) + } +} diff --git a/packages/ssi/src/domain/responseGenerator.ts b/packages/ssi/src/domain/responseGenerator.ts new file mode 100644 index 0000000000..93f9a1621e --- /dev/null +++ b/packages/ssi/src/domain/responseGenerator.ts @@ -0,0 +1,41 @@ +import { remoteConfiguration } from '@datadog/browser-remote-configuration' +import type { InitConfiguration } from '@datadog/browser-core' + +import { buildCdnUrl } from './cdnUrlBuilder.ts' +import { fetchCdnBundle } from './remoteConfigFetcher.ts' + +function generateInitCode(config: InitConfiguration): string { + // Serialize config to JavaScript, properly escaping values + const configJson = JSON.stringify({ ...config, clientToken: 'xxx', user: undefined, context: undefined }, null, 2) + + return ` +(function() { + if (typeof DD_RUM === 'undefined') { + console.error('[SSI] DD_RUM is not defined. Make sure the RUM SDK loaded correctly.'); + return; + } + + try { + DD_RUM._setDebug(true) + DD_RUM.init(${configJson}); + } catch (error) { + console.error('[SSI] Failed to initialize DD_RUM:', error); + } +})(); +` +} + +export async function generateResponse(configId: string): Promise { + // 1. Fetch remote config to get site and full configuration + const remoteConfig = await remoteConfiguration({ id: configId }) + const site = remoteConfig.site || 'datadoghq.com' + + // 2. Fetch RUM SDK bundle from CDN + const rumSdk = await fetchCdnBundle(buildCdnUrl(site, 'v6')) + + // 3. Generate initialization code with embedded config + const initCode = generateInitCode(remoteConfig) + + // 4. Concatenate SDK and init call + return [`import '${buildCdnUrl(site, 'v6')}';`, initCode].join('\n\n') +} diff --git a/packages/ssi/src/domain/server.ts b/packages/ssi/src/domain/server.ts new file mode 100644 index 0000000000..a83fbd2f51 --- /dev/null +++ b/packages/ssi/src/domain/server.ts @@ -0,0 +1,56 @@ +import * as http from 'node:http' +import * as url from 'node:url' + +import { generateResponse } from './responseGenerator.ts' + +export function createServer(): http.Server { + return http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => { + const parsedUrl = url.parse(req.url!, true) + + // Health check + if (parsedUrl.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('OK') + return + } + + // SSI endpoint + if (parsedUrl.pathname === '/ssi') { + const configId = parsedUrl.query.id as string + + if (!configId) { + res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.end('Missing required parameter: id') + return + } + + try { + const jsCode = await generateResponse(configId) + + res.writeHead(200, { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Cache-Control': 'public, max-age=3600', + 'Access-Control-Allow-Origin': '*', + }) + res.end(jsCode) + } catch (error) { + console.error('[SSI] Error generating response:', error) + res.writeHead(500, { 'Content-Type': 'text/plain' }) + res.end('Internal Server Error') + } + return + } + + if (parsedUrl.pathname?.startsWith('/chunks')) { + res.writeHead(301, { + Location: `https://www.datadoghq-browser-agent.com/us1/v6${parsedUrl.pathname}`, + }) + res.end() + return + } + + // 404 for all other routes + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not Found') + }) +} diff --git a/packages/ssi/src/domain/types.ts b/packages/ssi/src/domain/types.ts new file mode 100644 index 0000000000..81625a2688 --- /dev/null +++ b/packages/ssi/src/domain/types.ts @@ -0,0 +1,4 @@ +export interface RemoteConfig { + site?: string + [key: string]: unknown +} diff --git a/packages/ssi/src/entries/main.ts b/packages/ssi/src/entries/main.ts new file mode 100644 index 0000000000..256dd984fc --- /dev/null +++ b/packages/ssi/src/entries/main.ts @@ -0,0 +1,29 @@ +import { createServer } from '../domain/server.ts' + +const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000 +const HOST = process.env.HOST || '0.0.0.0' + +const server = createServer() + +server.listen(PORT, HOST, () => { + console.log(`[SSI] Server listening on http://${HOST}:${PORT}`) + console.log(`[SSI] Usage: http://${HOST}:${PORT}/ssi?id=`) + console.log(`[SSI] Health check: http://${HOST}:${PORT}/health`) +}) + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('[SSI] SIGTERM received, shutting down gracefully') + server.close(() => { + console.log('[SSI] Server closed') + process.exit(0) + }) +}) + +process.on('SIGINT', () => { + console.log('[SSI] SIGINT received, shutting down gracefully') + server.close(() => { + console.log('[SSI] Server closed') + process.exit(0) + }) +}) diff --git a/packages/ssi/tsconfig.json b/packages/ssi/tsconfig.json new file mode 100644 index 0000000000..0501267d22 --- /dev/null +++ b/packages/ssi/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "baseUrl": ".", + "declaration": true, + "rootDir": "./src/", + "types": ["node"], + "lib": ["ES2020"] + }, + "include": ["./src/**/*.ts"], + "exclude": ["./src/**/*.spec.ts", "./src/**/*.specHelper.ts"] +} \ No newline at end of file diff --git a/sandbox/index.html b/sandbox/index.html index 1e68157307..d4bf8cf23a 100644 --- a/sandbox/index.html +++ b/sandbox/index.html @@ -3,30 +3,31 @@ Sandbox - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/build/build-package.ts b/scripts/build/build-package.ts index 35de4985c5..48aceb239e 100644 --- a/scripts/build/build-package.ts +++ b/scripts/build/build-package.ts @@ -81,6 +81,25 @@ async function buildModules({ outDir, module, verbose }: { outDir: string; modul // TODO: in the future, consider building packages with something else than typescript (ex: // rspack, tsdown...) + // Read package-specific tsconfig.json if it exists + let packageCompilerOptions = {} + try { + const tsconfigPath = path.resolve(process.cwd(), 'tsconfig.json') + const tsconfigContent = await fs.readFile(tsconfigPath, 'utf-8') + const tsconfig = JSON.parse(tsconfigContent) + if (tsconfig.compilerOptions) { + // Extract options that should be merged (types, lib, etc.) + const { types, lib } = tsconfig.compilerOptions + packageCompilerOptions = { types, lib } + } + } catch { + // No package-specific tsconfig.json, continue with defaults + } + + // For Yarn workspaces, typeRoots needs to point to the root node_modules + const repoRoot = path.resolve(process.cwd(), '../..') + const typeRoots = [path.join(repoRoot, 'node_modules/@types')] + const diagnostics = buildWithTypeScript({ extends: '../../tsconfig.base.json', compilerOptions: { @@ -90,6 +109,8 @@ async function buildModules({ outDir, module, verbose }: { outDir: string; modul module, rootDir: './src/', outDir, + typeRoots, + ...packageCompilerOptions, }, include: ['./src'], exclude: ['./src/**/*.spec.*', './src/**/*.specHelper.*'], diff --git a/tsconfig.base.json b/tsconfig.base.json index cece26eb3d..81993c5124 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -22,7 +22,8 @@ "@datadog/browser-rum-react": ["./packages/rum-react/src/entries/main"], "@datadog/browser-rum-react/react-router-v6": ["./packages/rum-react/src/entries/reactRouterV6"], "@datadog/browser-rum-react/react-router-v7": ["./packages/rum-react/src/entries/reactRouterV7"], - "@datadog/browser-flagging": ["./packages/flagging/src/entries/main"] + "@datadog/browser-flagging": ["./packages/flagging/src/entries/main"], + "@datadog/browser-remote-config": ["./packages/remote-configuration/src/entries/main"] } } } diff --git a/yarn.lock b/yarn.lock index 5f2c511ac7..bab3272eaa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -249,11 +249,20 @@ __metadata: languageName: unknown linkType: soft +"@datadog/browser-remote-configuration@npm:6.24.1, @datadog/browser-remote-configuration@workspace:packages/remote-configuration": + version: 0.0.0-use.local + resolution: "@datadog/browser-remote-configuration@workspace:packages/remote-configuration" + dependencies: + "@datadog/browser-core": "npm:6.24.1" + languageName: unknown + linkType: soft + "@datadog/browser-rum-core@npm:6.24.1, @datadog/browser-rum-core@workspace:packages/rum-core": version: 0.0.0-use.local resolution: "@datadog/browser-rum-core@workspace:packages/rum-core" dependencies: "@datadog/browser-core": "npm:6.24.1" + "@datadog/browser-remote-configuration": "npm:6.24.1" ajv: "npm:8.17.1" languageName: unknown linkType: soft @@ -360,6 +369,16 @@ __metadata: languageName: unknown linkType: soft +"@datadog/ssi@workspace:packages/ssi": + version: 0.0.0-use.local + resolution: "@datadog/ssi@workspace:packages/ssi" + dependencies: + "@datadog/browser-core": "npm:6.24.1" + "@datadog/browser-remote-configuration": "npm:6.24.1" + "@types/node": "npm:24.10.1" + languageName: unknown + linkType: soft + "@discoveryjs/json-ext@npm:^0.6.1": version: 0.6.3 resolution: "@discoveryjs/json-ext@npm:0.6.3" @@ -2486,7 +2505,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:24.10.0, @types/node@npm:>=10.0.0": +"@types/node@npm:*, @types/node@npm:>=10.0.0": version: 24.10.0 resolution: "@types/node@npm:24.10.0" dependencies: @@ -2495,6 +2514,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:24.10.1": + version: 24.10.1 + resolution: "@types/node@npm:24.10.1" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/d6bca7a78f550fbb376f236f92b405d676003a8a09a1b411f55920ef34286ee3ee51f566203920e835478784df52662b5b2af89159d9d319352e9ea21801c002 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -3757,7 +3785,7 @@ __metadata: "@types/cors": "npm:2.8.19" "@types/express": "npm:5.0.5" "@types/jasmine": "npm:3.10.18" - "@types/node": "npm:24.10.0" + "@types/node": "npm:24.10.1" "@types/node-forge": "npm:1.3.14" ajv: "npm:8.17.1" browserstack-local: "npm:1.5.8"