From 471d42b4a7a9e72ac05c1ba83dbf5ff88f4bb9ab Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Mon, 3 Aug 2026 17:37:30 -0700 Subject: [PATCH] [FFL-2926] add offline provider tracking parity --- packages/browser/README.md | 17 +- .../src/cache/resettable-assignment-cache.ts | 60 +++++ packages/browser/src/domain/configuration.ts | 51 ++-- packages/browser/src/openfeature/exposures.ts | 7 +- .../src/openfeature/flagEvaluations.ts | 4 +- .../src/openfeature/offline-provider.ts | 45 +++- packages/browser/src/openfeature/provider.ts | 38 +-- packages/browser/src/openfeature/tracking.ts | 74 ++++++ packages/browser/src/provider-entrypoint.ts | 2 +- .../src/transport/startExposuresBatch.ts | 4 +- .../cache/resettable-assignment-cache.spec.ts | 108 ++++++++ .../test/openfeature/offline-tracking.spec.ts | 244 ++++++++++++++++++ 12 files changed, 600 insertions(+), 54 deletions(-) create mode 100644 packages/browser/src/cache/resettable-assignment-cache.ts create mode 100644 packages/browser/src/openfeature/tracking.ts create mode 100644 packages/browser/test/cache/resettable-assignment-cache.spec.ts create mode 100644 packages/browser/test/openfeature/offline-tracking.spec.ts diff --git a/packages/browser/README.md b/packages/browser/README.md index e7723ac3..82912c3c 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -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. @@ -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 diff --git a/packages/browser/src/cache/resettable-assignment-cache.ts b/packages/browser/src/cache/resettable-assignment-cache.ts new file mode 100644 index 00000000..759988f1 --- /dev/null +++ b/packages/browser/src/cache/resettable-assignment-cache.ts @@ -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 = Promise.resolve() + private entriesAfterClear?: Map + + constructor(private readonly delegate: AssignmentCache) {} + + init(): Promise { + const operation = this.afterLifecycle(() => this.delegate.init()) + this.lifecycle = operation + return operation + } + + clear(): Promise { + const entriesAfterClear = new Map() + 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): Promise { + return this.lifecycle.catch(() => {}).then(operation) + } +} diff --git a/packages/browser/src/domain/configuration.ts b/packages/browser/src/domain/configuration.ts index 68a8ce54..7e0e7ca4 100644 --- a/packages/browser/src/domain/configuration.ts +++ b/packages/browser/src/domain/configuration.ts @@ -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. @@ -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. @@ -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 - - // 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 @@ -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, + } +} diff --git a/packages/browser/src/openfeature/exposures.ts b/packages/browser/src/openfeature/exposures.ts index e2561155..273f6b4a 100644 --- a/packages/browser/src/openfeature/exposures.ts +++ b/packages/browser/src/openfeature/exposures.ts @@ -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 { @@ -26,8 +26,7 @@ export function createExposureLoggingHook( return { after: (hookContext: HookContext, details: EvaluationDetails) => { const timestamp = timeStampNow() - const evaluationContext = getEvaluationContext(hookContext.context) - const exposureEvent = createExposureEvent(evaluationContext, details) + const exposureEvent = createExposureEvent(getEvaluationContext(hookContext.context), details) if (!exposureEvent) { return } diff --git a/packages/browser/src/openfeature/flagEvaluations.ts b/packages/browser/src/openfeature/flagEvaluations.ts index 554a7dae..9e18af49 100644 --- a/packages/browser/src/openfeature/flagEvaluations.ts +++ b/packages/browser/src/openfeature/flagEvaluations.ts @@ -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) diff --git a/packages/browser/src/openfeature/offline-provider.ts b/packages/browser/src/openfeature/offline-provider.ts index 0c4a5761..01a0dfd6 100644 --- a/packages/browser/src/openfeature/offline-provider.ts +++ b/packages/browser/src/openfeature/offline-provider.ts @@ -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 { @@ -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) { @@ -45,6 +81,11 @@ export class DatadogOfflineProvider extends DatadogCoreProvider { async initialize(context: EvaluationContext = {}): Promise { 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 diff --git a/packages/browser/src/openfeature/provider.ts b/packages/browser/src/openfeature/provider.ts index e2d06207..88b93569 100644 --- a/packages/browser/src/openfeature/provider.ts +++ b/packages/browser/src/openfeature/provider.ts @@ -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, @@ -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 @@ -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) diff --git a/packages/browser/src/openfeature/tracking.ts b/packages/browser/src/openfeature/tracking.ts new file mode 100644 index 00000000..970b99ea --- /dev/null +++ b/packages/browser/src/openfeature/tracking.ts @@ -0,0 +1,74 @@ +import type { AssignmentCache } from '@datadog/flagging-core' +import type { EvaluationContext, Hook, HookContext } from '@openfeature/web-sdk' +import { assignmentCacheFactory } from '../cache/assignment-cache-factory' +import { chromeStorageIfAvailable } from '../cache/helpers' +import { ResettableAssignmentCache } from '../cache/resettable-assignment-cache' +import type { FlaggingTrackingConfiguration, FlaggingTrackingInitConfiguration } from '../domain/configuration' +import { createExposureLoggingHook } from './exposures' +import { createFlagEvalEVPHook } from './flagEvaluations' +import { createRumTrackingHook } from './rumIntegration' + +export interface ProviderTracking { + hooks: Hook[] + exposureCache?: AssignmentCache +} + +export function createProviderTracking({ + options, + configuration, + enabledByDefault, + getTrackingContext, + serializeExposureCacheLifecycle = false, +}: { + options: Partial + configuration?: FlaggingTrackingConfiguration + enabledByDefault: boolean + getTrackingContext?: (context: EvaluationContext) => EvaluationContext + serializeExposureCacheLifecycle?: boolean +}): ProviderTracking { + const hooks: Hook[] = [] + + if (options.enableRumFeatureFlagTracking ?? enabledByDefault) { + hooks.push(createRumTrackingHook()) + } + + if ((options.enableFlagEvaluationTracking ?? enabledByDefault) && configuration) { + hooks.push(createFlagEvalEVPHook(configuration)) + } + + let exposureCache: AssignmentCache | undefined + if ((options.enableExposureLogging ?? enabledByDefault) && configuration) { + exposureCache = assignmentCacheFactory({ + chromeStorage: chromeStorageIfAvailable(), + storageKeySuffix: 'dd-of-browser', + }) + if (serializeExposureCacheLifecycle) { + exposureCache = new ResettableAssignmentCache(exposureCache) + } + hooks.push(createExposureLoggingHook(configuration, exposureCache)) + } + + return { + hooks: getTrackingContext ? hooks.map((hook) => withTrackingContext(hook, getTrackingContext)) : hooks, + exposureCache, + } +} + +function withTrackingContext(hook: Hook, getTrackingContext: (context: EvaluationContext) => EvaluationContext): Hook { + if (!hook.after) { + return hook + } + + return { + ...hook, + after: (hookContext, details, hookHints) => + hook.after?.( + { + ...hookContext, + context: getTrackingContext(hookContext.context), + } as HookContext, + details, + hookHints + ), + } +} diff --git a/packages/browser/src/provider-entrypoint.ts b/packages/browser/src/provider-entrypoint.ts index 7b987453..2d376a4c 100644 --- a/packages/browser/src/provider-entrypoint.ts +++ b/packages/browser/src/provider-entrypoint.ts @@ -1,4 +1,4 @@ -export type { FlaggingInitConfiguration } from './domain/configuration' +export type { FlaggingInitConfiguration, FlaggingTrackingInitConfiguration } from './domain/configuration' export { DatadogDevtools } from './openfeature/devtools-provider' export { DatadogOfflineProvider } from './openfeature/offline-provider' export { DatadogProvider } from './openfeature/provider' diff --git a/packages/browser/src/transport/startExposuresBatch.ts b/packages/browser/src/transport/startExposuresBatch.ts index 0ee4ff9a..a7609b2d 100644 --- a/packages/browser/src/transport/startExposuresBatch.ts +++ b/packages/browser/src/transport/startExposuresBatch.ts @@ -6,10 +6,10 @@ import { createIdentityEncoder, Observable, } from '@datadog/browser-core' -import type { FlaggingConfiguration } from '../domain/configuration' +import type { FlaggingTrackingConfiguration } from '../domain/configuration' export function startExposuresBatch( - configuration: FlaggingConfiguration, + configuration: FlaggingTrackingConfiguration, reportError: (error: RawError) => void, pageMayExitObservable: Observable ) { diff --git a/packages/browser/test/cache/resettable-assignment-cache.spec.ts b/packages/browser/test/cache/resettable-assignment-cache.spec.ts new file mode 100644 index 00000000..6fe049ce --- /dev/null +++ b/packages/browser/test/cache/resettable-assignment-cache.spec.ts @@ -0,0 +1,108 @@ +import { + type AssignmentCache, + type AssignmentCacheEntry, + assignmentCacheKeyToString, + assignmentCacheValueToString, +} from '@datadog/flagging-core' +import { ResettableAssignmentCache } from '../../src/cache/resettable-assignment-cache' + +const assignmentA = assignment('a') +const assignmentB = assignment('b') + +describe('ResettableAssignmentCache', () => { + it('only replays entries from the latest clear generation', async () => { + const delegate = new DeferredClearCache() + const cache = new ResettableAssignmentCache(delegate) + + const firstClear = cache.clear() + await delegate.clearStarted() + cache.set(assignmentA) + + const secondClear = cache.clear() + cache.set(assignmentB) + delegate.resolveClear() + await delegate.clearStarted() + delegate.resolveClear() + await Promise.all([firstClear, secondClear]) + + expect(cache.has(assignmentA)).toBe(false) + expect(cache.has(assignmentB)).toBe(true) + expect(delegate.has(assignmentA)).toBe(false) + expect(delegate.has(assignmentB)).toBe(true) + }) + + it('recovers lifecycle sequencing after rejected initialization and clearing', async () => { + const delegate = new InMemoryAssignmentCache() + delegate.init = jest.fn().mockRejectedValueOnce(new Error('init failed')) + delegate.clear = jest + .fn() + .mockRejectedValueOnce(new Error('clear failed')) + .mockImplementationOnce(() => delegate.entries.clear()) + const cache = new ResettableAssignmentCache(delegate) + + await expect(cache.init()).rejects.toThrow('init failed') + const failedClear = cache.clear() + cache.set(assignmentA) + await expect(failedClear).rejects.toThrow('clear failed') + expect(cache.has(assignmentA)).toBe(true) + + const recoveredClear = cache.clear() + cache.set(assignmentB) + await expect(recoveredClear).resolves.toBeUndefined() + + expect(cache.has(assignmentA)).toBe(false) + expect(cache.has(assignmentB)).toBe(true) + }) +}) + +class InMemoryAssignmentCache implements AssignmentCache { + readonly entries = new Map() + + init(): Promise { + return Promise.resolve() + } + + clear(): Promise | void { + this.entries.clear() + } + + set(entry: AssignmentCacheEntry): void { + this.entries.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry)) + } + + has(entry: AssignmentCacheEntry): boolean { + return this.entries.get(assignmentCacheKeyToString(entry)) === assignmentCacheValueToString(entry) + } +} + +class DeferredClearCache extends InMemoryAssignmentCache { + private clearResolvers: Array<() => void> = [] + private clearStartedResolvers: Array<() => void> = [] + + clear(): Promise { + this.clearStartedResolvers.shift()?.() + return new Promise((resolve) => { + this.clearResolvers.push(() => { + this.entries.clear() + resolve() + }) + }) + } + + clearStarted(): Promise { + return new Promise((resolve) => this.clearStartedResolvers.push(resolve)) + } + + resolveClear(): void { + this.clearResolvers.shift()?.() + } +} + +function assignment(id: string): AssignmentCacheEntry { + return { + allocation: { key: 'allocation' }, + flag: { key: 'flag' }, + variant: { key: id }, + subject: { id, attributes: {} }, + } +} diff --git a/packages/browser/test/openfeature/offline-tracking.spec.ts b/packages/browser/test/openfeature/offline-tracking.spec.ts new file mode 100644 index 00000000..7e8e02a2 --- /dev/null +++ b/packages/browser/test/openfeature/offline-tracking.spec.ts @@ -0,0 +1,244 @@ +import { getGlobalObject, INTAKE_SITE_STAGING } from '@datadog/browser-core' +import { + assignmentCacheKeyToString, + assignmentCacheValueToString, + type ExposureEvent, + type FlagsConfiguration, +} from '@datadog/flagging-core' +import { configurationFromString } from '@datadog/flagging-core/configuration' +import { OpenFeature } from '@openfeature/web-sdk' +import { DatadogOfflineProvider } from '../../src/openfeature/offline-provider' +import type { DDRum } from '../../src/openfeature/rumIntegration' +import rulesWire from '../data/rules-v1-wire.json' + +const rulesConfiguration = configurationFromString(JSON.stringify(rulesWire)) + +const precomputedConfiguration: FlagsConfiguration = { + precomputed: { + context: { targetingKey: 'static-user', plan: 'free' }, + response: { + data: { + attributes: { + createdAt: '2026-07-06T23:01:56.822Z', + flags: { + 'static-flag': { + allocationKey: 'static-allocation', + variationKey: 'static-variation', + variationType: 'string', + variationValue: 'static-value', + reason: 'TARGETING_MATCH', + doLog: true, + }, + }, + }, + }, + }, + }, +} + +const tracking = { + clientToken: 'test-client-token', + applicationId: 'test-app-id', + env: 'test', + site: INTAKE_SITE_STAGING, + flagEvaluationTrackingInterval: 1000, +} + +describe('DatadogOfflineProvider tracking', () => { + const rumEvaluation = jest.fn() + let fetchMock: jest.Mock + let originalFetch: typeof global.fetch + + beforeAll(() => { + originalFetch = global.fetch + jest.useFakeTimers() + }) + + afterAll(() => { + global.fetch = originalFetch + jest.useRealTimers() + }) + + beforeEach(async () => { + fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 }) + global.fetch = fetchMock + rumEvaluation.mockReset() + getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM = { addFeatureFlagEvaluation: rumEvaluation } + localStorage.clear() + await OpenFeature.clearProviders() + await OpenFeature.clearContext() + OpenFeature.clearHandlers() + OpenFeature.clearHooks() + }) + + afterEach(() => { + delete getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM + Reflect.deleteProperty(globalThis, 'chrome') + }) + + it('does not track or create network activity by default', async () => { + const provider = new DatadogOfflineProvider() + provider.setConfiguration(precomputedConfiguration) + expect(provider.hooks).toEqual([]) + + await OpenFeature.setProviderAndWait(provider, { targetingKey: 'static-user', plan: 'free' }) + OpenFeature.getClient().getStringValue('static-flag', 'default') + jest.advanceTimersByTime(31_000) + + expect(rumEvaluation).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('uses the matching OpenFeature context for all opt-in tracking', async () => { + const provider = new DatadogOfflineProvider({ tracking }) + provider.setConfiguration(precomputedConfiguration) + expect(provider.hooks).toHaveLength(3) + + await OpenFeature.setProviderAndWait(provider, { targetingKey: 'static-user', plan: 'free' }) + OpenFeature.getClient().getStringValue('static-flag', 'default') + jest.advanceTimersByTime(31_000) + + expect(rumEvaluation).toHaveBeenCalledWith('static-flag', 'static-variation') + + const exposureRequest = findRequest('exposures') + expect(parseRequestBody(exposureRequest)).toMatchObject({ + subject: { id: 'static-user', attributes: { plan: 'free' } }, + }) + + const evaluationRequest = findRequest('flagevaluation') + expect(parseRequestBody(evaluationRequest)).toMatchObject({ + targeting_key: 'static-user', + context: { evaluation: { plan: 'free' } }, + }) + }) + + it('tracks rules-based evaluations with the supplied context', async () => { + const provider = new DatadogOfflineProvider({ tracking }) + provider.setConfiguration(rulesConfiguration) + await OpenFeature.setContext({ targetingKey: 'rules-user', country: 'US' }) + await OpenFeature.setProviderAndWait(provider) + + OpenFeature.getClient().getBooleanValue('test-flag', false) + jest.advanceTimersByTime(31_000) + + expect(rumEvaluation).toHaveBeenCalledWith('test-flag', 'on') + expect(parseRequestBody(findRequest('exposures'))).toMatchObject({ + flag: { key: 'test-flag' }, + allocation: { key: 'allocation' }, + variant: { key: 'on' }, + subject: { id: 'rules-user', attributes: { country: 'US' } }, + timestamp: expect.any(Number), + }) + expect(parseRequestBody(findRequest('flagevaluation'))).toMatchObject({ + flag: { key: 'test-flag' }, + allocation: { key: 'allocation' }, + variant: { key: 'on' }, + targeting_key: 'rules-user', + context: { evaluation: { country: 'US' } }, + timestamp: expect.any(Number), + first_evaluation: expect.any(Number), + last_evaluation: expect.any(Number), + }) + }) + + it('does not emit exposures when evaluation returns a default', async () => { + const provider = new DatadogOfflineProvider({ + tracking: { + ...tracking, + enableFlagEvaluationTracking: false, + enableRumFeatureFlagTracking: false, + }, + }) + provider.setConfiguration(precomputedConfiguration) + await OpenFeature.setProviderAndWait(provider, { targetingKey: 'static-user', plan: 'free' }) + + OpenFeature.getClient().getStringValue('missing-flag', 'default') + jest.advanceTimersByTime(31_000) + + expect(fetchMock.mock.calls.some(([url]) => url.toString().includes('exposures'))).toBe(false) + }) + + it('clears exposure deduplication when configuration is replaced', async () => { + const provider = new DatadogOfflineProvider({ + tracking: { + ...tracking, + enableFlagEvaluationTracking: false, + enableRumFeatureFlagTracking: false, + }, + }) + provider.setConfiguration(precomputedConfiguration) + await OpenFeature.setProviderAndWait(provider, { targetingKey: 'static-user', plan: 'free' }) + const client = OpenFeature.getClient() + + client.getStringValue('static-flag', 'default') + jest.advanceTimersByTime(31_000) + provider.setConfiguration(precomputedConfiguration) + client.getStringValue('static-flag', 'default') + jest.advanceTimersByTime(31_000) + + expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(2) + }) + + it('does not rehydrate stale exposures when configuration is replaced during cache initialization', async () => { + const staleExposure: ExposureEvent = { + allocation: { key: 'static-allocation' }, + flag: { key: 'static-flag' }, + variant: { key: 'static-variation' }, + subject: { id: 'static-user', attributes: { plan: 'free' } }, + } + const staleEntries = { + [assignmentCacheKeyToString(staleExposure)]: assignmentCacheValueToString(staleExposure), + } + let notifyReadStarted!: () => void + const readStarted = new Promise((resolve) => { + notifyReadStarted = resolve + }) + let resolveInitialRead!: (entries: Record) => void + const storage = { + get: jest.fn( + () => + new Promise>((resolve) => { + resolveInitialRead = resolve + notifyReadStarted() + }) + ), + set: jest.fn().mockResolvedValue(undefined), + clear: jest.fn().mockResolvedValue(undefined), + } as unknown as chrome.storage.StorageArea + Object.defineProperty(globalThis, 'chrome', { + configurable: true, + value: { storage: { local: storage } }, + }) + + const provider = new DatadogOfflineProvider({ + tracking: { + ...tracking, + enableFlagEvaluationTracking: false, + enableRumFeatureFlagTracking: false, + }, + }) + provider.setConfiguration(precomputedConfiguration) + const registration = OpenFeature.setProviderAndWait(provider, { targetingKey: 'static-user', plan: 'free' }) + await readStarted + + provider.setConfiguration(precomputedConfiguration) + resolveInitialRead(staleEntries) + await registration + + OpenFeature.getClient().getStringValue('static-flag', 'default') + jest.advanceTimersByTime(31_000) + + expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(1) + }) + + function findRequest(endpoint: string): RequestInit { + const call = fetchMock.mock.calls.find(([url]) => url.toString().includes(endpoint)) + expect(call).toBeDefined() + return call[1] + } + + function parseRequestBody(request: RequestInit): unknown { + expect(typeof request.body).toBe('string') + return JSON.parse((request.body as string).trim()) + } +})