Skip to content
Draft
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
135 changes: 135 additions & 0 deletions packages/endpoint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# @datadog/browser-sdk-endpoint

Node.js package for generating self-contained Datadog Browser SDK bundles with embedded remote configuration. Designed for CI/CD pipelines, SSR frameworks, and custom build tooling.

## Installation

```bash
npm install @datadog/browser-sdk-endpoint
```

## Usage

### Generate a bundle (CI / build script)

Fetch remote configuration, download the SDK from CDN, and produce a single self-executing IIFE in one call:

```typescript
import { generateBundle } from '@datadog/browser-sdk-endpoint'
import { writeFileSync } from 'node:fs'

const bundle = await generateBundle({
applicationId: 'your-app-id',
remoteConfigurationId: 'your-config-id',
variant: 'rum', // 'rum' | 'rum-slim' | 'logs' | 'rum-and-logs'
site: 'datadoghq.com', // optional, defaults to datadoghq.com
})

writeFileSync('public/datadog-sdk.js', bundle)
```

Then in your HTML, set `window.__DD_BASE_CONFIG__` with the fields that belong to the page (not the remote config) before loading the bundle:

```html
<script>
window.__DD_BASE_CONFIG__ = {
clientToken: 'your-client-token',
site: 'datadoghq.com'
}
</script>
<script src="/datadog-sdk.js"></script>
```

The bundle merges `__DD_BASE_CONFIG__` with the embedded remote configuration and calls `DD_RUM.init()` automatically — no additional `init()` call needed in your app code.

### SSR: inject configuration at render time

If you use server-side rendering and want to avoid a separate bundle generation step, you can fetch the remote configuration per request, serialize it to an inline JS expression, and inject it into the HTML response. The SDK is loaded separately; only the configuration is embedded.

```typescript
import { fetchRemoteConfiguration } from '@datadog/browser-remote-config'
import { resolveDynamicValues, serializeConfigToJs } from '@datadog/browser-remote-config/node'

// In your SSR handler (Express, Next.js getServerSideProps, etc.)
const result = await fetchRemoteConfiguration({
applicationId: 'your-app-id',
remoteConfigurationId: 'your-config-id',
})

if (result.ok) {
const configJs = serializeConfigToJs(resolveDynamicValues(result.value))

// Inject into <head> before the SDK script tag
// configJs is a JS object literal — dynamic values (cookies, DOM, window.*) are
// serialized as inline expressions that evaluate in the browser at page load time
html += `<script>window.__DD_RC_CONFIG__ = ${configJs}</script>`
}
```

Then in your client-side code:

```typescript
import { datadogRum } from '@datadog/browser-rum'

datadogRum.init({
applicationId: 'your-app-id',
clientToken: 'your-client-token',
site: 'datadoghq.com',
...window.__DD_RC_CONFIG__
})
```

`resolveDynamicValues` from the Node entry point serializes `DynamicOption` fields (cookie, JS path, DOM, localStorage) as inline JS expressions rather than evaluating them on the server. Those expressions run against live browser APIs when the `<script>` tag is parsed, so dynamic values like a cookie-based user ID are resolved at the right time.

### Build blocks (advanced)

Use `fetchConfig`, `generateCombinedBundle`, and `downloadSDK` separately for custom pipelines:

```typescript
import { fetchConfig, generateCombinedBundle } from '@datadog/browser-sdk-endpoint'
import { resolveDynamicValues, serializeConfigToJs } from '@datadog/browser-remote-config/node'
import { downloadSDK } from '@datadog/browser-sdk-endpoint'

const configResult = await fetchConfig({
applicationId: 'your-app-id',
remoteConfigurationId: 'your-config-id',
})

const configJs = serializeConfigToJs(resolveDynamicValues(configResult.value))
const sdkCode = await downloadSDK({ variant: 'rum' })

const bundle = generateCombinedBundle({ sdkCode, configJs, variant: 'rum' })
```

## API

### `generateBundle(options)`

Fetches remote configuration, downloads the SDK from CDN, and returns a combined bundle string.

- `applicationId` (string, required)
- `remoteConfigurationId` (string, required)
- `variant` (`'rum' | 'rum-slim' | 'logs' | 'rum-and-logs'`, required)
- `site` (string, optional) — Datadog site, e.g. `'datadoghq.eu'`
- `datacenter` (string, optional) — CDN datacenter, e.g. `'us1'`

### `fetchConfig(options)`

Fetches and validates remote configuration. Throws on network error or invalid config ID.

- `applicationId`, `remoteConfigurationId`, `site` — same as above

### `generateCombinedBundle(options)`

Assembles a self-executing IIFE from pre-fetched parts.

- `sdkCode` (string) — SDK source downloaded from CDN
- `configJs` (string) — serialized config from `serializeConfigToJs`
- `variant` — SDK variant label embedded in the bundle comment
- `sdkVersion` (string, optional) — version label embedded in the bundle comment

### `downloadSDK(options)`

Downloads the SDK bundle from the Datadog CDN. Results are cached in memory per variant + datacenter.

- `variant`, `datacenter`, `version` (optional — defaults to the package version)
16 changes: 16 additions & 0 deletions packages/endpoint/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@datadog/browser-sdk-endpoint",
"version": "6.28.0",
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"engines": {
"node": ">=18"
},
"dependencies": {
"@datadog/browser-remote-config": "6.28.0"
},
"publishConfig": {
"access": "public"
}
}
99 changes: 99 additions & 0 deletions packages/endpoint/src/bundleGenerator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { generateCombinedBundle } from './bundleGenerator.ts'
import { INLINE_HELPERS } from './helpers.ts'

const mockSdkCode = 'window.DD_RUM = { init: function(c) { this.config = c; } };'

describe('generateCombinedBundle', () => {
it('wraps output in an IIFE', () => {
const bundle = generateCombinedBundle({
sdkCode: mockSdkCode,
configJs: '{ "sessionSampleRate": 24 }',
variant: 'rum',
})
assert.ok(bundle.includes('(function() {'), 'Should contain IIFE start')
assert.ok(bundle.includes('})();'), 'Should contain IIFE end')
assert.ok(bundle.includes("'use strict';"), 'Should include use strict')
})

it('embeds configJs verbatim without re-serializing', () => {
const configJs = '{ "sessionSampleRate": 24, "user": { id: __dd_getJs(\'window.user\') } }'
const bundle = generateCombinedBundle({ sdkCode: mockSdkCode, configJs, variant: 'rum' })
assert.ok(bundle.includes(configJs), 'Should embed configJs verbatim')
})

it('always includes all six inline helpers', () => {
const bundle = generateCombinedBundle({
sdkCode: mockSdkCode,
configJs: '{ "sessionSampleRate": 100 }',
variant: 'rum',
})
assert.ok(bundle.includes('__dd_getCookie'), 'Should include cookie helper')
assert.ok(bundle.includes('__dd_getJs'), 'Should include JS helper')
assert.ok(bundle.includes('__dd_getDomText'), 'Should include DOM text helper')
assert.ok(bundle.includes('__dd_getDomAttr'), 'Should include DOM attr helper')
assert.ok(bundle.includes('__dd_getLocalStorage'), 'Should include localStorage helper')
assert.ok(bundle.includes('__dd_extract'), 'Should include extract helper')
})

it('includes variant in header comment', () => {
const bundle = generateCombinedBundle({
sdkCode: mockSdkCode,
configJs: '{}',
variant: 'rum-slim',
})
assert.ok(bundle.includes('SDK Variant: rum-slim'), 'Should include variant in header')
})

it('includes SDK version in header when provided', () => {
const bundle = generateCombinedBundle({
sdkCode: mockSdkCode,
configJs: '{}',
variant: 'rum',
sdkVersion: '6.28.0',
})
assert.ok(bundle.includes('SDK Version: 6.28.0'), 'Should include version in header')
})

it('calls DD_RUM.init with the embedded config variable', () => {
const bundle = generateCombinedBundle({
sdkCode: mockSdkCode,
configJs: '{ "sessionSampleRate": 50 }',
variant: 'rum',
})
assert.ok(bundle.includes('window.DD_RUM.init(__DATADOG_REMOTE_CONFIG__)'), 'Should call DD_RUM.init')
})

it('a config with only static values contains no helper calls in the config section', () => {
const configJs = '{ "sessionSampleRate": 24, "env": "prod" }'
const bundle = generateCombinedBundle({ sdkCode: mockSdkCode, configJs, variant: 'rum' })
// helpers are present as function definitions but not called from the config
const configSection = bundle.split('var __DATADOG_REMOTE_CONFIG__')[1].split(';')[0]
assert.ok(!configSection.includes('__dd_getCookie('), 'Static config should not call cookie helper')
assert.ok(!configSection.includes('__dd_getJs('), 'Static config should not call JS helper')
})

it('a config with dynamic values embeds helper calls in the config section', () => {
const configJs = '{ "user": { id: __dd_getJs(\'window.user\') } }'
const bundle = generateCombinedBundle({ sdkCode: mockSdkCode, configJs, variant: 'rum' })
const configSection = bundle.split('var __DATADOG_REMOTE_CONFIG__')[1].split(';')[0]
assert.ok(configSection.includes("__dd_getJs('window.user')"), 'Dynamic config should call JS helper')
})
})

describe('INLINE_HELPERS', () => {
it('defines all six helper functions', () => {
assert.ok(INLINE_HELPERS.includes('function __dd_getCookie'), 'Should define getCookie')
assert.ok(INLINE_HELPERS.includes('function __dd_getJs'), 'Should define getJs')
assert.ok(INLINE_HELPERS.includes('function __dd_getDomText'), 'Should define getDomText')
assert.ok(INLINE_HELPERS.includes('function __dd_getDomAttr'), 'Should define getDomAttr')
assert.ok(INLINE_HELPERS.includes('function __dd_getLocalStorage'), 'Should define getLocalStorage')
assert.ok(INLINE_HELPERS.includes('function __dd_extract'), 'Should define extract')
})

it('is valid JavaScript', () => {
// Wrapping in a function and parsing via Function constructor validates syntax
assert.doesNotThrow(() => new Function(INLINE_HELPERS), 'INLINE_HELPERS should be valid JS')
})
})
106 changes: 106 additions & 0 deletions packages/endpoint/src/bundleGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { SdkVariant } from './sdkDownloader.ts'
import { getDefaultVersion, downloadSDK } from './sdkDownloader.ts'
import { INLINE_HELPERS } from './helpers.ts'
import { fetchRemoteConfiguration } from '@datadog/browser-remote-config'
import { resolveDynamicValues, serializeConfigToJs } from '@datadog/browser-remote-config/node'

export type { SdkVariant } from './sdkDownloader.ts'

export interface FetchConfigOptions {
applicationId: string
remoteConfigurationId: string
site?: string
}

export interface CombineBundleOptions {
sdkCode: string
configJs: string
variant: SdkVariant
sdkVersion?: string
}

export interface GenerateBundleOptions {
applicationId: string
remoteConfigurationId: string
variant: SdkVariant
site?: string
datacenter?: string
}

const CONFIG_FETCH_TIMEOUT_MS = 30_000
const VALID_VARIANTS: SdkVariant[] = ['rum', 'rum-slim', 'logs', 'rum-and-logs']

export async function fetchConfig(options: FetchConfigOptions) {
const result = await fetchRemoteConfiguration({
applicationId: options.applicationId,
remoteConfigurationId: options.remoteConfigurationId,
site: options.site,
signal: AbortSignal.timeout(CONFIG_FETCH_TIMEOUT_MS),
})

if (!result.ok) {
throw new Error(
`Failed to fetch remote configuration: ${result.error.message}\n` +
`Verify applicationId "${options.applicationId}" and ` +
`configId "${options.remoteConfigurationId}" are correct.`
)
}

return { ok: true as const, value: result.value }
}

export function generateCombinedBundle(options: CombineBundleOptions): string {
const { sdkCode, configJs, variant, sdkVersion } = options
const versionDisplay = sdkVersion ?? 'unknown'

return `/**
* Datadog Browser SDK with Embedded Remote Configuration
* SDK Variant: ${variant}
* SDK Version: ${versionDisplay}
*/
(function() {
'use strict';

// Inline helpers for dynamic value resolution
${INLINE_HELPERS}

// Embedded remote configuration
var __DATADOG_REMOTE_CONFIG__ = ${configJs};

// SDK bundle (${variant}) from CDN
${sdkCode}

// Auto-initialize — merge base config (clientToken, site, etc.) set by the page before this script
var __DD_CONFIG__ = Object.assign({}, window.__DD_BASE_CONFIG__ || {}, __DATADOG_REMOTE_CONFIG__);
if (typeof window !== 'undefined') {
if (window.DD_RUM) { window.DD_RUM.init(__DD_CONFIG__); }
if (window.DD_LOGS) { window.DD_LOGS.init(__DD_CONFIG__); }
}
})();`
}

export async function generateBundle(options: GenerateBundleOptions): Promise<string> {
if (!options.applicationId || typeof options.applicationId !== 'string') {
throw new Error("Option 'applicationId' is required and must be a non-empty string.")
}
if (!options.remoteConfigurationId || typeof options.remoteConfigurationId !== 'string') {
throw new Error("Option 'remoteConfigurationId' is required and must be a non-empty string.")
}
if (!VALID_VARIANTS.includes(options.variant)) {
throw new Error(`Option 'variant' must be 'rum' or 'rum-slim', got '${String(options.variant)}'.`)
}

const configResult = await fetchConfig({
applicationId: options.applicationId,
remoteConfigurationId: options.remoteConfigurationId,
site: options.site,
})

const resolved = resolveDynamicValues(configResult.value)
const configJs = serializeConfigToJs(resolved)

const sdkCode = await downloadSDK({ variant: options.variant, datacenter: options.datacenter })
const sdkVersion = getDefaultVersion()

return generateCombinedBundle({ sdkCode, configJs, variant: options.variant, sdkVersion })
}
11 changes: 11 additions & 0 deletions packages/endpoint/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Inline browser helper functions embedded in generated bundles.
* Minimal vanilla JS — no dependencies, no transpilation needed.
*/
export const INLINE_HELPERS = `\
function __dd_getCookie(n){var m=document.cookie.match(new RegExp('(?:^|; )'+encodeURIComponent(n)+'=([^;]*)'));return m?decodeURIComponent(m[1]):undefined}
function __dd_getJs(p){try{return p.split('.').reduce(function(o,k){return o[k]},window)}catch(e){return undefined}}
function __dd_getDomText(s){try{var e=document.querySelector(s);return e?e.textContent:undefined}catch(e){return undefined}}
function __dd_getDomAttr(s,a){try{var e=document.querySelector(s);return e?e.getAttribute(a):undefined}catch(e){return undefined}}
function __dd_getLocalStorage(k){try{return localStorage.getItem(k)}catch(e){return undefined}}
function __dd_extract(v,p){if(v==null)return undefined;var m=new RegExp(p).exec(String(v));return m?m[1]!==undefined?m[1]:m[0]:undefined}`
3 changes: 3 additions & 0 deletions packages/endpoint/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { generateBundle, generateCombinedBundle, fetchConfig } from './bundleGenerator.ts'
export type { GenerateBundleOptions, CombineBundleOptions, FetchConfigOptions } from './bundleGenerator.ts'
export type { SdkVariant } from './sdkDownloader.ts'
Loading
Loading