Skip to content

Commit

Permalink
feat(rc): SSRC targeting (#2665)
Browse files Browse the repository at this point in the history
* Initial set up and evaluation logic for server side custom signals (#2628)

initial skeleton for custom signal evaluation logic

---------

Co-authored-by: Kevin Elko <kjelko@google.com>

* Add logic for remaining custom signal operators (#2633)

* initial skeleton for custom signal evaluation logic

* adjust some formatting

* remove extra curly brace

* run lint

* Run apidocs

* Split EvaluationContext into UserProvidedSignals and PredefinedSignals

* rerun apidocs

* add logic for remaining custom signal operators

* more test cases

* test cases for numeric operators

* update tests to be way more robust and implement handling for a few edge cases

* update some comments

---------

Co-authored-by: Kevin Elko <kjelko@google.com>

* Ssrc targeting numeric version fixes (#2656)

* refactor numeric version parsing logic and add more test cases

* run lint

* test cases for max num segments

* run lint on tests

---------

Co-authored-by: Kevin Elko <kjelko@google.com>

* fix test ordering

* Export signal types and rerun apidocs

* update docstrings

* uber minor comment fix

---------

Co-authored-by: Kevin Elko <kjelko@google.com>
  • Loading branch information
kjelko and Kevin Elko authored Sep 5, 2024
1 parent 2fb4a27 commit 2a6ca8e
Show file tree
Hide file tree
Showing 5 changed files with 615 additions and 16 deletions.
43 changes: 40 additions & 3 deletions etc/firebase-admin.remote-config.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,41 @@ export interface AndCondition {
conditions?: Array<OneOfCondition>;
}

// @public
export interface CustomSignalCondition {
customSignalKey?: string;
customSignalOperator?: CustomSignalOperator;
targetCustomSignalValues?: string[];
}

// @public
export enum CustomSignalOperator {
NUMERIC_EQUAL = "NUMERIC_EQUAL",
NUMERIC_GREATER_EQUAL = "NUMERIC_GREATER_EQUAL",
NUMERIC_GREATER_THAN = "NUMERIC_GREATER_THAN",
NUMERIC_LESS_EQUAL = "NUMERIC_LESS_EQUAL",
NUMERIC_LESS_THAN = "NUMERIC_LESS_THAN",
NUMERIC_NOT_EQUAL = "NUMERIC_NOT_EQUAL",
SEMANTIC_VERSION_EQUAL = "SEMANTIC_VERSION_EQUAL",
SEMANTIC_VERSION_GREATER_EQUAL = "SEMANTIC_VERSION_GREATER_EQUAL",
SEMANTIC_VERSION_GREATER_THAN = "SEMANTIC_VERSION_GREATER_THAN",
SEMANTIC_VERSION_LESS_EQUAL = "SEMANTIC_VERSION_LESS_EQUAL",
SEMANTIC_VERSION_LESS_THAN = "SEMANTIC_VERSION_LESS_THAN",
SEMANTIC_VERSION_NOT_EQUAL = "SEMANTIC_VERSION_NOT_EQUAL",
STRING_CONTAINS = "STRING_CONTAINS",
STRING_CONTAINS_REGEX = "STRING_CONTAINS_REGEX",
STRING_DOES_NOT_CONTAIN = "STRING_DOES_NOT_CONTAIN",
STRING_EXACTLY_MATCHES = "STRING_EXACTLY_MATCHES",
UNKNOWN = "UNKNOWN"
}

// @public
export type DefaultConfig = {
[key: string]: string | number | boolean;
};

// @public
export type EvaluationContext = {
randomizationId?: string;
};
export type EvaluationContext = UserProvidedSignals & PredefinedSignals;

// @public
export interface ExplicitParameterValue {
Expand Down Expand Up @@ -78,6 +104,7 @@ export interface NamedCondition {
// @public
export interface OneOfCondition {
andCondition?: AndCondition;
customSignal?: CustomSignalCondition;
false?: Record<string, never>;
orCondition?: OrCondition;
percent?: PercentCondition;
Expand Down Expand Up @@ -108,6 +135,11 @@ export enum PercentConditionOperator {
UNKNOWN = "UNKNOWN"
}

// @public
export type PredefinedSignals = {
randomizationId?: string;
};

// @public
export class RemoteConfig {
// (undocumented)
Expand Down Expand Up @@ -205,6 +237,11 @@ export type ServerTemplateDataType = ServerTemplateData | string;
// @public
export type TagColor = 'BLUE' | 'BROWN' | 'CYAN' | 'DEEP_ORANGE' | 'GREEN' | 'INDIGO' | 'LIME' | 'ORANGE' | 'PINK' | 'PURPLE' | 'TEAL';

// @public
export type UserProvidedSignals = {
[key: string]: string | number;
};

// @public
export interface Value {
asBoolean(): boolean;
Expand Down
154 changes: 152 additions & 2 deletions src/remote-config/condition-evaluator-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import {
NamedCondition,
OrCondition,
PercentCondition,
PercentConditionOperator
PercentConditionOperator,
CustomSignalCondition,
CustomSignalOperator,
} from './remote-config-api';
import * as farmhash from 'farmhash-modern';

Expand Down Expand Up @@ -76,6 +78,9 @@ export class ConditionEvaluator {
if (condition.percent) {
return this.evaluatePercentCondition(condition.percent, context);
}
if (condition.customSignal) {
return this.evaluateCustomSignalCondition(condition.customSignal, context);
}
// TODO: add logging once we have a wrapped logger.
return false;
}
Expand Down Expand Up @@ -167,7 +172,6 @@ export class ConditionEvaluator {
return false;
}

// Visible for testing
static hashSeededRandomizationId(seededRandomizationId: string): bigint {
// For consistency with the Remote Config fetch endpoint's percent condition behavior
// we use Farmhash's fingerprint64 algorithm and interpret the resulting unsigned value
Expand All @@ -182,4 +186,150 @@ export class ConditionEvaluator {

return hash64;
}

private evaluateCustomSignalCondition(
customSignalCondition: CustomSignalCondition,
context: EvaluationContext
): boolean {
const {
customSignalOperator,
customSignalKey,
targetCustomSignalValues,
} = customSignalCondition;

if (!customSignalOperator || !customSignalKey || !targetCustomSignalValues) {
// TODO: add logging once we have a wrapped logger.
return false;
}

if (!targetCustomSignalValues.length) {
return false;
}

// Extract the value of the signal from the evaluation context.
const actualCustomSignalValue = context[customSignalKey];

if (actualCustomSignalValue == undefined) {
return false
}

switch (customSignalOperator) {
case CustomSignalOperator.STRING_CONTAINS:
return compareStrings(
targetCustomSignalValues,
actualCustomSignalValue,
(target, actual) => actual.includes(target),
);
case CustomSignalOperator.STRING_DOES_NOT_CONTAIN:
return !compareStrings(
targetCustomSignalValues,
actualCustomSignalValue,
(target, actual) => actual.includes(target),
);
case CustomSignalOperator.STRING_EXACTLY_MATCHES:
return compareStrings(
targetCustomSignalValues,
actualCustomSignalValue,
(target, actual) => actual.trim() === target.trim(),
);
case CustomSignalOperator.STRING_CONTAINS_REGEX:
return compareStrings(
targetCustomSignalValues,
actualCustomSignalValue,
(target, actual) => new RegExp(target).test(actual),
);

// For numeric operators only one target value is allowed.
case CustomSignalOperator.NUMERIC_LESS_THAN:
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r < 0);
case CustomSignalOperator.NUMERIC_LESS_EQUAL:
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r <= 0);
case CustomSignalOperator.NUMERIC_EQUAL:
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r === 0);
case CustomSignalOperator.NUMERIC_NOT_EQUAL:
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r !== 0);
case CustomSignalOperator.NUMERIC_GREATER_THAN:
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r > 0);
case CustomSignalOperator.NUMERIC_GREATER_EQUAL:
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r >= 0);

// For semantic operators only one target value is allowed.
case CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN:
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r < 0);
case CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL:
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r <= 0);
case CustomSignalOperator.SEMANTIC_VERSION_EQUAL:
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r === 0);
case CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL:
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r !== 0);
case CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN:
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r > 0);
case CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL:
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r >= 0);
}

// TODO: add logging once we have a wrapped logger.
return false;
}
}

// Compares the actual string value of a signal against a list of target
// values. If any of the target values are a match, returns true.
function compareStrings(
targetValues: Array<string>,
actualValue: string|number,
predicateFn: (target: string, actual: string) => boolean
): boolean {
const actual = String(actualValue);
return targetValues.some((target) => predicateFn(target, actual));
}

// Compares two numbers against each other.
// Calls the predicate function with -1, 0, 1 if actual is less than, equal to, or greater than target.
function compareNumbers(
actualValue: string|number,
targetValue: string,
predicateFn: (result: number) => boolean
): boolean {
const target = Number(targetValue);
const actual = Number(actualValue);
if (isNaN(target) || isNaN(actual)) {
return false;
}
return predicateFn(actual < target ? -1 : actual > target ? 1 : 0);
}

// Max number of segments a numeric version can have. This is enforced by the server as well.
const MAX_LENGTH = 5;

// Compares semantic version strings against each other.
// Calls the predicate function with -1, 0, 1 if actual is less than, equal to, or greater than target.
function compareSemanticVersions(
actualValue: string|number,
targetValue: string,
predicateFn: (result: number) => boolean
): boolean {
const version1 = String(actualValue).split('.').map(Number);
const version2 = targetValue.split('.').map(Number);

for (let i = 0; i < MAX_LENGTH; i++) {
// Check to see if segments are present. Note that these may be present and be NaN.
const version1HasSegment = version1[i] !== undefined;
const version2HasSegment = version2[i] !== undefined;

// If both are undefined, we've consumed everything and they're equal.
if (!version1HasSegment && !version2HasSegment) return predicateFn(0)

// Insert zeros if undefined for easier comparison.
if (!version1HasSegment) version1[i] = 0;
if (!version2HasSegment) version2[i] = 0;

// At this point, if either segment is NaN, we return false directly.
if (isNaN(version1[i]) || isNaN(version2[i])) return false;

// Check if we have a difference in segments. Otherwise continue to next segment.
if (version1[i] < version2[i]) return predicateFn(-1);
if (version1[i] > version2[i]) return predicateFn(1);
}
return false;
}
4 changes: 4 additions & 0 deletions src/remote-config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { RemoteConfig } from './remote-config';

export {
AndCondition,
CustomSignalCondition,
CustomSignalOperator,
DefaultConfig,
EvaluationContext,
ExplicitParameterValue,
Expand All @@ -41,6 +43,7 @@ export {
ParameterValueType,
PercentConditionOperator,
PercentCondition,
PredefinedSignals,
RemoteConfigCondition,
RemoteConfigParameter,
RemoteConfigParameterGroup,
Expand All @@ -52,6 +55,7 @@ export {
ServerTemplateData,
ServerTemplateDataType,
TagColor,
UserProvidedSignals,
Value,
ValueSource,
Version,
Expand Down
Loading

0 comments on commit 2a6ca8e

Please sign in to comment.