Skip to content
Merged
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
49 changes: 47 additions & 2 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,41 @@ If the RUM user changes after provider initialization, call
preserving explicitly configured OpenFeature properties. Nested RUM user properties are not included in the
evaluation context.

## Flag-key obfuscation

Requests to the Datadog Precompute endpoint automatically send
`X-DD-FEATURE-FLAGS-CAPABILITIES: assignment-encoding-flag-key-256-v1`.
Comment thread
leoromanovsky marked this conversation as resolved.
This declares support for the `flag-key-sha256-v1` response encoding.
Datadog controls its server rollout.
Requests through `flaggingProxy` do not send this header by default. Proxy owners
can opt in through `customHeaders`. The proxy must forward the header and, for
cross-origin requests, allow it in its CORS response before enabling it.
The provider accepts both plaintext and obfuscated responses without an
application configuration change.

Continue evaluating the original flag key. The shared core hashes it with the
response's public salt before lookup. Flag values do not change. Evaluation
details, exposure events, and RUM annotations retain the original flag key. Portable
configuration and IndexedDB storage retain the descriptor with the assignments.
Comment thread
leoromanovsky marked this conversation as resolved.
Unsupported or malformed encodings are rejected, not interpreted as plaintext.

Encoded portable snapshots use wire version 2, which older readers reject.
Plaintext and rules-only snapshots keep version 1. New readers accept both versions.
This version applies to SDK serialization, not the Precompute API response.

New IndexedDB writes use a separate cache key namespace for both response formats.
Older SDKs cannot read any entries in the new namespace, including plaintext.
New SDKs can read legacy plaintext
entries when the new namespace has no entry. A plaintext rollout rollback replaces
the encoded entry in the new namespace. It does not update an older SDK's cache.
A snapshot containing both encoded assignments and rules uses version 2.
Older readers reject that entire snapshot, including its rules.

Obfuscation removes readable flag-map keys. It is not encryption, authorization,
or response signing. Values, variation names, allocation names, telemetry,
and application code can still reveal a feature's purpose. Do not put sensitive
information in client-facing variant values, including JSON objects.

## Portable configuration parsing

The default entry point supports precomputed configurations without including
Expand Down Expand Up @@ -267,9 +302,19 @@ await tracking.shutdown()

The application owns manually registered hooks: clearing client hooks or removing `DatadogCoreProvider` does not shut down their resources. Unregister them and call `tracking.shutdown()` when they are no longer needed. The regular `DatadogProvider` shuts down its own tracking resources through OpenFeature's provider lifecycle.

Exposure deduplication is tied to the active `DatadogCoreProvider` configuration, so replacing the provider configuration allows exposures for the new configuration to be emitted without clearing application-managed hook state.
Precomputed assignments retain the existing exposure-reset behavior for plaintext and obfuscated responses.
On a refresh, `DatadogProvider` clears exposure deduplication when a previously loaded `createdAt` changes.
It does not clear on the first fetch without an initial configuration.
`DatadogCoreProvider` includes the configuration identity in exposure deduplication. Replacing the configuration,
including a changed `createdAt` or obfuscation salt, permits another exposure.
Reapplying the same configuration preserves deduplication. Neither provider emits an exposure until the application evaluates a flag.
`createdAt` is a configuration timestamp, not an experiment revision. This behavior does not depend on it changing on every request.

Exposure caches retain up to 50,000 entries per scope, matching the Node provider's limit.
The memory cache removes the least recently used entry. Persistent caches remove the oldest written entries.
An evicted entry can produce another exposure. Storage failures do not prevent flag evaluation.

Refetching identical content does not invalidate deduplication when only retrieval metadata (`fetchedAt` or `etag`) changes. For rules-based configurations, the server's `createdAt` build timestamp is also excluded from the identity, matching the backend's semantic fingerprint behavior. These fields remain available on the configuration and in its portable wire representation.
For rules-based configurations, `DatadogCoreProvider` also includes the rules configuration identity. Changed rules allow new exposures without clearing application-managed hook state. Retrieval metadata (`fetchedAt` and `etag`) and the server's `createdAt` build timestamp do not change that identity. These fields remain available on the configuration and in its portable wire representation.

Both the standalone exposure hook and `DatadogProvider` scope persistent exposure caches by telemetry site, client token, proxy URL, environment, application, service, and source. Scope values are hashed into the storage namespace; raw tokens are not stored in cache keys. Recreating a hook with the same scope retains deduplication, while another destination can emit its own exposures. Older unscoped cache entries are not reused, so upgrading can produce a one-time repeat exposure. Function-valued telemetry proxies use memory-only deduplication because their destination cannot be inferred reliably from the callback's identity.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { addTelemetryDebug } from '@datadog/browser-core'
import {
type AssignmentCacheEntry,
assignmentCacheKeyToString,
Expand All @@ -21,7 +22,9 @@ export default class ChromeStorageAssignmentCache implements BulkReadAssignmentC
set(entry: AssignmentCacheEntry): void {
// "fire-and-forget" - we intentionally don't wait for the promise to resolve
// noinspection JSIgnoredPromiseFromCall
this.storage.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry))
this.storage.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry)).catch(() => {
addTelemetryDebug('Failed to persist an exposure cache entry')
})
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down
54 changes: 51 additions & 3 deletions packages/browser/src/cache/chrome-storage-async-map.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { AsyncMap } from '@datadog/flagging-core'
import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants'

/** Chrome storage-backed {@link AsyncMap}. */
export default class ChromeStorageAsyncMap<T> implements AsyncMap<string, T> {
private readonly prefix: string
private keys: Set<string> | undefined
private pendingOperation = Promise.resolve()

constructor(
private readonly storage: chrome.storage.StorageArea,
Expand All @@ -23,6 +26,18 @@ export default class ChromeStorageAsyncMap<T> implements AsyncMap<string, T> {
}

async entries(): Promise<{ [p: string]: T }> {
return this.run(async () => {
const entries = await this.readEntries()
this.keys = new Set(Object.keys(entries))
await this.trim()
for (const key of Object.keys(entries)) {
if (!this.keys.has(key)) delete entries[key]
}
return entries
})
}

private async readEntries(): Promise<Record<string, T>> {
const entries = await this.storage.get<Record<string, T>>(null)
const scopedEntries: Record<string, T> = Object.create(null)
for (const [key, value] of Object.entries(entries)) {
Expand All @@ -32,11 +47,44 @@ export default class ChromeStorageAsyncMap<T> implements AsyncMap<string, T> {
}

async set(key: string, value: T) {
await this.storage.set({ [this.prefix + key]: value })
return this.run(async () => {
this.keys ??= new Set(Object.keys(await this.readEntries()))
this.keys.delete(key)
this.keys.add(key)
// Evict before writing so a full store has space for the new entry.
await this.trim()
await this.storage.set({ [this.prefix + key]: value })
})
}

async clear() {
const keys = Object.keys(await this.entries()).map((key) => this.prefix + key)
if (keys.length) await this.storage.remove(keys)
return this.run(async () => {
const keys = Object.keys(await this.readEntries()).map((key) => this.prefix + key)
if (keys.length) await this.storage.remove(keys)
this.keys = new Set()
})
}

private async trim(): Promise<void> {
const keys = this.keys!
const removed: string[] = []
for (const key of keys) {
if (keys.size - removed.length <= MAX_EXPOSURE_CACHE_ENTRIES) break
removed.push(key)
}
if (removed.length) {
await this.storage.remove(removed.map((key) => this.prefix + key))
for (const key of removed) keys.delete(key)
}
}

private run<R>(operation: () => Promise<R>): Promise<R> {
// Serialize writes and eviction. A failed operation must not stop later writes.
const result = this.pendingOperation.then(operation)
this.pendingOperation = result.then(
() => undefined,
() => undefined
)
return result
}
}
2 changes: 2 additions & 0 deletions packages/browser/src/cache/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Match the Node provider's exposure cache limit.
export const MAX_EXPOSURE_CACHE_ENTRIES = 50_000
29 changes: 26 additions & 3 deletions packages/browser/src/cache/indexeddb-flags-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,35 @@ export class IndexedDBFlagsCache {
/** Read cached config for the given context. Returns undefined on miss or any error. */
async get(context: EvaluationContext): Promise<FlagsConfiguration | undefined> {
try {
const configKey = buildConfigKey(this.clientToken, context)
const legacyKey = buildConfigKey(this.clientToken, context)
const configKey = `v2-${legacyKey}`
const db = await openDB()
try {
const config = await new Promise<FlagsConfiguration | undefined>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.get(configKey)
request.onsuccess = () => resolve(request.result as FlagsConfiguration | undefined)
request.onsuccess = () => {
if (request.result !== undefined) {
resolve(request.result as FlagsConfiguration)
return
}
// Read older plaintext entries on upgrade. Never expose encoded
// snapshots written by a prerelease SDK through the legacy key.
const legacy = store.get(legacyKey)
legacy.onerror = () => reject(legacy.error)
legacy.onsuccess = () => {
const config = legacy.result as FlagsConfiguration | undefined
const attributes = config?.precomputed?.response?.data?.attributes
resolve(
attributes &&
(attributes.obfuscated === undefined || attributes.obfuscated === false) &&
attributes.obfuscation === undefined
? config
: undefined
)
}
}
request.onerror = () => reject(request.error)
})
if (!config || typeof config !== 'object') {
Expand All @@ -62,7 +83,9 @@ export class IndexedDBFlagsCache {

/** Fire-and-forget persist. Never throws. */
set(config: FlagsConfiguration, context: EvaluationContext): void {
const configKey = buildConfigKey(this.clientToken, context)
// One namespace for new writes keeps plaintext rollback and encoded
// refreshes in the same slot without changing what older SDKs can read.
const configKey = `v2-${buildConfigKey(this.clientToken, context)}`
openDB()
.then((db) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
Expand Down
22 changes: 20 additions & 2 deletions packages/browser/src/cache/local-storage-assignment-shim.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// noinspection JSUnusedGlobalSymbols (methods are used by common repository)

import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants'
import { hasWindowLocalStorage } from './helpers'

export class LocalStorageAssignmentShim {
Expand Down Expand Up @@ -49,12 +51,28 @@ export class LocalStorageAssignmentShim {
}

public set(key: string, value: string): this {
return this.setCache(this.getCache().set(key, value))
const cache = this.getCache()
cache.delete(key)
cache.set(key, value)
this.trim(cache)
return this.setCache(cache)
}

private getCache(): Map<string, string> {
const cache = window.localStorage.getItem(this.localStorageKey)
return cache ? new Map(JSON.parse(cache)) : new Map()
const entries: Map<string, string> = cache ? new Map(JSON.parse(cache)) : new Map()
if (entries.size > MAX_EXPOSURE_CACHE_ENTRIES) {
this.trim(entries)
this.setCache(entries)
}
return entries
}

private trim(cache: Map<string, string>): void {
for (const key of cache.keys()) {
if (cache.size <= MAX_EXPOSURE_CACHE_ENTRIES) break
cache.delete(key)
}
}

private setCache(cache: Map<string, string>): this {
Expand Down
44 changes: 11 additions & 33 deletions packages/browser/src/cache/simple-assignment-cache.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,25 @@
import {
type AbstractAssignmentCache,
type AssignmentCacheEntry,
NonExpiringInMemoryAssignmentCache,
} from '@datadog/flagging-core'
import { LRUInMemoryAssignmentCache } from '@datadog/flagging-core'

import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants'
import type { BulkReadAssignmentCache, BulkWriteAssignmentCache } from './hybrid-assignment-cache'

/** An {@link BulkWriteAssignmentCache} assignment cache backed by an in-memory {@link Map} */
export default class SimpleAssignmentCache implements BulkWriteAssignmentCache, BulkReadAssignmentCache {
private readonly store: Map<string, string>
private readonly cache: AbstractAssignmentCache<Map<string, string>>

/** A bounded in-memory exposure cache, also used to serve persisted entries. */
export default class SimpleAssignmentCache
extends LRUInMemoryAssignmentCache
implements BulkWriteAssignmentCache, BulkReadAssignmentCache
{
constructor() {
this.store = new Map<string, string>()
this.cache = new NonExpiringInMemoryAssignmentCache(this.store)
}

init(): Promise<void> {
return Promise.resolve()
}

set(key: AssignmentCacheEntry): void {
this.cache.set(key)
}

has(key: AssignmentCacheEntry): boolean {
return this.cache.has(key)
super(MAX_EXPOSURE_CACHE_ENTRIES)
}

setEntries(entries: [string, string][]): void {
const { store } = this
// it's important to call store.set() directly here because we want to set the raw entries into the cache, bypassing
// the AbstractAssignmentCache logic, which takes an AssignmentCacheKey instead.
// Load serialized entries through the same capacity limit as new exposures.
entries.forEach(([key, value]) => {
store.set(key, value)
this.delegate.set(key, value)
})
}

getEntries(): Promise<[string, string][]> {
return Promise.resolve(Array.from(this.cache.entries()))
}

clear(): void {
this.cache.clear()
return Promise.resolve(Array.from(this.entries()))
}
}
2 changes: 1 addition & 1 deletion packages/browser/src/openfeature/provider-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ export function createProviderTracking({
const tracking = composeDatadogTrackingHooks(...trackingHooks)
return {
...tracking,
exposureCache,
hooks: getTrackingContext
? tracking.hooks.map((hook) => withTrackingContext(hook, getTrackingContext))
: tracking.hooks,
exposureCache,
}
}

Expand Down
9 changes: 2 additions & 7 deletions packages/browser/src/openfeature/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,8 @@ export class DatadogProvider extends DatadogProviderBase {
// `signal`, so we don't block OF SDK unnecessarily.
this.latestContextUpdate = this.retrieveFlagsConfiguration(evaluationContext, { signal })
.then((result) =>
// New configuration might require clearing exposure
// cache. One example of this is updating experiment
// boundaries: if we previously emitted exposure events for an
// experiment and the new configuration bumped experiment
// start time, we need to emit at least one new event within
// the new experiment timeframe. We do that by clearing our
// exposure
// Preserve the existing timestamp-based reset for experiment reporting.
// createdAt is a configuration timestamp, not an experiment revision.
this.maybeClearExposureCache(result.config, { signal }).then(
() => result,
// Ignore exposure cache errors. They should not prevent us from using the latest configuration.
Expand Down
19 changes: 16 additions & 3 deletions packages/browser/src/transport/fetchConfiguration.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { type FlagsConfiguration, parsePrecomputedConfigurationResponse } from '@datadog/flagging-core'
import {
type FlagsConfiguration,
parsePrecomputedConfigurationResponse,
SUPPORTED_FLAGS_CAPABILITIES,
} from '@datadog/flagging-core'
import { timeStampNow } from '@datadog/js-core/time'
import type { EvaluationContext } from '@openfeature/web-sdk'
import { ParseError } from '@openfeature/web-sdk'
import type { FlaggingInitConfiguration } from '../domain/configuration'
import { buildEndpointHost } from './endpoint'

const sourcePayload = {
sdk_name: 'browser',
sdk_version: __BUILD_ENV__SDK_VERSION__,
}
const capabilitiesHeader = [...SUPPORTED_FLAGS_CAPABILITIES].sort().join(',')

type JSONAPIError = {
errors: {
Expand Down Expand Up @@ -101,12 +107,15 @@ export function buildConfigurationHeaders(
export async function fetchPrecomputedConfiguration(
options: PrecomputedConfigurationFetchOptions
): Promise<FlagsConfiguration> {
const url = buildConfigurationUrl(options, 'precomputed')
const requestOptions: ConfigurationRequestOptions = options
const url = buildConfigurationUrl(requestOptions, 'precomputed')
const fetchedAt = timeStampNow()
const defaultHeaders = buildConfigurationHeaders(
options,
{
'Content-Type': 'application/vnd.api+json',
// Customer proxies can opt in through customHeaders after allowing it in CORS.
...(!requestOptions.flaggingProxy && { 'X-DD-FEATURE-FLAGS-CAPABILITIES': capabilitiesHeader }),
},
'precomputed'
)
Expand Down Expand Up @@ -160,12 +169,16 @@ export function createFlagsConfigurationFetcher(initConfiguration: FlaggingInitC
// Validate the endpoint while building the provider, preserving the existing constructor behavior.
buildConfigurationUrl(initConfiguration, 'precomputed')
return async (context: EvaluationContext, { signal }: { signal?: AbortSignal } = {}): Promise<FlagsConfiguration> => {
return fetchPrecomputedConfiguration({
const configuration = await fetchPrecomputedConfiguration({
...initConfiguration,
env: initConfiguration.env || '',
context,
fetch: initConfiguration.flagConfigurationFetch,
signal,
})
// Use the provider's existing context-matched cache fallback. Do not replace
// a valid snapshot with a malformed response or unsupported encoding.
if (configuration.precomputedError) throw new ParseError(configuration.precomputedError)
Comment thread
aarsilv marked this conversation as resolved.
return configuration
}
}
Loading
Loading