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
4 changes: 4 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,10 @@ 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.

Dependent precomputed assignments are negotiated through the URL query parameter
`cap_dependent-flags-variant-key=v1`. The provider adds it to every precompute
request, including requests sent through `flaggingProxy`.

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
Expand Down
58 changes: 34 additions & 24 deletions packages/browser/src/cache/indexeddb-flags-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export class IndexedDBFlagsCache {
): Promise<FlagsConfiguration | undefined> {
try {
const legacyKey = buildConfigKey(this.clientToken, context)
const configKey = `${policyPrefix(optedOut)}-${legacyKey}${this.selectionSuffix}`
const configKey = `${policyPrefix(optedOut, 'v3')}-${legacyKey}${this.selectionSuffix}`
const previousConfigKey = `${policyPrefix(optedOut, 'v2')}-${legacyKey}${this.selectionSuffix}`
const db = await openDB()
try {
const config = await new Promise<FlagsConfiguration | undefined>((resolve, reject) => {
Expand All @@ -44,24 +45,32 @@ export class IndexedDBFlagsCache {
resolve(request.result as FlagsConfiguration)
return
}
if (optedOut || this.selectionSuffix) {
resolve(undefined)
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
)
const previous = store.get(previousConfigKey)
previous.onerror = () => reject(previous.error)
previous.onsuccess = () => {
if (previous.result !== undefined) {
resolve(previous.result as FlagsConfiguration)
return
}
if (optedOut || this.selectionSuffix) {
resolve(undefined)
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)
Expand All @@ -80,9 +89,9 @@ export class IndexedDBFlagsCache {

/** Fire-and-forget persist. Never throws. */
set(config: FlagsConfiguration, context: EvaluationContext, optedOut: ExperimentationOptOutPolicy = false): void {
// One namespace for new writes keeps plaintext rollback and encoded
// refreshes in the same slot without changing what older SDKs can read.
const prefix = policyPrefix(optedOut)
// New wire shapes use a new namespace so an older SDK cannot consume a
// snapshot it does not understand after an application rollback.
const prefix = policyPrefix(optedOut, 'v3')
const configKey = `${prefix}-${buildConfigKey(this.clientToken, context)}${this.selectionSuffix}`
openDB()
.then((db) => {
Expand All @@ -99,6 +108,7 @@ export class IndexedDBFlagsCache {
}
}

function policyPrefix(policy: ExperimentationOptOutPolicy): string {
return policy ? experimentationOptOutCacheKey(policy) : 'v2'
function policyPrefix(policy: ExperimentationOptOutPolicy, version: 'v2' | 'v3'): string {
if (version === 'v2') return policy ? experimentationOptOutCacheKey(policy) : version
return policy ? `${version}-${experimentationOptOutCacheKey(policy)}` : version
}
16 changes: 10 additions & 6 deletions packages/browser/src/openfeature/core-provider-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
import type { EvaluationDetails, FlagValue, ResolutionDetails } from '@openfeature/web-sdk'
import { inheritPrecomputedDependencyEvaluations } from './precomputed-dependencies'

const CORE_CONFIGURATION_ID_METADATA_KEY = '__dd_core_configuration_id'

export function withCoreConfigurationId<T extends FlagValue>(
details: ResolutionDetails<T>,
configurationId: string | undefined
): ResolutionDetails<T> {
const allocationKey = details.flagMetadata?.allocationKey
const sourceMetadata = details.flagMetadata
const allocationKey = sourceMetadata?.allocationKey
if (
!sourceMetadata ||
configurationId === undefined ||
details.flagMetadata?.doLog !== true ||
typeof allocationKey !== 'string' ||
details.variant == null
) {
return details
}

const flagMetadata = {
...sourceMetadata,
[CORE_CONFIGURATION_ID_METADATA_KEY]: configurationId,
}
inheritPrecomputedDependencyEvaluations(sourceMetadata, flagMetadata)
return {
...details,
flagMetadata: {
...details.flagMetadata,
[CORE_CONFIGURATION_ID_METADATA_KEY]: configurationId,
},
flagMetadata,
}
}

Expand Down
7 changes: 6 additions & 1 deletion packages/browser/src/openfeature/core-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from './experimentation-opt-out'
import { registerDatadogProvider, supportsExposureAdmission } from './exposure-scope'
import { stampExposureCacheVerdict } from './exposures'
import { getPrecomputedDependencyEvaluations } from './precomputed-dependencies'
import { DatadogProviderBase } from './provider-base'

export interface DatadogCoreProviderConfiguration
Expand All @@ -51,7 +52,11 @@ export class DatadogCoreProvider extends DatadogProviderBase {
readonly hooks: Hook[] = [
{
finally: (context, details) => {
if (supportsExposureAdmission(context)) completeExposureDecision(details)
if (!supportsExposureAdmission(context)) return
for (const dependency of getPrecomputedDependencyEvaluations(details)) {
if (dependency.isPresentInAssignment) completeExposureDecision(dependency)
}
completeExposureDecision(details)
},
},
]
Expand Down
64 changes: 51 additions & 13 deletions packages/browser/src/openfeature/exposure-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
} from '@openfeature/web-sdk'
import { getEvaluationTrackingContext } from './evaluation-telemetry-policy'
import { supportsExposureAdmission } from './exposure-scope'
import { getPrecomputedDependencyEvaluations } from './precomputed-dependencies'

const METADATA_ALLOCATION_KEY = '__dd_allocation_key'
const METADATA_DO_LOG = '__dd_do_log'
Expand Down Expand Up @@ -61,14 +62,13 @@ class ExposureEvaluationImpl implements ExposureEvaluation {
readonly details: EvaluationDetails<FlagValue>
private readonly evaluationContext: EvaluationContext | undefined

constructor(hookContext: HookContext, details: EvaluationDetails<FlagValue>) {
constructor(hookContext: HookContext, details: EvaluationDetails<FlagValue>, trackingContext?: EvaluationContext) {
this.flagKey = hookContext?.flagKey ?? details.flagKey
this.details = details
// Prefer the context the provider actually evaluated (for example the
// RUM-enriched subject); fall back to the hook context for unbound results.
// The caller resolves the provider-bound context once for the whole graph.
// A wrapper may expose an unreadable context; the exposure decision below never reads it.
try {
this.evaluationContext = cloneExposureContext(getEvaluationTrackingContext(details, () => hookContext?.context))
this.evaluationContext = cloneExposureContext(trackingContext)
} catch {
this.evaluationContext = undefined
}
Expand Down Expand Up @@ -180,18 +180,56 @@ export class ExposureHook implements Hook {
/** Runs in the final hook stage, so a later hook's error is seen. */
finally(hookContext: HookContext, evaluationDetails: EvaluationDetails<FlagValue>): void {
if (evaluationDetails == null || !supportsExposureAdmission(hookContext)) return
let evaluations: Array<
EvaluationDetails<FlagValue> | (Omit<EvaluationDetails<FlagValue>, 'value'> & { value?: FlagValue })
>
try {
// The callback must read the final verdict, whether or not a provider or
// tracking hook finalized the decision first.
completeExposureDecision(evaluationDetails)
this.callback(new ExposureEvaluationImpl(hookContext, evaluationDetails))
const succeeded =
evaluationDetails.errorCode === undefined &&
evaluationDetails.reason !== 'ERROR' &&
evaluationDetails.variant != null
evaluations = succeeded
? [
...getPrecomputedDependencyEvaluations(evaluationDetails).filter(
(dependency) => dependency.isPresentInAssignment
),
evaluationDetails,
]
: [evaluationDetails]
} catch (error) {
if (this.warned) {
console.debug('Exposure callback failed', error)
} else {
this.warned = true
console.warn('Exposure callback failed; later failures are logged at debug level', error)
this.logCallbackFailure(error)
return
}
let trackingContext: EvaluationContext | undefined
try {
trackingContext = getEvaluationTrackingContext(evaluationDetails, () => hookContext.context)
} catch {
// An unreadable caller context must not suppress an otherwise valid callback.
}
for (const evaluation of evaluations) {
try {
// The callback must read the final verdict, whether or not a provider or
// tracking hook finalized the decision first.
completeExposureDecision(evaluation)
this.callback(
new ExposureEvaluationImpl(
evaluation === evaluationDetails ? hookContext : ({ flagKey: evaluation.flagKey } as HookContext),
evaluation as EvaluationDetails<FlagValue>,
trackingContext
)
)
} catch (error) {
this.logCallbackFailure(error)
}
}
}

private logCallbackFailure(error: unknown): void {
if (this.warned) {
console.debug('Exposure callback failed', error)
} else {
this.warned = true
console.warn('Exposure callback failed; later failures are logged at debug level', error)
}
}
}
Loading
Loading