Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0d92b3f
feat(openfeature): add manually configured DatadogCoreProvider
greghuels Sep 29, 2026
2ffc35b
feat(openfeature): delegate offline rules evaluation to core (FFL-2837)
greghuels Sep 29, 2026
c8b6cab
fix(openfeature): preserve missing offline targeting keys (FFL-2837)
greghuels Sep 29, 2026
e9ce348
refactor(openfeature): cast precomputed response at adapter boundary
greghuels Sep 29, 2026
df777df
docs(openfeature): remove internal evaluator policy
greghuels Sep 29, 2026
12f5a12
fix(flags): omit null context attributes on Android bridge (FFL-2837)
greghuels Sep 30, 2026
6fe71c9
refactor(openfeature): move rules parser behind /rules-based entry
greghuels Oct 1, 2026
d995d82
fix(openfeature): report PARSE_ERROR for unparseable offline configur…
greghuels Oct 1, 2026
c626c47
fix(openfeature): keep flag-level PARSE_ERROR in the offline provider
greghuels Oct 1, 2026
9b7ca0e
fix(openfeature): report an error instead of throwing for a null conf…
greghuels Oct 1, 2026
87f5959
feat(openfeature): send split serial ID for offline precomputed flags
greghuels Oct 1, 2026
e27ae61
fix(openfeature): warn when an offline configuration has no capability
greghuels Oct 1, 2026
3621670
docs(openfeature): correct the missing targeting key behavior for rules
greghuels Oct 1, 2026
3a0eb58
test(flags): cover offline tracking of rules results and delegate errors
greghuels Oct 1, 2026
377ae80
docs(openfeature): note that replacing a configuration keeps exposure…
greghuels Oct 1, 2026
22d5b2b
perf(openfeature): compute the offline snapshot match once per reconcile
greghuels Oct 1, 2026
d3f5678
fix(openfeature): reject only usable rules on an older core SDK
greghuels Oct 1, 2026
fa03a48
chore: add flagging-core runtime dependencies to LICENSE-3rdparty
greghuels Oct 1, 2026
f41c9a5
fix(openfeature): log the older core SDK warning through InternalLog
greghuels Oct 1, 2026
d3b9edc
refactor(openfeature): rename the internal core provider base class
greghuels Oct 1, 2026
c64dca3
fix(flags): keep the evaluation result when native tracking throws sy…
greghuels Oct 1, 2026
0537205
fix(openfeature): map non-object core provider configurations to PARS…
greghuels Oct 1, 2026
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
3 changes: 3 additions & 0 deletions LICENSE-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ dev,typescript,Apache-2.0,"Copyright Microsoft Corporation"
dev,genversion,MIT,"Copyright (c) 2021 Akseli Palén"
dev,@openfeature/core,Apache-2.0,"Copyright (c) The OpenFeature Authors"
prod,@openfeature/web-sdk,Apache-2.0,"Copyright (c) The OpenFeature Authors"
prod,@datadog/flagging-core,Apache-2.0,"Copyright 2025-present Datadog, Inc."
prod,@bufbuild/protobuf,Apache-2.0 AND BSD-3-Clause,"Copyright 2021-2026 Buf Technologies, Inc.; Copyright 2008 Google Inc."
prod,spark-md5,WTFPL OR MIT,"Copyright (C) 2015 André Cruz <amdfcruz@gmail.com>"
prod,chokidar,MIT,"Copyright (c) 2012 Paul Miller (https://paulmillr.com), Elan Shanker"
prod,fast-glob,MIT,"Copyright (c) Denis Malinochkin"
prod,svgo,MIT,"Copyright (c) Kir Belevich"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,17 @@ private fun buildFlagsConfiguration(configuration: Map<String, Any?>): FlagsConf
}.build()
}

private fun buildEvaluationContext(
internal fun buildEvaluationContext(
targetingKey: String,
attributes: ReadableMap,
): EvaluationContext {
val parsed = mutableMapOf<String, String>()

for ((key, value) in attributes.entryIterator) {
parsed[key] = value.toString()
// JS null attributes arrive as null; the native context only holds strings, so omit them.
for ((key, value) in attributes.toHashMap()) {
if (value != null) {
parsed[key] = value.toString()
}
Comment thread
greghuels marked this conversation as resolved.
}

return EvaluationContext(targetingKey, parsed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.datadog.android.flags.Flags
import com.datadog.android.flags.FlagsConfiguration
import com.datadog.tools.unit.toReadableMap
import com.facebook.react.bridge.Promise
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.Extensions
Expand Down Expand Up @@ -74,4 +75,22 @@ internal class DdFlagsImplementationTest {
datadogMock.close()
}
}

@Test
fun `M omit null attributes W buildEvaluationContext()`() {
// Given
val attributes = mapOf(
"country" to "US",
"age" to 42,
"size" to null
).toReadableMap()

// When
val context = buildEvaluationContext("user", attributes)

// Then
assertThat(context.targetingKey).isEqualTo("user")
assertThat(context.attributes).containsOnlyKeys("country", "age")
assertThat(context.attributes["country"]).isEqualTo("US")
}
}
147 changes: 128 additions & 19 deletions packages/core/src/flags/FlagsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ import type { JsonValue, EvaluationContext, FlagDetails } from './types';
* Error codes an offline configuration result can carry:
* - `INVALID_CONTEXT`: the active context does not match the precomputed snapshot.
* - `PROVIDER_NOT_READY`: an offline operation ran with no configuration loaded.
* - `PARSE_ERROR`: the offline evaluator cannot parse the supplied configuration.
* - `GENERAL`: the loaded configuration is unusable (malformed/unsupported/undecodable).
*/
export type ConfigurationErrorCode =
| 'INVALID_CONTEXT'
| 'PROVIDER_NOT_READY'
| 'PARSE_ERROR'
| 'GENERAL';

/**
Expand Down Expand Up @@ -63,6 +65,22 @@ type LoadedConfigurationState =
flags: Map<string, FlagCacheEntry>;
};

/** @internal JavaScript evaluation supplied by the OpenFeature package; tracking stays native. */
interface OfflineFlagsEvaluator {
reconcile(
context: EvaluationContext | undefined,
hasTargetingKey?: boolean
): ConfigurationResult;
evaluate<T>(
key: string,
defaultValue: T,
type: 'boolean' | 'string' | 'number' | 'object'
): {
details: FlagDetails<T>;
exposure?: { flag: FlagCacheEntry; context: EvaluationContext };
};
}

export class FlagsClient {
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags')
Expand All @@ -77,6 +95,10 @@ export class FlagsClient {
// is never mistaken for an app-set override (which would spuriously fail a later replacement).
private externalContext: EvaluationContext | undefined = undefined;

// Rules distinguish a missing subject from an explicitly anonymous ('') subject. Keep that
// information alongside the normalized context, without changing native/precomputed semantics.
private externalContextHasTargetingKey: boolean | undefined = undefined;

// The effective context evaluation and exposure tracking run against: the external override
// when set, otherwise the loaded configuration's embedded context.
private evaluationContext: EvaluationContext | undefined = undefined;
Expand All @@ -91,6 +113,9 @@ export class FlagsClient {
// The decoded outcome of the last loaded offline configuration (see the type).
private loadedConfiguration: LoadedConfigurationState = { kind: 'none' };

// Owned by the named client (not the provider instance), preserving shared-client behavior.
private offlineEvaluator: OfflineFlagsEvaluator | undefined;

// Internal serving status. `'none'` = online path (serve `flagsCache`); `'ready'` = serve
// the offline snapshot; `'error'` = serve coded defaults with `configurationError`.
private configurationStatus: 'none' | 'ready' | 'error' = 'none';
Expand Down Expand Up @@ -137,17 +162,19 @@ export class FlagsClient {
// client for both online and offline is unsupported (give the offline provider its own
// `clientName`), so warn when an offline configuration is discarded here. Online-only clients
// have no overlay, so this does not touch their keep-last-known-flags-on-failure behavior.
if (this.loadedConfiguration.kind !== 'none') {
if (this.loadedConfiguration.kind !== 'none' || this.offlineEvaluator) {
InternalLog.log(
`An offline configuration was loaded for '${this.clientName}' but an online fetch was requested; discarding it and serving default values on failure. Use a separate client for the offline provider.`,
SdkVerbosity.WARN
);
this.loadedConfiguration = { kind: 'none' };
this.offlineEvaluator = undefined;
this.configurationStatus = 'none';
this.configurationError = undefined;
this.flagsCache = new Map();
this.evaluationContext = undefined;
this.externalContext = undefined;
this.externalContextHasTargetingKey = undefined;
}

try {
Expand All @@ -158,13 +185,15 @@ export class FlagsClient {
);

this.externalContext = processedContext;
this.externalContextHasTargetingKey = true;
this.evaluationContext = processedContext;
this.flagsCache = new Map(Object.entries(result));

// A successful online fetch is authoritative: reset to the clean online state so a
// prior offline error status (e.g. PROVIDER_NOT_READY from an offline op with no
// configuration loaded) can't keep serving coded defaults over the fetched flags.
this.loadedConfiguration = { kind: 'none' };
this.offlineEvaluator = undefined;
this.configurationStatus = 'none';
this.configurationError = undefined;
} catch (error) {
Expand Down Expand Up @@ -196,8 +225,25 @@ export class FlagsClient {
*/
setEvaluationContextWithoutFetching = (
context: EvaluationContext
): ConfigurationResult => {
return this.__ddSetOfflineEvaluationContext(
context,
typeof context.targetingKey === 'string'
);
};

/**
* Preserve subject presence before the OpenFeature adapter normalizes a missing key to ''.
* The context still follows native/precomputed normalization; only the offline evaluator
* receives the presence bit. Stored on the named client so aliases and replacements share it.
* @internal Used only by the matching-version OpenFeature integration.
*/
__ddSetOfflineEvaluationContext = (
context: EvaluationContext,
hasTargetingKey: boolean
): ConfigurationResult => {
this.externalContext = processEvaluationContext(context);
this.externalContextHasTargetingKey = hasTargetingKey;

return this.reconcile();
};
Expand All @@ -213,6 +259,7 @@ export class FlagsClient {
*/
resetEvaluationContextWithoutFetching = (): ConfigurationResult => {
this.externalContext = undefined;
this.externalContextHasTargetingKey = undefined;

return this.reconcile();
};
Expand All @@ -239,11 +286,36 @@ export class FlagsClient {
setConfiguration = (
configuration: ParsedFlagsConfiguration
): ConfigurationResult => {
this.offlineEvaluator = undefined;
this.loadedConfiguration = this.loadConfiguration(configuration);

return this.reconcile();
};

/**
* Install a JavaScript evaluator for this named client's offline configuration.
* The factory receives the SDK's existing compatibility helpers, so the provider can reuse
* precomputed validation/context normalization without depending on private module paths.
* All client getters use the delegate; successful evaluations still use native tracking.
* An online fetch or a direct setConfiguration replaces this overlay, just as before.
* @internal Used only by the matching-version OpenFeature integration.
*/
__ddSetOfflineEvaluator = (
create: (helpers: {
decodePrecomputedFlags: typeof decodePrecomputedFlags;
normalizeWireContext: typeof normalizeWireContext;
}) => OfflineFlagsEvaluator
): ConfigurationResult => {
this.offlineEvaluator = create({
Comment thread
btthomas marked this conversation as resolved.
decodePrecomputedFlags,
normalizeWireContext
});
this.loadedConfiguration = { kind: 'none' };
this.flagsCache = new Map();
this.evaluationContext = undefined;
return this.reconcile();
};

/**
* Decode a supplied configuration into a {@link LoadedConfigurationState} **once**, capturing
* whether the load itself is valid (independent of the active context). Later context changes
Expand Down Expand Up @@ -294,6 +366,24 @@ export class FlagsClient {
* can never promote an invalid (or absent) load to `ready`.
*/
private reconcile = (): ConfigurationResult => {
if (this.offlineEvaluator) {
const result = this.offlineEvaluator.reconcile(
this.externalContext,
this.externalContextHasTargetingKey
);
if (result.status === 'error') {
return this.enterError(
result.errorCode,
result.errorCode === 'PARSE_ERROR'
? `The offline configuration for '${this.clientName}' cannot be parsed.`
: `The offline configuration for '${this.clientName}' cannot serve the current context.`
);
}
this.configurationStatus = 'ready';
this.configurationError = undefined;
return result;
}

const loaded = this.loadedConfiguration;

// No offline configuration engaged: an offline operation with nothing loaded is not ready.
Expand Down Expand Up @@ -367,32 +457,51 @@ export class FlagsClient {
};

private track = (flag: FlagCacheEntry, context: EvaluationContext) => {
// A non-blocking call; don't await this.
this.nativeFlags
.trackEvaluation(
this.clientName,
flag.key,
flag,
context.targetingKey,
context.attributes ?? {}
)
.catch(error => {
if (error instanceof Error) {
InternalLog.log(
`Error tracking flag evaluation: ${error.message}`,
SdkVerbosity.WARN
);
}
});
// A non-blocking call; don't await this. A tracking failure, synchronous or not, must
// never change the evaluation result.
try {
this.nativeFlags
.trackEvaluation(
this.clientName,
flag.key,
flag,
context.targetingKey,
context.attributes ?? {}
)
.catch(this.logTrackingError);
} catch (error) {
this.logTrackingError(error);
}
};

private logTrackingError = (error: unknown) => {
if (error instanceof Error) {
InternalLog.log(
`Error tracking flag evaluation: ${error.message}`,
SdkVerbosity.WARN
);
}
};

private getDetails = <T>(
key: string,
defaultValue: T,
type: 'boolean' | 'string' | 'number' | 'object'
): FlagDetails<T> => {
if (this.offlineEvaluator) {
const { details, exposure } = this.offlineEvaluator.evaluate(
key,
defaultValue,
type
);
if (!details.errorCode && exposure) {
Comment thread
greghuels marked this conversation as resolved.
this.track(exposure.flag, exposure.context);
Comment thread
btthomas marked this conversation as resolved.
}
return details;
}

// An offline configuration that cannot be served against the active context surfaces the
// precise error code (INVALID_CONTEXT / GENERAL / PROVIDER_NOT_READY) with the coded
// precise error code (INVALID_CONTEXT / PARSE_ERROR / GENERAL / PROVIDER_NOT_READY) with the coded
// default. The OpenFeature provider maps this to a PROVIDER_ERROR / ERROR state.
if (this.configurationStatus === 'error' && this.configurationError) {
return {
Expand Down
Loading
Loading