Skip to content
Draft

✨ ssi #4001

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions packages/remote-configuration/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
49 changes: 49 additions & 0 deletions packages/remote-configuration/src/domain/remote/fetch.ts
Original file line number Diff line number Diff line change
@@ -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<RemoteConfiguration> {
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 }
15 changes: 15 additions & 0 deletions packages/remote-configuration/src/domain/remote/index.ts
Original file line number Diff line number Diff line change
@@ -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<InitConfiguration> {
const remote = await fetch(options)

return process(remote)
}

export { remoteConfiguration }
export type { Options as RemoteConfigurationOptions }
190 changes: 190 additions & 0 deletions packages/remote-configuration/src/domain/remote/process.ts
Original file line number Diff line number Diff line change
@@ -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 any[]> = T extends [infer Only]
? Only
: T extends [infer First, infer Second, ...infer Rest]
? XOR<[XORHelper<First, Second>, ...Rest]>
: never

// Helper: XOR for two types
type XORHelper<T, U> =
| (T & { [K in Exclude<keyof U, keyof T>]?: never })
| (U & { [K in Exclude<keyof T, keyof U>]?: 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<O extends Record<string, unknown>, 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<O extends SerializedCookieStrategy>(option: O) {
return getCookie(option.name)
}

function resolveDOM<O extends SerializedDOMStrategy>(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<O extends SerializedJSStrategy>(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<string, unknown>
},
window as unknown as Record<string, unknown>
)
} 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 }
11 changes: 11 additions & 0 deletions packages/remote-configuration/src/entries/main.ts
Original file line number Diff line number Diff line change
@@ -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'
3 changes: 2 additions & 1 deletion packages/rum-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 2 additions & 17 deletions packages/rum-core/src/boot/preStartRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
1 change: 0 additions & 1 deletion packages/rum-core/src/domain/configuration/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export * from './configuration'
export * from './remoteConfiguration'
Loading