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
17 changes: 16 additions & 1 deletion packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ import { configurationFromString, DatadogProvider } from '@datadog/openfeature-b

### Using DatadogOfflineProvider with portable configuration

`DatadogOfflineProvider` is an opt-in evaluation-only provider for applications that supply their own flags configuration, such as an SSR bootstrap or offline init payload. It does not fetch or poll configuration.
`DatadogOfflineProvider` is an opt-in provider for applications that supply their own flags configuration, such as an SSR bootstrap or offline init payload. It never fetches or polls configuration. By default it also sends no telemetry.

For static offline initialization, a context-specific precomputed configuration must use the OpenFeature context for which it was computed. Use `getPrecomputedContext()` to access a detached copy through the supported API. An empty context (`{}`) is treated literally and does not select the embedded context.

Expand Down Expand Up @@ -162,6 +162,21 @@ const enabled = client.getBooleanValue('new-checkout', false)

For dynamic context, use the default `@datadog/openfeature-browser` entry point and a rules-based configuration wire. After registering the provider, use `OpenFeature.setContext()` normally; context changes are evaluated locally without fetching configuration.

To send the same exposure, flag-evaluation, and RUM tracking events as `DatadogProvider`, add a `tracking` configuration. Providing it enables all three integrations by default; each can be disabled independently. This only enables telemetry transport—flag configuration remains fully offline.

```javascript
const provider = new DatadogOfflineProvider({
tracking: {
clientToken: 'client-token',
applicationId: 'application-id',
site: 'datadoghq.com',
service: 'storefront',
enableRumFeatureFlagTracking: false,
},
})
provider.setConfiguration(configuration)
```

## End-user license agreement

https://www.datadoghq.com/legal/eula
60 changes: 60 additions & 0 deletions packages/browser/src/cache/resettable-assignment-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
type AssignmentCache,
type AssignmentCacheEntry,
assignmentCacheKeyToString,
assignmentCacheValueToString,
} from '@datadog/flagging-core'

/**
* Serializes asynchronous cache lifecycle operations while making a clear visible synchronously.
* Entries written after a clear request are replayed after pending initialization and clearing finish.
*/
export class ResettableAssignmentCache implements AssignmentCache {
private lifecycle: Promise<void> = Promise.resolve()
private entriesAfterClear?: Map<string, AssignmentCacheEntry>

constructor(private readonly delegate: AssignmentCache) {}

init(): Promise<void> {
const operation = this.afterLifecycle(() => this.delegate.init())
this.lifecycle = operation
return operation
}

clear(): Promise<void> {
const entriesAfterClear = new Map<string, AssignmentCacheEntry>()
this.entriesAfterClear = entriesAfterClear

const operation = this.afterLifecycle(async () => {
await this.delegate.clear()
if (this.entriesAfterClear !== entriesAfterClear) {
return
}

entriesAfterClear.forEach((entry) => {
this.delegate.set(entry)
})
this.entriesAfterClear = undefined
})
this.lifecycle = operation
return operation
}

set(entry: AssignmentCacheEntry): void {
this.entriesAfterClear?.set(assignmentCacheKeyToString(entry), entry)
this.delegate.set(entry)
}

has(entry: AssignmentCacheEntry): boolean {
if (!this.entriesAfterClear) {
return this.delegate.has(entry)
}

const cached = this.entriesAfterClear.get(assignmentCacheKeyToString(entry))
return cached !== undefined && assignmentCacheValueToString(cached) === assignmentCacheValueToString(entry)
}

private afterLifecycle(operation: () => Promise<void> | void): Promise<void> {
return this.lifecycle.catch(() => {}).then(operation)
}
}
51 changes: 36 additions & 15 deletions packages/browser/src/domain/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,12 @@ import { createFlagsConfigurationFetcher } from '../transport/fetchConfiguration
/**
* Init Configuration for the Flagging SDK.
*/
export interface FlaggingInitConfiguration extends InitConfiguration {
export interface FlaggingTrackingInitConfiguration extends InitConfiguration {
/**
* The RUM application ID.
*/
applicationId?: string

/**
* Initial flags configuration (precomputed flags)
*/
initialFlagsConfiguration?: FlagsConfiguration

/**
* RUM integration options
* @deprecated Use enableExposureLogging instead. RUM-based exposure tracking will be removed in a future version.
Expand Down Expand Up @@ -57,6 +52,16 @@ export interface FlaggingInitConfiguration extends InitConfiguration {
* Flag evaluation tracking interval in milliseconds (default: 10000ms)
*/
flagEvaluationTrackingInterval?: number
}

/**
* Init Configuration for the online Flagging provider.
*/
export interface FlaggingInitConfiguration extends FlaggingTrackingInitConfiguration {
/**
* Initial flags configuration (precomputed flags)
*/
initialFlagsConfiguration?: FlagsConfiguration

/**
* Custom headers to add to the request to the Datadog API.
Expand All @@ -74,22 +79,25 @@ export interface FlaggingInitConfiguration extends InitConfiguration {
flaggingProxy?: string
}

export interface FlaggingConfiguration extends Configuration {
export interface FlaggingTrackingConfiguration extends Configuration {
applicationId?: string
flagEvaluationTrackingInterval: number

// Inherited from Configuration via TransportConfiguration.
// Declared explicitly here to make the contract visible to consumers of FlaggingTrackingConfiguration.
flagEvaluationEndpointBuilder: EndpointBuilder
}

export interface FlaggingConfiguration extends FlaggingTrackingConfiguration {
fetchFlagsConfiguration: (
context: EvaluationContext,
options?: { signal?: AbortSignal }
) => Promise<FlagsConfiguration>

// Inherited from Configuration via TransportConfiguration.
// Declared explicitly here to make the contract visible to consumers of FlaggingConfiguration.
flagEvaluationEndpointBuilder: EndpointBuilder
}

export function validateAndBuildFlaggingConfiguration(
initConfiguration: FlaggingInitConfiguration
): FlaggingConfiguration | undefined {
export function validateAndBuildFlaggingTrackingConfiguration(
initConfiguration: FlaggingTrackingInitConfiguration
): FlaggingTrackingConfiguration | undefined {
const baseConfiguration = validateAndBuildConfiguration(initConfiguration)
if (!baseConfiguration) {
return
Expand All @@ -98,7 +106,20 @@ export function validateAndBuildFlaggingConfiguration(
return {
applicationId: initConfiguration.applicationId,
flagEvaluationTrackingInterval: initConfiguration.flagEvaluationTrackingInterval ?? 10000,
fetchFlagsConfiguration: createFlagsConfigurationFetcher(initConfiguration),
...baseConfiguration,
}
}

export function validateAndBuildFlaggingConfiguration(
initConfiguration: FlaggingInitConfiguration
): FlaggingConfiguration | undefined {
const trackingConfiguration = validateAndBuildFlaggingTrackingConfiguration(initConfiguration)
if (!trackingConfiguration) {
return
}

return {
fetchFlagsConfiguration: createFlagsConfigurationFetcher(initConfiguration),
...trackingConfiguration,
}
}
7 changes: 3 additions & 4 deletions packages/browser/src/openfeature/exposures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { addTelemetryDebug, createPageMayExitObservable } from '@datadog/browser
import { type AssignmentCache, createExposureEvent, type ExposureEventWithTimestamp } from '@datadog/flagging-core'
import { timeStampNow } from '@datadog/js-core/time'
import type { EvaluationContext, EvaluationDetails, FlagValue, Hook, HookContext } from '@openfeature/web-sdk'
import type { FlaggingConfiguration } from '../domain/configuration'
import type { FlaggingTrackingConfiguration } from '../domain/configuration'
import { startExposuresBatch } from '../transport/startExposuresBatch'

/**
* Create hook for exposure logging.
*/
export function createExposureLoggingHook(
configuration: FlaggingConfiguration,
configuration: FlaggingTrackingConfiguration,
exposureCache: AssignmentCache,
getEvaluationContext: (context: EvaluationContext) => EvaluationContext = (context) => context
): Hook {
Expand All @@ -26,8 +26,7 @@ export function createExposureLoggingHook(
return {
after: (hookContext: HookContext, details: EvaluationDetails<FlagValue>) => {
const timestamp = timeStampNow()
const evaluationContext = getEvaluationContext(hookContext.context)
const exposureEvent = createExposureEvent(evaluationContext, details)
const exposureEvent = createExposureEvent(getEvaluationContext(hookContext.context), details)
if (!exposureEvent) {
return
}
Expand Down
4 changes: 2 additions & 2 deletions packages/browser/src/openfeature/flagEvaluations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import {
} from '@datadog/browser-core'
import { FlagEvaluationAggregator, type FlagEvaluationEvent } from '@datadog/flagging-core'
import type { EvaluationContext, EvaluationDetails, FlagValue, Hook, HookContext } from '@openfeature/web-sdk'
import type { FlaggingConfiguration } from '../domain/configuration'
import type { FlaggingTrackingConfiguration } from '../domain/configuration'

export function createFlagEvalEVPHook(
configuration: FlaggingConfiguration,
configuration: FlaggingTrackingConfiguration,
getEvaluationContext: (context: EvaluationContext) => EvaluationContext = (context) => context
): Hook {
const pageMayExitObservable = createPageMayExitObservable(configuration)
Expand Down
45 changes: 43 additions & 2 deletions packages/browser/src/openfeature/offline-provider.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,55 @@
import type { FlagsConfiguration, FlagTypeToValue } from '@datadog/flagging-core'
import type { AssignmentCache, FlagsConfiguration, FlagTypeToValue } from '@datadog/flagging-core'
import { evaluate, type FlagsConfigurationError, getFlagsConfigurationError } from '@datadog/flagging-core'
import type {
EvaluationContext,
FlagValueType,
Hook,
Logger,
ProviderMetadata,
ResolutionDetails,
} from '@openfeature/web-sdk'
import { InvalidContextError, ParseError, ProviderEvents, ProviderNotReadyError } from '@openfeature/web-sdk'
import {
type FlaggingTrackingInitConfiguration,
validateAndBuildFlaggingTrackingConfiguration,
} from '../domain/configuration'
import { DatadogCoreProvider } from './core-provider'
import { toProviderErrorEvent } from './error-event'
import { createProviderTracking } from './tracking'

export interface DatadogOfflineProviderOptions {
/**
* Optional browser telemetry transport and tracking settings. Offline evaluation never fetches
* configuration. Tracking is disabled when this property is omitted; when supplied, integrations
* use the same defaults as DatadogProvider and can be disabled individually.
*/
tracking?: FlaggingTrackingInitConfiguration
}

export class DatadogOfflineProvider extends DatadogCoreProvider {
readonly metadata: ProviderMetadata = {
name: 'datadog-offline',
}
hooks?: Hook[]

private flagsConfiguration: FlagsConfiguration | undefined
private context: EvaluationContext = {}
private readonly exposureCache?: AssignmentCache

constructor() {
constructor(options: DatadogOfflineProviderOptions = {}) {
super()
const trackingConfiguration = options.tracking
? validateAndBuildFlaggingTrackingConfiguration(options.tracking)
: undefined
const tracking = createProviderTracking({
options: options.tracking ?? {},
configuration: trackingConfiguration,
enabledByDefault: options.tracking !== undefined,
getTrackingContext: (context) => context,
serializeExposureCacheLifecycle: true,
})
this.hooks = tracking.hooks
this.exposureCache = tracking.exposureCache
}

getConfiguration(): FlagsConfiguration | undefined {
Expand All @@ -30,6 +59,13 @@ export class DatadogOfflineProvider extends DatadogCoreProvider {
setConfiguration(configuration: FlagsConfiguration): void {
const hadEvaluatableConfiguration = this.canEvaluateCurrentContext()
this.flagsConfiguration = configuration
try {
void Promise.resolve(this.exposureCache?.clear()).catch(() => {
// Telemetry cache failures must not prevent configuration updates.
})
} catch {
// Telemetry cache failures must not prevent configuration updates.
}

const error = toOpenFeatureError(getFlagsConfigurationError(configuration, this.context))
if (error) {
Expand All @@ -45,6 +81,11 @@ export class DatadogOfflineProvider extends DatadogCoreProvider {

async initialize(context: EvaluationContext = {}): Promise<void> {
this.context = context
try {
await this.exposureCache?.init()
} catch {
// Telemetry cache failures must not prevent offline evaluation.
}
const error = toOpenFeatureError(getFlagsConfigurationError(this.flagsConfiguration, this.context))
if (error) {
throw error
Expand Down
38 changes: 11 additions & 27 deletions packages/browser/src/openfeature/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import type {
ResolutionDetails,
} from '@openfeature/web-sdk'
import { ProviderEvents, ProviderStatus } from '@openfeature/web-sdk'
import { assignmentCacheFactory } from '../cache/assignment-cache-factory'
import { chromeStorageIfAvailable, hasIndexedDB } from '../cache/helpers'
import { hasIndexedDB } from '../cache/helpers'
import { IndexedDBFlagsCache } from '../cache/indexeddb-flags-cache'
import {
type FlaggingConfiguration,
Expand All @@ -24,9 +23,8 @@ import {
} from '../domain/configuration'
import { DatadogCoreProvider } from './core-provider'
import { toProviderErrorEvent } from './error-event'
import { createExposureLoggingHook } from './exposures'
import { createFlagEvalEVPHook } from './flagEvaluations'
import { createRumTrackingHook, enrichEvaluationContextWithRumUser } from './rumIntegration'
import { enrichEvaluationContextWithRumUser } from './rumIntegration'
import { createProviderTracking } from './tracking'

/**
* @deprecated Use FlaggingInitConfiguration instead
Expand Down Expand Up @@ -105,29 +103,15 @@ export class DatadogProvider extends DatadogCoreProvider {
super()
this.configuration = validateAndBuildFlaggingConfiguration(options)

// Set up provider-managed hooks and events
this.hooks = []

this.isRumIntegrationEnabled = options.enableRumFeatureFlagTracking ?? true
if (this.isRumIntegrationEnabled) {
this.hooks.push(createRumTrackingHook())
}

// Add EVP flag evaluation hook.
const isEvaluationTrackingEnabled = options.enableFlagEvaluationTracking ?? true
if (isEvaluationTrackingEnabled && this.configuration) {
this.hooks.push(createFlagEvalEVPHook(this.configuration, () => this.evaluationContext))
}

// Add proper exposure logging hook (creates batch internally)
const isExposureLoggingEnabled = options.enableExposureLogging ?? true
if (isExposureLoggingEnabled && this.configuration) {
this.exposureCache = assignmentCacheFactory({
chromeStorage: chromeStorageIfAvailable(),
storageKeySuffix: 'dd-of-browser',
})
this.hooks.push(createExposureLoggingHook(this.configuration, this.exposureCache, () => this.evaluationContext))
}
const tracking = createProviderTracking({
options,
configuration: this.configuration,
enabledByDefault: true,
getTrackingContext: () => this.evaluationContext,
})
this.hooks = tracking.hooks
this.exposureCache = tracking.exposureCache

if (hasIndexedDB()) {
this.flagsCache = new IndexedDBFlagsCache(options.clientToken)
Expand Down
Loading