@fallom/trace 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -187,6 +187,59 @@ const model = await models.get("summarizer-config", sessionId, {
187
187
  });
188
188
  ```
189
189
 
190
+ ### User Targeting (LaunchDarkly-style)
191
+
192
+ Override weighted distribution for specific users or segments. Targeting rules are evaluated client-side for zero latency.
193
+
194
+ ```typescript
195
+ import { models } from "@fallom/trace";
196
+
197
+ // Target specific users to specific variants
198
+ const model = await models.get("my-config", sessionId, {
199
+ fallback: "gpt-4o-mini",
200
+ customerId: "user-123", // For individual targeting
201
+ context: { // For rule-based targeting
202
+ plan: "enterprise",
203
+ region: "us-west",
204
+ },
205
+ });
206
+ ```
207
+
208
+ **Evaluation order:**
209
+ 1. **Individual Targets** - Exact match on `customerId` or any field
210
+ 2. **Rules** - Condition-based targeting (all conditions must match)
211
+ 3. **Fallback** - Weighted random distribution
212
+
213
+ **Configure targeting in the dashboard:**
214
+
215
+ ```json
216
+ {
217
+ "enabled": true,
218
+ "individualTargets": [
219
+ { "field": "customerId", "value": "vip-user-123", "variantIndex": 1 }
220
+ ],
221
+ "rules": [
222
+ {
223
+ "conditions": [
224
+ { "field": "plan", "operator": "eq", "value": "enterprise" }
225
+ ],
226
+ "variantIndex": 1
227
+ }
228
+ ]
229
+ }
230
+ ```
231
+
232
+ **Supported operators:**
233
+ | Operator | Description | Example |
234
+ |----------|-------------|---------|
235
+ | `eq` | Equals | `plan = "enterprise"` |
236
+ | `neq` | Not equals | `plan ≠ "free"` |
237
+ | `in` | In list | `plan in ["enterprise", "business"]` |
238
+ | `nin` | Not in list | `region not in ["cn", "ru"]` |
239
+ | `contains` | Contains substring | `email contains "@acme.com"` |
240
+ | `startsWith` | Starts with | `region starts with "eu-"` |
241
+ | `endsWith` | Ends with | `email ends with ".gov"` |
242
+
190
243
  ## Prompt Management
191
244
 
192
245
  Manage prompts centrally and A/B test them.
@@ -295,11 +348,22 @@ Get model assignment for A/B testing.
295
348
 
296
349
  ```typescript
297
350
  const model = await models.get("my-config", sessionId, {
298
- fallback: "gpt-4o-mini", // used if config not found
299
- version: 2, // pin to specific config version
351
+ fallback: "gpt-4o-mini", // used if config not found
352
+ version: 2, // pin to specific config version
353
+ customerId: "user-123", // for individual targeting
354
+ context: { plan: "enterprise" }, // for rule-based targeting
355
+ debug: false, // enable debug logging
300
356
  });
301
357
  ```
302
358
 
359
+ | Option | Type | Description |
360
+ |--------|------|-------------|
361
+ | `fallback` | `string` | Model to return if config not found |
362
+ | `version` | `number` | Pin to specific config version |
363
+ | `customerId` | `string` | User ID for individual targeting |
364
+ | `context` | `Record<string, string>` | Context for rule-based targeting |
365
+ | `debug` | `boolean` | Enable debug logging |
366
+
303
367
  ### `fallom.prompts.get(promptKey, options?)`
304
368
 
305
369
  Get a managed prompt.
@@ -24,6 +24,57 @@ function log(msg) {
24
24
  console.log(`[Fallom] ${msg}`);
25
25
  }
26
26
  }
27
+ function evaluateTargeting(targeting, customerId, context) {
28
+ if (!targeting || targeting.enabled === false) {
29
+ return null;
30
+ }
31
+ const evalContext = {
32
+ ...context || {},
33
+ ...customerId ? { customerId } : {}
34
+ };
35
+ log(`Evaluating targeting with context: ${JSON.stringify(evalContext)}`);
36
+ if (targeting.individualTargets) {
37
+ for (const target of targeting.individualTargets) {
38
+ const fieldValue = evalContext[target.field];
39
+ if (fieldValue === target.value) {
40
+ log(`Individual target matched: ${target.field}=${target.value} -> variant ${target.variantIndex}`);
41
+ return target.variantIndex;
42
+ }
43
+ }
44
+ }
45
+ if (targeting.rules) {
46
+ for (const rule of targeting.rules) {
47
+ const allConditionsMatch = rule.conditions.every((condition) => {
48
+ const fieldValue = evalContext[condition.field];
49
+ if (fieldValue === void 0) return false;
50
+ switch (condition.operator) {
51
+ case "eq":
52
+ return fieldValue === condition.value;
53
+ case "neq":
54
+ return fieldValue !== condition.value;
55
+ case "in":
56
+ return Array.isArray(condition.value) && condition.value.includes(fieldValue);
57
+ case "nin":
58
+ return Array.isArray(condition.value) && !condition.value.includes(fieldValue);
59
+ case "contains":
60
+ return typeof condition.value === "string" && fieldValue.includes(condition.value);
61
+ case "startsWith":
62
+ return typeof condition.value === "string" && fieldValue.startsWith(condition.value);
63
+ case "endsWith":
64
+ return typeof condition.value === "string" && fieldValue.endsWith(condition.value);
65
+ default:
66
+ return false;
67
+ }
68
+ });
69
+ if (allConditionsMatch) {
70
+ log(`Rule matched: ${JSON.stringify(rule.conditions)} -> variant ${rule.variantIndex}`);
71
+ return rule.variantIndex;
72
+ }
73
+ }
74
+ }
75
+ log("No targeting rules matched, falling back to weighted random");
76
+ return null;
77
+ }
27
78
  function init(options = {}) {
28
79
  apiKey = options.apiKey || process.env.FALLOM_API_KEY || null;
29
80
  baseUrl = options.baseUrl || process.env.FALLOM_CONFIGS_URL || process.env.FALLOM_BASE_URL || "https://configs.fallom.com";
@@ -112,7 +163,7 @@ async function fetchSpecificVersion(configKey, version, timeout = SYNC_TIMEOUT)
112
163
  return null;
113
164
  }
114
165
  async function get(configKey, sessionId, options = {}) {
115
- const { version, fallback, debug = false } = options;
166
+ const { version, fallback, customerId, context, debug = false } = options;
116
167
  debugMode = debug;
117
168
  ensureInit();
118
169
  log(
@@ -181,6 +232,12 @@ async function get(configKey, sessionId, options = {}) {
181
232
  variants
182
233
  )}`
183
234
  );
235
+ const targetedVariantIndex = evaluateTargeting(config.targeting, customerId, context);
236
+ if (targetedVariantIndex !== null && variants[targetedVariantIndex]) {
237
+ const assignedModel2 = variants[targetedVariantIndex].model;
238
+ log(`\u2705 Assigned model via targeting: ${assignedModel2}`);
239
+ return returnModel(configKey, sessionId, assignedModel2, configVersion);
240
+ }
184
241
  const hashBytes = createHash("md5").update(sessionId).digest();
185
242
  const hashVal = hashBytes.readUInt32BE(0) % 1e6;
186
243
  log(`Session hash: ${hashVal} (out of 1,000,000)`);
@@ -197,7 +254,7 @@ async function get(configKey, sessionId, options = {}) {
197
254
  break;
198
255
  }
199
256
  }
200
- log(`\u2705 Assigned model: ${assignedModel}`);
257
+ log(`\u2705 Assigned model via weighted random: ${assignedModel}`);
201
258
  return returnModel(configKey, sessionId, assignedModel, configVersion);
202
259
  } catch (e) {
203
260
  if (e instanceof Error && e.message.includes("not found")) {
package/dist/index.d.mts CHANGED
@@ -261,6 +261,8 @@ declare function init$2(options?: {
261
261
  * @param options - Optional settings
262
262
  * @param options.version - Pin to specific version (1, 2, etc). undefined = latest
263
263
  * @param options.fallback - Model to return if config not found or Fallom is down
264
+ * @param options.customerId - User ID for individual targeting (e.g., "user-123")
265
+ * @param options.context - Additional context for rule-based targeting (e.g., { plan: "enterprise" })
264
266
  * @param options.debug - Enable debug logging
265
267
  * @returns Model string (e.g., "claude-opus", "gpt-4o")
266
268
  * @throws Error if config not found AND no fallback provided
@@ -268,6 +270,8 @@ declare function init$2(options?: {
268
270
  declare function get$1(configKey: string, sessionId: string, options?: {
269
271
  version?: number;
270
272
  fallback?: string;
273
+ customerId?: string;
274
+ context?: Record<string, string>;
271
275
  debug?: boolean;
272
276
  }): Promise<string>;
273
277
 
package/dist/index.d.ts CHANGED
@@ -261,6 +261,8 @@ declare function init$2(options?: {
261
261
  * @param options - Optional settings
262
262
  * @param options.version - Pin to specific version (1, 2, etc). undefined = latest
263
263
  * @param options.fallback - Model to return if config not found or Fallom is down
264
+ * @param options.customerId - User ID for individual targeting (e.g., "user-123")
265
+ * @param options.context - Additional context for rule-based targeting (e.g., { plan: "enterprise" })
264
266
  * @param options.debug - Enable debug logging
265
267
  * @returns Model string (e.g., "claude-opus", "gpt-4o")
266
268
  * @throws Error if config not found AND no fallback provided
@@ -268,6 +270,8 @@ declare function init$2(options?: {
268
270
  declare function get$1(configKey: string, sessionId: string, options?: {
269
271
  version?: number;
270
272
  fallback?: string;
273
+ customerId?: string;
274
+ context?: Record<string, string>;
271
275
  debug?: boolean;
272
276
  }): Promise<string>;
273
277
 
package/dist/index.js CHANGED
@@ -31,6 +31,57 @@ function log3(msg) {
31
31
  console.log(`[Fallom] ${msg}`);
32
32
  }
33
33
  }
34
+ function evaluateTargeting(targeting, customerId, context) {
35
+ if (!targeting || targeting.enabled === false) {
36
+ return null;
37
+ }
38
+ const evalContext = {
39
+ ...context || {},
40
+ ...customerId ? { customerId } : {}
41
+ };
42
+ log3(`Evaluating targeting with context: ${JSON.stringify(evalContext)}`);
43
+ if (targeting.individualTargets) {
44
+ for (const target of targeting.individualTargets) {
45
+ const fieldValue = evalContext[target.field];
46
+ if (fieldValue === target.value) {
47
+ log3(`Individual target matched: ${target.field}=${target.value} -> variant ${target.variantIndex}`);
48
+ return target.variantIndex;
49
+ }
50
+ }
51
+ }
52
+ if (targeting.rules) {
53
+ for (const rule of targeting.rules) {
54
+ const allConditionsMatch = rule.conditions.every((condition) => {
55
+ const fieldValue = evalContext[condition.field];
56
+ if (fieldValue === void 0) return false;
57
+ switch (condition.operator) {
58
+ case "eq":
59
+ return fieldValue === condition.value;
60
+ case "neq":
61
+ return fieldValue !== condition.value;
62
+ case "in":
63
+ return Array.isArray(condition.value) && condition.value.includes(fieldValue);
64
+ case "nin":
65
+ return Array.isArray(condition.value) && !condition.value.includes(fieldValue);
66
+ case "contains":
67
+ return typeof condition.value === "string" && fieldValue.includes(condition.value);
68
+ case "startsWith":
69
+ return typeof condition.value === "string" && fieldValue.startsWith(condition.value);
70
+ case "endsWith":
71
+ return typeof condition.value === "string" && fieldValue.endsWith(condition.value);
72
+ default:
73
+ return false;
74
+ }
75
+ });
76
+ if (allConditionsMatch) {
77
+ log3(`Rule matched: ${JSON.stringify(rule.conditions)} -> variant ${rule.variantIndex}`);
78
+ return rule.variantIndex;
79
+ }
80
+ }
81
+ }
82
+ log3("No targeting rules matched, falling back to weighted random");
83
+ return null;
84
+ }
34
85
  function init2(options = {}) {
35
86
  apiKey2 = options.apiKey || process.env.FALLOM_API_KEY || null;
36
87
  baseUrl2 = options.baseUrl || process.env.FALLOM_CONFIGS_URL || process.env.FALLOM_BASE_URL || "https://configs.fallom.com";
@@ -119,7 +170,7 @@ async function fetchSpecificVersion(configKey, version, timeout = SYNC_TIMEOUT)
119
170
  return null;
120
171
  }
121
172
  async function get(configKey, sessionId, options = {}) {
122
- const { version, fallback, debug = false } = options;
173
+ const { version, fallback, customerId, context, debug = false } = options;
123
174
  debugMode2 = debug;
124
175
  ensureInit();
125
176
  log3(
@@ -188,6 +239,12 @@ async function get(configKey, sessionId, options = {}) {
188
239
  variants
189
240
  )}`
190
241
  );
242
+ const targetedVariantIndex = evaluateTargeting(config.targeting, customerId, context);
243
+ if (targetedVariantIndex !== null && variants[targetedVariantIndex]) {
244
+ const assignedModel2 = variants[targetedVariantIndex].model;
245
+ log3(`\u2705 Assigned model via targeting: ${assignedModel2}`);
246
+ return returnModel(configKey, sessionId, assignedModel2, configVersion);
247
+ }
191
248
  const hashBytes = (0, import_crypto.createHash)("md5").update(sessionId).digest();
192
249
  const hashVal = hashBytes.readUInt32BE(0) % 1e6;
193
250
  log3(`Session hash: ${hashVal} (out of 1,000,000)`);
@@ -204,7 +261,7 @@ async function get(configKey, sessionId, options = {}) {
204
261
  break;
205
262
  }
206
263
  }
207
- log3(`\u2705 Assigned model: ${assignedModel}`);
264
+ log3(`\u2705 Assigned model via weighted random: ${assignedModel}`);
208
265
  return returnModel(configKey, sessionId, assignedModel, configVersion);
209
266
  } catch (e) {
210
267
  if (e instanceof Error && e.message.includes("not found")) {
package/dist/index.mjs CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  __export,
3
3
  init,
4
4
  models_exports
5
- } from "./chunk-W6M2RQ3W.mjs";
5
+ } from "./chunk-KFD5AQ7V.mjs";
6
6
 
7
7
  // src/trace.ts
8
8
  var trace_exports = {};
@@ -1602,7 +1602,7 @@ var FallomSession = class {
1602
1602
  configKey = this.ctx.configKey;
1603
1603
  opts = configKeyOrOptions || {};
1604
1604
  }
1605
- const { get: get2 } = await import("./models-JKMOBZUO.mjs");
1605
+ const { get: get2 } = await import("./models-SEFDGZU2.mjs");
1606
1606
  return get2(configKey, this.ctx.sessionId, opts);
1607
1607
  }
1608
1608
  /**
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  get,
3
3
  init
4
- } from "./chunk-W6M2RQ3W.mjs";
4
+ } from "./chunk-KFD5AQ7V.mjs";
5
5
  export {
6
6
  get,
7
7
  init
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fallom/trace",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Model A/B testing and tracing for LLM applications. Zero latency, production-ready.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",