Skip to content

Commit 2a6ca8e

Browse files
kjelkoKevin Elko
andauthored
feat(rc): SSRC targeting (#2665)
* 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>
1 parent 2fb4a27 commit 2a6ca8e

File tree

5 files changed

+615
-16
lines changed

5 files changed

+615
-16
lines changed

etc/firebase-admin.remote-config.api.md

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,41 @@ export interface AndCondition {
1313
conditions?: Array<OneOfCondition>;
1414
}
1515

16+
// @public
17+
export interface CustomSignalCondition {
18+
customSignalKey?: string;
19+
customSignalOperator?: CustomSignalOperator;
20+
targetCustomSignalValues?: string[];
21+
}
22+
23+
// @public
24+
export enum CustomSignalOperator {
25+
NUMERIC_EQUAL = "NUMERIC_EQUAL",
26+
NUMERIC_GREATER_EQUAL = "NUMERIC_GREATER_EQUAL",
27+
NUMERIC_GREATER_THAN = "NUMERIC_GREATER_THAN",
28+
NUMERIC_LESS_EQUAL = "NUMERIC_LESS_EQUAL",
29+
NUMERIC_LESS_THAN = "NUMERIC_LESS_THAN",
30+
NUMERIC_NOT_EQUAL = "NUMERIC_NOT_EQUAL",
31+
SEMANTIC_VERSION_EQUAL = "SEMANTIC_VERSION_EQUAL",
32+
SEMANTIC_VERSION_GREATER_EQUAL = "SEMANTIC_VERSION_GREATER_EQUAL",
33+
SEMANTIC_VERSION_GREATER_THAN = "SEMANTIC_VERSION_GREATER_THAN",
34+
SEMANTIC_VERSION_LESS_EQUAL = "SEMANTIC_VERSION_LESS_EQUAL",
35+
SEMANTIC_VERSION_LESS_THAN = "SEMANTIC_VERSION_LESS_THAN",
36+
SEMANTIC_VERSION_NOT_EQUAL = "SEMANTIC_VERSION_NOT_EQUAL",
37+
STRING_CONTAINS = "STRING_CONTAINS",
38+
STRING_CONTAINS_REGEX = "STRING_CONTAINS_REGEX",
39+
STRING_DOES_NOT_CONTAIN = "STRING_DOES_NOT_CONTAIN",
40+
STRING_EXACTLY_MATCHES = "STRING_EXACTLY_MATCHES",
41+
UNKNOWN = "UNKNOWN"
42+
}
43+
1644
// @public
1745
export type DefaultConfig = {
1846
[key: string]: string | number | boolean;
1947
};
2048

2149
// @public
22-
export type EvaluationContext = {
23-
randomizationId?: string;
24-
};
50+
export type EvaluationContext = UserProvidedSignals & PredefinedSignals;
2551

2652
// @public
2753
export interface ExplicitParameterValue {
@@ -78,6 +104,7 @@ export interface NamedCondition {
78104
// @public
79105
export interface OneOfCondition {
80106
andCondition?: AndCondition;
107+
customSignal?: CustomSignalCondition;
81108
false?: Record<string, never>;
82109
orCondition?: OrCondition;
83110
percent?: PercentCondition;
@@ -108,6 +135,11 @@ export enum PercentConditionOperator {
108135
UNKNOWN = "UNKNOWN"
109136
}
110137

138+
// @public
139+
export type PredefinedSignals = {
140+
randomizationId?: string;
141+
};
142+
111143
// @public
112144
export class RemoteConfig {
113145
// (undocumented)
@@ -205,6 +237,11 @@ export type ServerTemplateDataType = ServerTemplateData | string;
205237
// @public
206238
export type TagColor = 'BLUE' | 'BROWN' | 'CYAN' | 'DEEP_ORANGE' | 'GREEN' | 'INDIGO' | 'LIME' | 'ORANGE' | 'PINK' | 'PURPLE' | 'TEAL';
207239

240+
// @public
241+
export type UserProvidedSignals = {
242+
[key: string]: string | number;
243+
};
244+
208245
// @public
209246
export interface Value {
210247
asBoolean(): boolean;

src/remote-config/condition-evaluator-internal.ts

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import {
2323
NamedCondition,
2424
OrCondition,
2525
PercentCondition,
26-
PercentConditionOperator
26+
PercentConditionOperator,
27+
CustomSignalCondition,
28+
CustomSignalOperator,
2729
} from './remote-config-api';
2830
import * as farmhash from 'farmhash-modern';
2931

@@ -76,6 +78,9 @@ export class ConditionEvaluator {
7678
if (condition.percent) {
7779
return this.evaluatePercentCondition(condition.percent, context);
7880
}
81+
if (condition.customSignal) {
82+
return this.evaluateCustomSignalCondition(condition.customSignal, context);
83+
}
7984
// TODO: add logging once we have a wrapped logger.
8085
return false;
8186
}
@@ -167,7 +172,6 @@ export class ConditionEvaluator {
167172
return false;
168173
}
169174

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

183187
return hash64;
184188
}
189+
190+
private evaluateCustomSignalCondition(
191+
customSignalCondition: CustomSignalCondition,
192+
context: EvaluationContext
193+
): boolean {
194+
const {
195+
customSignalOperator,
196+
customSignalKey,
197+
targetCustomSignalValues,
198+
} = customSignalCondition;
199+
200+
if (!customSignalOperator || !customSignalKey || !targetCustomSignalValues) {
201+
// TODO: add logging once we have a wrapped logger.
202+
return false;
203+
}
204+
205+
if (!targetCustomSignalValues.length) {
206+
return false;
207+
}
208+
209+
// Extract the value of the signal from the evaluation context.
210+
const actualCustomSignalValue = context[customSignalKey];
211+
212+
if (actualCustomSignalValue == undefined) {
213+
return false
214+
}
215+
216+
switch (customSignalOperator) {
217+
case CustomSignalOperator.STRING_CONTAINS:
218+
return compareStrings(
219+
targetCustomSignalValues,
220+
actualCustomSignalValue,
221+
(target, actual) => actual.includes(target),
222+
);
223+
case CustomSignalOperator.STRING_DOES_NOT_CONTAIN:
224+
return !compareStrings(
225+
targetCustomSignalValues,
226+
actualCustomSignalValue,
227+
(target, actual) => actual.includes(target),
228+
);
229+
case CustomSignalOperator.STRING_EXACTLY_MATCHES:
230+
return compareStrings(
231+
targetCustomSignalValues,
232+
actualCustomSignalValue,
233+
(target, actual) => actual.trim() === target.trim(),
234+
);
235+
case CustomSignalOperator.STRING_CONTAINS_REGEX:
236+
return compareStrings(
237+
targetCustomSignalValues,
238+
actualCustomSignalValue,
239+
(target, actual) => new RegExp(target).test(actual),
240+
);
241+
242+
// For numeric operators only one target value is allowed.
243+
case CustomSignalOperator.NUMERIC_LESS_THAN:
244+
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r < 0);
245+
case CustomSignalOperator.NUMERIC_LESS_EQUAL:
246+
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r <= 0);
247+
case CustomSignalOperator.NUMERIC_EQUAL:
248+
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r === 0);
249+
case CustomSignalOperator.NUMERIC_NOT_EQUAL:
250+
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r !== 0);
251+
case CustomSignalOperator.NUMERIC_GREATER_THAN:
252+
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r > 0);
253+
case CustomSignalOperator.NUMERIC_GREATER_EQUAL:
254+
return compareNumbers(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r >= 0);
255+
256+
// For semantic operators only one target value is allowed.
257+
case CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN:
258+
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r < 0);
259+
case CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL:
260+
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r <= 0);
261+
case CustomSignalOperator.SEMANTIC_VERSION_EQUAL:
262+
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r === 0);
263+
case CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL:
264+
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r !== 0);
265+
case CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN:
266+
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r > 0);
267+
case CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL:
268+
return compareSemanticVersions(actualCustomSignalValue, targetCustomSignalValues[0], (r) => r >= 0);
269+
}
270+
271+
// TODO: add logging once we have a wrapped logger.
272+
return false;
273+
}
274+
}
275+
276+
// Compares the actual string value of a signal against a list of target
277+
// values. If any of the target values are a match, returns true.
278+
function compareStrings(
279+
targetValues: Array<string>,
280+
actualValue: string|number,
281+
predicateFn: (target: string, actual: string) => boolean
282+
): boolean {
283+
const actual = String(actualValue);
284+
return targetValues.some((target) => predicateFn(target, actual));
285+
}
286+
287+
// Compares two numbers against each other.
288+
// Calls the predicate function with -1, 0, 1 if actual is less than, equal to, or greater than target.
289+
function compareNumbers(
290+
actualValue: string|number,
291+
targetValue: string,
292+
predicateFn: (result: number) => boolean
293+
): boolean {
294+
const target = Number(targetValue);
295+
const actual = Number(actualValue);
296+
if (isNaN(target) || isNaN(actual)) {
297+
return false;
298+
}
299+
return predicateFn(actual < target ? -1 : actual > target ? 1 : 0);
300+
}
301+
302+
// Max number of segments a numeric version can have. This is enforced by the server as well.
303+
const MAX_LENGTH = 5;
304+
305+
// Compares semantic version strings against each other.
306+
// Calls the predicate function with -1, 0, 1 if actual is less than, equal to, or greater than target.
307+
function compareSemanticVersions(
308+
actualValue: string|number,
309+
targetValue: string,
310+
predicateFn: (result: number) => boolean
311+
): boolean {
312+
const version1 = String(actualValue).split('.').map(Number);
313+
const version2 = targetValue.split('.').map(Number);
314+
315+
for (let i = 0; i < MAX_LENGTH; i++) {
316+
// Check to see if segments are present. Note that these may be present and be NaN.
317+
const version1HasSegment = version1[i] !== undefined;
318+
const version2HasSegment = version2[i] !== undefined;
319+
320+
// If both are undefined, we've consumed everything and they're equal.
321+
if (!version1HasSegment && !version2HasSegment) return predicateFn(0)
322+
323+
// Insert zeros if undefined for easier comparison.
324+
if (!version1HasSegment) version1[i] = 0;
325+
if (!version2HasSegment) version2[i] = 0;
326+
327+
// At this point, if either segment is NaN, we return false directly.
328+
if (isNaN(version1[i]) || isNaN(version2[i])) return false;
329+
330+
// Check if we have a difference in segments. Otherwise continue to next segment.
331+
if (version1[i] < version2[i]) return predicateFn(-1);
332+
if (version1[i] > version2[i]) return predicateFn(1);
333+
}
334+
return false;
185335
}

src/remote-config/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { RemoteConfig } from './remote-config';
2626

2727
export {
2828
AndCondition,
29+
CustomSignalCondition,
30+
CustomSignalOperator,
2931
DefaultConfig,
3032
EvaluationContext,
3133
ExplicitParameterValue,
@@ -41,6 +43,7 @@ export {
4143
ParameterValueType,
4244
PercentConditionOperator,
4345
PercentCondition,
46+
PredefinedSignals,
4447
RemoteConfigCondition,
4548
RemoteConfigParameter,
4649
RemoteConfigParameterGroup,
@@ -52,6 +55,7 @@ export {
5255
ServerTemplateData,
5356
ServerTemplateDataType,
5457
TagColor,
58+
UserProvidedSignals,
5559
Value,
5660
ValueSource,
5761
Version,

0 commit comments

Comments
 (0)