@terreno/feature-flags 57.16.1 → 57.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/evaluate.js +11 -10
- package/package.json +3 -3
- package/src/evaluate.ts +11 -12
- package/src/tests/evaluate.test.ts +56 -1
package/dist/evaluate.js
CHANGED
|
@@ -6,7 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.evaluateAllFlags = exports.evaluateFlag = exports.deterministicHash = void 0;
|
|
7
7
|
const api_1 = require("@terreno/api");
|
|
8
8
|
const get_1 = __importDefault(require("lodash/get"));
|
|
9
|
-
|
|
9
|
+
// Evaluation diagnostics are opt-in and never include the user's attribute values.
|
|
10
|
+
const isDebugEnabled = () => process.env.FEATURE_FLAGS_DEBUG === "true";
|
|
10
11
|
/**
|
|
11
12
|
* Deterministic hash of a string to a number between 0 and 99.
|
|
12
13
|
* Uses a simple but effective hash (djb2) to ensure the same user+flag
|
|
@@ -58,7 +59,7 @@ const matchesFieldRule = (user, rule) => {
|
|
|
58
59
|
break;
|
|
59
60
|
}
|
|
60
61
|
if (isDebugEnabled()) {
|
|
61
|
-
api_1.logger.
|
|
62
|
+
api_1.logger.debug(`[feature-flags] field rule: user.${rule.field} ${rule.operator} → ${result}`);
|
|
62
63
|
}
|
|
63
64
|
return result;
|
|
64
65
|
};
|
|
@@ -72,7 +73,7 @@ const matchesRule = (user, rule, segments) => {
|
|
|
72
73
|
try {
|
|
73
74
|
const result = segmentFn(user);
|
|
74
75
|
if (isDebugEnabled()) {
|
|
75
|
-
api_1.logger.
|
|
76
|
+
api_1.logger.debug(`[feature-flags] segment rule: "${rule.segment}" → ${result}`);
|
|
76
77
|
}
|
|
77
78
|
return result;
|
|
78
79
|
}
|
|
@@ -91,7 +92,7 @@ const evaluateFlag = (flag, userId, user, segments) => {
|
|
|
91
92
|
const debug = isDebugEnabled();
|
|
92
93
|
if (!flag.enabled) {
|
|
93
94
|
if (debug) {
|
|
94
|
-
api_1.logger.
|
|
95
|
+
api_1.logger.debug(`[feature-flags] "${flag.key}" is disabled → ${flag.type === "variant" ? "null" : "false"}`);
|
|
95
96
|
}
|
|
96
97
|
return flag.type === "variant" ? null : false;
|
|
97
98
|
}
|
|
@@ -102,7 +103,7 @@ const evaluateFlag = (flag, userId, user, segments) => {
|
|
|
102
103
|
if (matched) {
|
|
103
104
|
const result = flag.type === "variant" ? (rule.variant ?? null) : (rule.enabled ?? false);
|
|
104
105
|
if (debug) {
|
|
105
|
-
api_1.logger.
|
|
106
|
+
api_1.logger.debug(`[feature-flags] "${flag.key}" matched rule ${i} → ${JSON.stringify(result)}`);
|
|
106
107
|
}
|
|
107
108
|
return result;
|
|
108
109
|
}
|
|
@@ -112,7 +113,7 @@ const evaluateFlag = (flag, userId, user, segments) => {
|
|
|
112
113
|
if (flag.type === "boolean") {
|
|
113
114
|
const result = hash < flag.rolloutPercentage;
|
|
114
115
|
if (debug) {
|
|
115
|
-
api_1.logger.
|
|
116
|
+
api_1.logger.debug(`[feature-flags] "${flag.key}" no rules matched, hash=${hash} rollout=${flag.rolloutPercentage}% → ${result}`);
|
|
116
117
|
}
|
|
117
118
|
return result;
|
|
118
119
|
}
|
|
@@ -122,7 +123,7 @@ const evaluateFlag = (flag, userId, user, segments) => {
|
|
|
122
123
|
cumulativeWeight += variant.weight;
|
|
123
124
|
if (hash < cumulativeWeight) {
|
|
124
125
|
if (debug) {
|
|
125
|
-
api_1.logger.
|
|
126
|
+
api_1.logger.debug(`[feature-flags] "${flag.key}" no rules matched, hash=${hash} → variant "${variant.key}"`);
|
|
126
127
|
}
|
|
127
128
|
return variant.key;
|
|
128
129
|
}
|
|
@@ -130,7 +131,7 @@ const evaluateFlag = (flag, userId, user, segments) => {
|
|
|
130
131
|
// Fallback (shouldn't happen if weights sum to 100)
|
|
131
132
|
const fallback = flag.variants.length > 0 ? flag.variants[flag.variants.length - 1].key : null;
|
|
132
133
|
if (debug) {
|
|
133
|
-
api_1.logger.
|
|
134
|
+
api_1.logger.debug(`[feature-flags] "${flag.key}" variant fallback → ${JSON.stringify(fallback)}`);
|
|
134
135
|
}
|
|
135
136
|
return fallback;
|
|
136
137
|
};
|
|
@@ -143,13 +144,13 @@ const evaluateAllFlags = async (flagModel, userId, user, segments) => {
|
|
|
143
144
|
const flags = await flagModel.find({ archived: { $ne: true }, enabled: true });
|
|
144
145
|
const results = {};
|
|
145
146
|
if (isDebugEnabled()) {
|
|
146
|
-
api_1.logger.
|
|
147
|
+
api_1.logger.debug(`[feature-flags] evaluating ${flags.length} flags for user ${userId}`);
|
|
147
148
|
}
|
|
148
149
|
for (const flag of flags) {
|
|
149
150
|
results[flag.key] = (0, exports.evaluateFlag)(flag, userId, user, segments);
|
|
150
151
|
}
|
|
151
152
|
if (isDebugEnabled()) {
|
|
152
|
-
api_1.logger.
|
|
153
|
+
api_1.logger.debug(`[feature-flags] results: ${JSON.stringify(results)}`);
|
|
153
154
|
}
|
|
154
155
|
return results;
|
|
155
156
|
};
|
package/package.json
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@openfeature/server-sdk": "^1.22.0",
|
|
8
|
-
"@terreno/api": "57.
|
|
8
|
+
"@terreno/api": "57.17.0",
|
|
9
9
|
"express": "^5.2.1",
|
|
10
10
|
"lodash": "^4.18.1"
|
|
11
11
|
},
|
|
12
12
|
"description": "Feature flags and A/B testing plugin for @terreno/api — part of Terreno, the batteries-included TypeScript framework for universal apps.",
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"@biomejs/biome": "^2.3.6",
|
|
15
|
-
"@terreno/test": "57.
|
|
15
|
+
"@terreno/test": "57.17.0",
|
|
16
16
|
"@types/bun": "^1.4.0",
|
|
17
17
|
"@types/express": "^5.0.6",
|
|
18
18
|
"@types/lodash": "^4.17.17",
|
|
@@ -53,5 +53,5 @@
|
|
|
53
53
|
"test:coverage": "bun run ../scripts/check-coverage.ts"
|
|
54
54
|
},
|
|
55
55
|
"types": "dist/index.d.ts",
|
|
56
|
-
"version": "57.
|
|
56
|
+
"version": "57.17.0"
|
|
57
57
|
}
|
package/src/evaluate.ts
CHANGED
|
@@ -7,7 +7,8 @@ import type {
|
|
|
7
7
|
SegmentFunction,
|
|
8
8
|
} from "./types";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Evaluation diagnostics are opt-in and never include the user's attribute values.
|
|
11
|
+
const isDebugEnabled = (): boolean => process.env.FEATURE_FLAGS_DEBUG === "true";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Deterministic hash of a string to a number between 0 and 99.
|
|
@@ -63,9 +64,7 @@ const matchesFieldRule = (user: unknown, rule: FeatureFlagRule): boolean => {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
if (isDebugEnabled()) {
|
|
66
|
-
logger.
|
|
67
|
-
`[feature-flags] field rule: user.${rule.field}=${JSON.stringify(fieldValue)} ${rule.operator} ${JSON.stringify(ruleValue)} → ${result}`
|
|
68
|
-
);
|
|
67
|
+
logger.debug(`[feature-flags] field rule: user.${rule.field} ${rule.operator} → ${result}`);
|
|
69
68
|
}
|
|
70
69
|
|
|
71
70
|
return result;
|
|
@@ -85,7 +84,7 @@ const matchesRule = (
|
|
|
85
84
|
try {
|
|
86
85
|
const result = segmentFn(user);
|
|
87
86
|
if (isDebugEnabled()) {
|
|
88
|
-
logger.
|
|
87
|
+
logger.debug(`[feature-flags] segment rule: "${rule.segment}" → ${result}`);
|
|
89
88
|
}
|
|
90
89
|
return result;
|
|
91
90
|
} catch (err) {
|
|
@@ -111,7 +110,7 @@ export const evaluateFlag = (
|
|
|
111
110
|
|
|
112
111
|
if (!flag.enabled) {
|
|
113
112
|
if (debug) {
|
|
114
|
-
logger.
|
|
113
|
+
logger.debug(
|
|
115
114
|
`[feature-flags] "${flag.key}" is disabled → ${flag.type === "variant" ? "null" : "false"}`
|
|
116
115
|
);
|
|
117
116
|
}
|
|
@@ -125,7 +124,7 @@ export const evaluateFlag = (
|
|
|
125
124
|
if (matched) {
|
|
126
125
|
const result = flag.type === "variant" ? (rule.variant ?? null) : (rule.enabled ?? false);
|
|
127
126
|
if (debug) {
|
|
128
|
-
logger.
|
|
127
|
+
logger.debug(`[feature-flags] "${flag.key}" matched rule ${i} → ${JSON.stringify(result)}`);
|
|
129
128
|
}
|
|
130
129
|
return result;
|
|
131
130
|
}
|
|
@@ -137,7 +136,7 @@ export const evaluateFlag = (
|
|
|
137
136
|
if (flag.type === "boolean") {
|
|
138
137
|
const result = hash < flag.rolloutPercentage;
|
|
139
138
|
if (debug) {
|
|
140
|
-
logger.
|
|
139
|
+
logger.debug(
|
|
141
140
|
`[feature-flags] "${flag.key}" no rules matched, hash=${hash} rollout=${flag.rolloutPercentage}% → ${result}`
|
|
142
141
|
);
|
|
143
142
|
}
|
|
@@ -150,7 +149,7 @@ export const evaluateFlag = (
|
|
|
150
149
|
cumulativeWeight += variant.weight;
|
|
151
150
|
if (hash < cumulativeWeight) {
|
|
152
151
|
if (debug) {
|
|
153
|
-
logger.
|
|
152
|
+
logger.debug(
|
|
154
153
|
`[feature-flags] "${flag.key}" no rules matched, hash=${hash} → variant "${variant.key}"`
|
|
155
154
|
);
|
|
156
155
|
}
|
|
@@ -161,7 +160,7 @@ export const evaluateFlag = (
|
|
|
161
160
|
// Fallback (shouldn't happen if weights sum to 100)
|
|
162
161
|
const fallback = flag.variants.length > 0 ? flag.variants[flag.variants.length - 1].key : null;
|
|
163
162
|
if (debug) {
|
|
164
|
-
logger.
|
|
163
|
+
logger.debug(`[feature-flags] "${flag.key}" variant fallback → ${JSON.stringify(fallback)}`);
|
|
165
164
|
}
|
|
166
165
|
return fallback;
|
|
167
166
|
};
|
|
@@ -180,7 +179,7 @@ export const evaluateAllFlags = async (
|
|
|
180
179
|
const results: EvaluationResult = {};
|
|
181
180
|
|
|
182
181
|
if (isDebugEnabled()) {
|
|
183
|
-
logger.
|
|
182
|
+
logger.debug(`[feature-flags] evaluating ${flags.length} flags for user ${userId}`);
|
|
184
183
|
}
|
|
185
184
|
|
|
186
185
|
for (const flag of flags) {
|
|
@@ -188,7 +187,7 @@ export const evaluateAllFlags = async (
|
|
|
188
187
|
}
|
|
189
188
|
|
|
190
189
|
if (isDebugEnabled()) {
|
|
191
|
-
logger.
|
|
190
|
+
logger.debug(`[feature-flags] results: ${JSON.stringify(results)}`);
|
|
192
191
|
}
|
|
193
192
|
|
|
194
193
|
return results;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {describe, expect, it} from "bun:test";
|
|
1
|
+
import {afterEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import {logger} from "@terreno/api";
|
|
2
3
|
import {deterministicHash, evaluateAllFlags, evaluateFlag} from "../evaluate";
|
|
3
4
|
import type {FeatureFlagDocument, FeatureFlagModel, SegmentFunction} from "../types";
|
|
4
5
|
|
|
@@ -515,6 +516,60 @@ describe("evaluateFlag", () => {
|
|
|
515
516
|
});
|
|
516
517
|
});
|
|
517
518
|
|
|
519
|
+
describe("evaluation diagnostics", () => {
|
|
520
|
+
const originalDebugEnv = process.env.FEATURE_FLAGS_DEBUG;
|
|
521
|
+
const originalInfo = logger.info;
|
|
522
|
+
const originalDebug = logger.debug;
|
|
523
|
+
|
|
524
|
+
const captureLogs = (): string[] => {
|
|
525
|
+
const lines: string[] = [];
|
|
526
|
+
logger.info = ((message: string) => {
|
|
527
|
+
lines.push(message);
|
|
528
|
+
}) as typeof logger.info;
|
|
529
|
+
logger.debug = ((message: string) => {
|
|
530
|
+
lines.push(message);
|
|
531
|
+
}) as typeof logger.debug;
|
|
532
|
+
return lines;
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const emailRuleFlag = (): FeatureFlagDocument =>
|
|
536
|
+
createFlag({
|
|
537
|
+
key: "staff-only",
|
|
538
|
+
rolloutPercentage: 0,
|
|
539
|
+
rules: [{enabled: true, field: "email", operator: "contains", value: "@staff.example"}],
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
afterEach(() => {
|
|
543
|
+
logger.info = originalInfo;
|
|
544
|
+
logger.debug = originalDebug;
|
|
545
|
+
if (originalDebugEnv === undefined) {
|
|
546
|
+
delete process.env.FEATURE_FLAGS_DEBUG;
|
|
547
|
+
} else {
|
|
548
|
+
process.env.FEATURE_FLAGS_DEBUG = originalDebugEnv;
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("logs nothing about a user's attributes by default", () => {
|
|
553
|
+
delete process.env.FEATURE_FLAGS_DEBUG;
|
|
554
|
+
const lines = captureLogs();
|
|
555
|
+
|
|
556
|
+
evaluateFlag(emailRuleFlag(), "user1", {email: "dummy-user@example.com"}, noSegments);
|
|
557
|
+
|
|
558
|
+
expect(lines.join("\n")).not.toContain("dummy-user@example.com");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("never logs the user's attribute value when debug logging is on", () => {
|
|
562
|
+
process.env.FEATURE_FLAGS_DEBUG = "true";
|
|
563
|
+
const lines = captureLogs();
|
|
564
|
+
|
|
565
|
+
evaluateFlag(emailRuleFlag(), "user1", {email: "dummy-user@example.com"}, noSegments);
|
|
566
|
+
|
|
567
|
+
const combined = lines.join("\n");
|
|
568
|
+
expect(combined).not.toContain("dummy-user@example.com");
|
|
569
|
+
expect(combined).toContain("user.email contains");
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
518
573
|
describe("evaluateAllFlags", () => {
|
|
519
574
|
const makeMockModel = (flags: FeatureFlagDocument[]): FeatureFlagModel => {
|
|
520
575
|
return {
|