@avsbhq/node 1.6.1 → 1.7.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/LICENSE +21 -0
- package/README.md +576 -272
- package/dist/index.cjs +917 -524
- package/dist/index.d.cts +392 -135
- package/dist/index.d.ts +392 -135
- package/dist/index.js +891 -508
- package/package.json +33 -11
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @avsbhq/node
|
|
2
2
|
|
|
3
|
-
Node.js server
|
|
3
|
+
Node.js server SDK for [A vs B](https://app.avsb.cloud).
|
|
4
4
|
|
|
5
|
-
Evaluate feature flags, bucket users consistently, and track
|
|
5
|
+
Evaluate feature flags, bucket users consistently, and track conversions on the server. Includes Express and Fastify middleware, real-time datafile streaming over SSE (on by default), pluggable sticky bucketing (in-memory and Redis), multi-context evaluation, and per-request decision logs.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -12,62 +12,124 @@ Evaluate feature flags, bucket users consistently, and track conversion events o
|
|
|
12
12
|
npm install @avsbhq/node
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Requires Node.js 18 or later.
|
|
15
|
+
Requires Node.js 18 or later. No mandatory peer dependencies.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Install `@avsbhq/utils` as well for the other framework adapters (Hono, Koa, Next, Lambda, Cloudflare Workers), the OpenFeature provider, and the warehouse decision sinks.
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
21
21
|
## 2. Quickstart
|
|
22
22
|
|
|
23
23
|
```ts
|
|
24
|
-
import { AvsbServer } from '@avsbhq/node'
|
|
24
|
+
import { AvsbServer } from '@avsbhq/node';
|
|
25
25
|
|
|
26
|
-
const server = new AvsbServer({
|
|
27
|
-
sdkKey: process.env.AVSB_SDK_KEY!,
|
|
28
|
-
})
|
|
26
|
+
const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! });
|
|
29
27
|
|
|
30
|
-
const result = await server.onReady()
|
|
28
|
+
const result = await server.onReady();
|
|
31
29
|
if (!result.success) {
|
|
32
|
-
|
|
30
|
+
// Flags serve the defaults you pass until a datafile arrives, and polling
|
|
31
|
+
// continues in the background. Survivable, not fatal.
|
|
32
|
+
console.error('AvsB init failed', result.error);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
const user = server.forUser({ kind: 'user', key: 'u_123', plan: 'pro' })
|
|
35
|
+
// Bind the context once per request.
|
|
36
|
+
const user = server.forUser({ kind: 'user', key: 'u_123', plan: 'pro' });
|
|
37
37
|
|
|
38
|
-
const flag = user.getBoolFlag('new-checkout-flow', false)
|
|
39
|
-
|
|
40
|
-
renderNewCheckout()
|
|
41
|
-
}
|
|
38
|
+
const flag = user.getBoolFlag('new-checkout-flow', false);
|
|
39
|
+
const checkoutTemplate = flag.isEnabled() ? 'checkout-v2' : 'checkout-v1';
|
|
42
40
|
|
|
43
|
-
user.track('checkout_started', {
|
|
41
|
+
user.track('checkout_started', { revenue: 99.0 });
|
|
44
42
|
```
|
|
45
43
|
|
|
46
|
-
|
|
44
|
+
Construct `AvsbServer` once at boot. It holds the datafile in memory, refreshes over SSE when the environment publishes, and falls back to polling when streaming is unavailable.
|
|
47
45
|
|
|
48
46
|
---
|
|
49
47
|
|
|
50
|
-
## 3.
|
|
48
|
+
## 3. Constructor options
|
|
49
|
+
|
|
50
|
+
Every type named on this page is exported by the package, so the shapes below (and your own declarations) can import them:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import type {
|
|
54
|
+
DecideOption,
|
|
55
|
+
DecisionLog,
|
|
56
|
+
EvalContext,
|
|
57
|
+
EvaluationSource,
|
|
58
|
+
Flag,
|
|
59
|
+
FlagDatafile,
|
|
60
|
+
GetAllFlagsOptions,
|
|
61
|
+
Logger,
|
|
62
|
+
ServerGetFlagOptions,
|
|
63
|
+
StickyBucketService,
|
|
64
|
+
} from '@avsbhq/node';
|
|
65
|
+
```
|
|
51
66
|
|
|
52
|
-
|
|
67
|
+
```ts
|
|
68
|
+
interface AvsbServerOptions {
|
|
69
|
+
/** `sdk_<environment>_<id>`, e.g. sdk_production_ttqm0eaj4vth1krcb2xn. */
|
|
70
|
+
sdkKey: string;
|
|
71
|
+
/** Polling interval in ms. Default 60_000. Clamped to a 5_000 minimum, jittered. */
|
|
72
|
+
pollingInterval?: number;
|
|
73
|
+
/** Datafile CDN host. Default 'https://cdn.avsb.cloud'. */
|
|
74
|
+
cdnHost?: string;
|
|
75
|
+
/** Max ms onReady() waits for the first datafile. Default 10_000. */
|
|
76
|
+
initTimeout?: number;
|
|
77
|
+
/** A datafile you already have, so onReady() resolves with no network call. */
|
|
78
|
+
bootstrap?: FlagDatafile;
|
|
79
|
+
/** SSE datafile updates. Default TRUE on the server. */
|
|
80
|
+
streaming?: boolean;
|
|
81
|
+
/** Stream ticket endpoint. Overrides the datafile's own `streamEndpoint`. */
|
|
82
|
+
streamingEndpoint?: string;
|
|
83
|
+
/** Event tracker tuning. */
|
|
84
|
+
events?: EventOptions;
|
|
85
|
+
/** Sticky bucketing store. */
|
|
86
|
+
stickyBucketService?: StickyBucketService;
|
|
87
|
+
/** Your logger. Default: console at warn level outside production. */
|
|
88
|
+
logger?: Logger;
|
|
89
|
+
/** Shorthand for the default logger's level. 'silent' restores no output. */
|
|
90
|
+
logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface EventOptions {
|
|
94
|
+
/** Ms between automatic flushes. Default 2000. */
|
|
95
|
+
flushInterval?: number;
|
|
96
|
+
/** Max events per request. Default 10. */
|
|
97
|
+
batchSize?: number;
|
|
98
|
+
/** Queue cap; oldest dropped with a warning. Default 10_000. */
|
|
99
|
+
maxQueueSize?: number;
|
|
100
|
+
/** Retry attempts per failed flush. Default 3. */
|
|
101
|
+
retryAttempts?: number;
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### SDK keys
|
|
106
|
+
|
|
107
|
+
Copy the key for the environment you want from app.avsb.cloud, then Settings, then Environments. The format is `sdk_<environment>_<id>`.
|
|
53
108
|
|
|
54
109
|
```bash
|
|
55
|
-
AVSB_SDK_KEY=
|
|
110
|
+
AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xn
|
|
56
111
|
```
|
|
57
112
|
|
|
58
|
-
|
|
113
|
+
The shape is checked at construction. A key that does not look like one is logged with what looked wrong and where the real key lives; the SDK carries on with the value you passed, because the datafile request is the real arbiter.
|
|
59
114
|
|
|
60
|
-
|
|
115
|
+
Keep the key in environment variables. The SDK reads it once, at construction, so moving an environment to a new key means setting the new value and restarting the process (or constructing a new client and closing the old one).
|
|
61
116
|
|
|
62
117
|
---
|
|
63
118
|
|
|
64
119
|
## 4. Identity
|
|
65
120
|
|
|
66
|
-
The server
|
|
121
|
+
The server holds no context of its own: you supply it per request. The examples below read a request object shaped like this, whatever your framework calls it:
|
|
67
122
|
|
|
68
|
-
|
|
123
|
+
```ts
|
|
124
|
+
interface AppRequest {
|
|
125
|
+
user: { id: string; plan: string; country: string };
|
|
126
|
+
org: { id: string; tier: string };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
declare const req: AppRequest;
|
|
130
|
+
```
|
|
69
131
|
|
|
70
|
-
|
|
132
|
+
### `forUser(context)`, the primary pattern
|
|
71
133
|
|
|
72
134
|
```ts
|
|
73
135
|
const user = server.forUser({
|
|
@@ -75,184 +137,269 @@ const user = server.forUser({
|
|
|
75
137
|
key: req.user.id,
|
|
76
138
|
plan: req.user.plan,
|
|
77
139
|
country: req.user.country,
|
|
78
|
-
})
|
|
140
|
+
});
|
|
79
141
|
|
|
80
|
-
const flag = user.getBoolFlag('checkout-v2', false)
|
|
81
|
-
user.track('page_viewed'
|
|
142
|
+
const flag = user.getBoolFlag('checkout-v2', false);
|
|
143
|
+
user.track('page_viewed');
|
|
82
144
|
```
|
|
83
145
|
|
|
84
|
-
`createUserContext` is an
|
|
146
|
+
`createUserContext(context)` is an alias for the same thing. The returned `UserBoundClient` carries its own decision log and its own forced decisions.
|
|
85
147
|
|
|
86
148
|
### Per-call context
|
|
87
149
|
|
|
88
|
-
|
|
150
|
+
Context is the THIRD POSITIONAL argument on every evaluation call, matching `@avsbhq/edge`:
|
|
89
151
|
|
|
90
152
|
```ts
|
|
91
153
|
const flag = server.getBoolFlag('checkout-v2', false, {
|
|
92
|
-
|
|
93
|
-
|
|
154
|
+
kind: 'user',
|
|
155
|
+
key: 'u_123',
|
|
156
|
+
plan: 'pro',
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Calling without a context returns your default with `source: 'not_ready'` and warns once, naming `forUser`. There is nobody to evaluate for.
|
|
161
|
+
|
|
162
|
+
### Multi-context
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const user = server.forUser({
|
|
166
|
+
kind: 'multi',
|
|
167
|
+
user: { kind: 'user', key: req.user.id, plan: req.user.plan },
|
|
168
|
+
organization: { kind: 'organization', key: req.org.id, tier: req.org.tier },
|
|
169
|
+
});
|
|
170
|
+
// A rule can target organization.tier while bucketing on user.key.
|
|
94
171
|
```
|
|
95
172
|
|
|
96
173
|
### Commerce targeting attributes
|
|
97
174
|
|
|
98
|
-
Audiences built with the commerce condition types (cart value, products viewed, purchase history)
|
|
175
|
+
Audiences built with the commerce condition types (cart value, products viewed, purchase history) read fixed attribute names off the context:
|
|
99
176
|
|
|
100
177
|
```ts
|
|
101
178
|
const flag = server.getBoolFlag('big-cart-banner', false, {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
orderCount: 7, // optional
|
|
114
|
-
},
|
|
179
|
+
kind: 'user',
|
|
180
|
+
key: 'u_123',
|
|
181
|
+
cartValue: 120.5, // decimal MAJOR units of the project currency
|
|
182
|
+
viewedSkus: ['SKU-1', 'SKU-2'], // "products viewed" matching on SKU
|
|
183
|
+
viewedCategories: ['trainers'], // "products viewed" matching on category
|
|
184
|
+
purchaseHistory: {
|
|
185
|
+
// PurchaseHistoryAttribute, exported for you
|
|
186
|
+
hasPurchased: true,
|
|
187
|
+
lastPurchaseAt: 1765500000000, // epoch milliseconds (optional)
|
|
188
|
+
lifetimeSpend: 540.0, // decimal MAJOR units (optional)
|
|
189
|
+
orderCount: 7, // optional
|
|
115
190
|
},
|
|
116
|
-
})
|
|
191
|
+
});
|
|
117
192
|
```
|
|
118
193
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
- **
|
|
122
|
-
- **Missing or mistyped attributes evaluate the condition to `false`.** A completely absent `purchaseHistory` makes ALL purchase-history operators false, including `has_not_purchased`. On the server, absence means "not provided", not "never purchased" (this deliberately diverges from the browser snippet, where a profile miss is authoritative).
|
|
123
|
-
- **Dataset-backed "products viewed" conditions evaluate `false` on the server.** Dataset view windows live in the browser; server callers use literal SKU/category lists. The session/30-day window distinction is also browser-only; supply whatever view list you consider current.
|
|
194
|
+
- **All money is decimal major units.** Condition values are stored in minor units and converted with the project currency exponent from the datafile (default 2); you never pass minor units.
|
|
195
|
+
- **Missing or mistyped attributes evaluate to `false`.** A completely absent `purchaseHistory` makes every purchase-history operator false, including `has_not_purchased`. On the server, absence means "not provided", not "never purchased". This deliberately differs from the browser snippet, where a profile miss is authoritative.
|
|
196
|
+
- **Dataset-backed "products viewed" conditions evaluate `false` on the server.** Dataset view windows live in the browser; server callers pass literal SKU or category lists.
|
|
124
197
|
|
|
125
|
-
### `alias(previousContext, newContext)`
|
|
198
|
+
### `alias(previousContext, newContext): void`
|
|
126
199
|
|
|
127
|
-
|
|
200
|
+
Marks the moment a signup or login claimed an anonymous session. Synchronous.
|
|
128
201
|
|
|
129
202
|
```ts
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
)
|
|
203
|
+
const anonymousId = 'anon_7f3c9d2b'; // the key you evaluated with before login
|
|
204
|
+
const signedInId = 'u_123'; // the key you evaluate with from now on
|
|
205
|
+
|
|
206
|
+
server.alias({ kind: 'user', key: anonymousId }, { kind: 'user', key: signedInId });
|
|
134
207
|
```
|
|
135
208
|
|
|
209
|
+
What it does: queues one `avsb_alias` event for the new identity.
|
|
210
|
+
|
|
211
|
+
What it does NOT do, precisely: it does not rewrite sticky assignments, move past exposures, or store the previous key (the conversion wire carries no properties column, so a retroactive session join is not available yet). To keep one visitor's bucketing across a login, evaluate with the SAME context key on both sides.
|
|
212
|
+
|
|
136
213
|
### Sharing identity with the web snippet
|
|
137
214
|
|
|
138
|
-
To measure one metric across
|
|
215
|
+
To measure one metric across the A vs B web snippet and this SDK, the context `key` must equal the snippet's visitor id: the `_avsb_visitor` cookie UUID that `avsb.getVisitorId()` returns. Results join on raw id equality, so a different key means the two surfaces never line up. Forward the snippet id from the browser and use it as the key here. Full guide: [Shared Visitor Identity](https://avsb.cloud/docs/developer-reference/shared-visitor-identity).
|
|
139
216
|
|
|
140
217
|
---
|
|
141
218
|
|
|
142
|
-
## 5.
|
|
219
|
+
## 5. Reading flags
|
|
143
220
|
|
|
144
|
-
|
|
221
|
+
### Signatures
|
|
145
222
|
|
|
146
223
|
```ts
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
224
|
+
getFlag<T>(flagKey: string, defaultValue: T, context?: EvalContext, options?: ServerGetFlagOptions): Flag<T>
|
|
225
|
+
getBoolFlag(flagKey: string, defaultValue: boolean, context?: EvalContext, options?: ServerGetFlagOptions): Flag<boolean>
|
|
226
|
+
getStringFlag(flagKey: string, defaultValue: string, context?: EvalContext, options?: ServerGetFlagOptions): Flag<string>
|
|
227
|
+
getNumberFlag(flagKey: string, defaultValue: number, context?: EvalContext, options?: ServerGetFlagOptions): Flag<number>
|
|
228
|
+
getJsonFlag<T>(flagKey: string, defaultValue: T, context?: EvalContext, options?: ServerGetFlagOptions): Flag<T>
|
|
229
|
+
getAllFlags(context?: EvalContext, options?: GetAllFlagsOptions): Record<string, Flag>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The two options bags those signatures name:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
interface ServerGetFlagOptions {
|
|
236
|
+
decideOptions?: DecideOption[];
|
|
237
|
+
decisionLog?: DecisionLog;
|
|
238
|
+
}
|
|
152
239
|
|
|
153
|
-
|
|
154
|
-
|
|
240
|
+
interface GetAllFlagsOptions {
|
|
241
|
+
/** Record an exposure per flag. Default FALSE. */
|
|
242
|
+
fireExposures?: boolean;
|
|
243
|
+
decideOptions?: DecideOption[];
|
|
244
|
+
decisionLog?: DecisionLog;
|
|
245
|
+
}
|
|
155
246
|
```
|
|
156
247
|
|
|
157
|
-
|
|
248
|
+
On a `UserBoundClient` the context is already bound, so the same calls read `getBoolFlag(key, default, options?)`.
|
|
158
249
|
|
|
159
|
-
|
|
250
|
+
One deliberate difference from `@avsbhq/browser`: there, `getAllFlags` takes an options bag (`getAllFlags({ context })`) because the browser client is context-bound and the bag disambiguates its optional arguments; here the context is the first positional argument, matching every other server evaluation call.
|
|
160
251
|
|
|
161
|
-
|
|
252
|
+
Typed getters check the value against the flag's declared type in the datafile. On a mismatch they log a warning naming both types and return YOUR default; they never throw and never coerce.
|
|
253
|
+
|
|
254
|
+
### The `Flag<T>` object
|
|
162
255
|
|
|
163
256
|
```ts
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
257
|
+
interface Flag<T = unknown> {
|
|
258
|
+
readonly value: T;
|
|
259
|
+
readonly variationKey: string | null;
|
|
260
|
+
readonly source: EvaluationSource;
|
|
261
|
+
readonly ruleId: string | null;
|
|
262
|
+
readonly ruleType: 'targeted_delivery' | 'ab_test' | 'holdout' | 'bandit' | null;
|
|
263
|
+
readonly reasons: string[];
|
|
264
|
+
readonly evaluatedAt: number;
|
|
265
|
+
readonly durationMicros: number;
|
|
266
|
+
isEnabled(): boolean;
|
|
267
|
+
exists(): boolean;
|
|
268
|
+
}
|
|
168
269
|
```
|
|
169
270
|
|
|
170
|
-
|
|
271
|
+
`isEnabled()` is true only when a REAL decision produced a truthy value: sources `rule`, `holdout`, `bandit`, `runtimeOverride`, `datafileOverride`, `sticky`. It is always false for `default`, `not_found`, `disabled`, and `not_ready`.
|
|
171
272
|
|
|
172
|
-
|
|
273
|
+
`exists()` answers "is this key in the datafile": false for `not_found`, and false for `not_ready` because with no datafile the question is unanswerable.
|
|
173
274
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
275
|
+
| `source` | Meaning |
|
|
276
|
+
| ------------------ | ------------------------------------------------------------------------------ |
|
|
277
|
+
| `datafileOverride` | A per-user override configured in the dashboard matched. |
|
|
278
|
+
| `runtimeOverride` | `setOverrideForUser`, `setGlobalOverride`, or a forced decision matched. |
|
|
279
|
+
| `sticky` | A stored assignment was reused. |
|
|
280
|
+
| `rule` | A targeting or A/B rule matched. |
|
|
281
|
+
| `holdout` | The context is held out; the holdout variation was served. |
|
|
282
|
+
| `bandit` | A bandit rule picked the variation. |
|
|
283
|
+
| `default` | The flag exists, nothing matched, its default variation was served. |
|
|
284
|
+
| `disabled` | The flag exists but is off in this environment. |
|
|
285
|
+
| `not_found` | The datafile loaded and has no such key. Check the key name. |
|
|
286
|
+
| `not_ready` | No datafile yet, or no context passed. Await `onReady()`, or pass `bootstrap`. |
|
|
179
287
|
|
|
180
|
-
###
|
|
288
|
+
### `DecideOption`
|
|
181
289
|
|
|
182
290
|
```ts
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
flag.ruleId // which rule matched
|
|
189
|
-
flag.reasons // string[], evaluation path
|
|
291
|
+
import { DecideOption } from '@avsbhq/node';
|
|
292
|
+
|
|
293
|
+
user.getBoolFlag('checkout-v2', false, {
|
|
294
|
+
decideOptions: [DecideOption.DISABLE_EXPOSURE],
|
|
295
|
+
});
|
|
190
296
|
```
|
|
191
297
|
|
|
192
|
-
|
|
298
|
+
Two members, both accepted:
|
|
299
|
+
|
|
300
|
+
- `DISABLE_EXPOSURE` evaluates without recording an exposure. Use it for admin panels, previews, and bulk reads.
|
|
301
|
+
- `INCLUDE_REASONS` asks for reason strings. This SDK collects reasons unconditionally, so passing it changes nothing here; it is real in the per-language SDKs that gate reason collection behind it.
|
|
302
|
+
|
|
303
|
+
Anything else warns once with the accepted list. `EXCLUDE_VARIABLES`, `IGNORE_STICKY_BUCKET`, and `ENABLED_FLAGS_ONLY` were deleted: no evaluator in any language implemented them.
|
|
304
|
+
|
|
305
|
+
### `getAllFlags`
|
|
193
306
|
|
|
194
307
|
```ts
|
|
195
|
-
const all = server.getAllFlags({ kind: 'user', key: 'u_123' })
|
|
196
|
-
// Exposures are suppressed by default. Pass { fireExposures: true } to
|
|
308
|
+
const all = server.getAllFlags({ kind: 'user', key: 'u_123' });
|
|
309
|
+
// Exposures are suppressed by default. Pass { fireExposures: true } to record them.
|
|
197
310
|
```
|
|
198
311
|
|
|
199
|
-
|
|
312
|
+
Called without a context, or before the datafile loads, it returns `{}` AND warns, because an empty map is otherwise indistinguishable from a project with no flags.
|
|
200
313
|
|
|
201
|
-
|
|
202
|
-
// Override for a specific user context key
|
|
203
|
-
server.setOverrideForUser('checkout-v2', 'u_qa', 'on')
|
|
204
|
-
server.setOverrideForUser('checkout-v2', 'u_qa', 'on', 'user') // explicit kind
|
|
314
|
+
### Overrides, for QA and testing
|
|
205
315
|
|
|
206
|
-
|
|
207
|
-
server.
|
|
316
|
+
```ts
|
|
317
|
+
server.setOverrideForUser('checkout-v2', 'u_qa', 'on');
|
|
318
|
+
server.setOverrideForUser('checkout-v2', 'acme', 'on', 'organization'); // explicit kind
|
|
319
|
+
server.setGlobalOverride('checkout-v2', 'on');
|
|
208
320
|
|
|
209
|
-
|
|
210
|
-
server.
|
|
211
|
-
server.
|
|
212
|
-
server.clearAllOverrides()
|
|
321
|
+
server.clearOverrideForUser('checkout-v2', 'u_qa');
|
|
322
|
+
server.clearGlobalOverride('checkout-v2');
|
|
323
|
+
server.clearAllOverrides();
|
|
213
324
|
```
|
|
214
325
|
|
|
215
|
-
Per-request forced decisions
|
|
326
|
+
Per-request forced decisions, which never touch the shared state above:
|
|
216
327
|
|
|
217
328
|
```ts
|
|
218
|
-
const user = server.forUser(
|
|
219
|
-
user.setForcedDecision('checkout-v2', { variationKey: 'on' })
|
|
220
|
-
user.clearForcedDecision('checkout-v2')
|
|
329
|
+
const user = server.forUser({ kind: 'user', key: 'u_qa' });
|
|
330
|
+
user.setForcedDecision('checkout-v2', { variationKey: 'on' });
|
|
331
|
+
user.clearForcedDecision('checkout-v2');
|
|
221
332
|
```
|
|
222
333
|
|
|
223
334
|
---
|
|
224
335
|
|
|
225
|
-
##
|
|
336
|
+
## 6. Tracking
|
|
226
337
|
|
|
227
338
|
```ts
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
339
|
+
// Bound client: context implied.
|
|
340
|
+
user.track('purchase_completed', { revenue: 199.0 }); // money
|
|
341
|
+
user.track('items_added', { value: 3 }); // a quantity
|
|
342
|
+
user.track('signup_completed'); // a count
|
|
343
|
+
|
|
344
|
+
// Server directly: context in the payload.
|
|
345
|
+
server.track('purchase_completed', {
|
|
346
|
+
revenue: 199.0,
|
|
347
|
+
context: { kind: 'user', key: 'u_123' },
|
|
348
|
+
});
|
|
232
349
|
```
|
|
233
350
|
|
|
234
|
-
|
|
351
|
+
`TrackPayload` carries two numbers, and they are two columns end to end:
|
|
352
|
+
|
|
353
|
+
| Field | Meaning | Storage column |
|
|
354
|
+
| --------- | ----------------------------------------------------------------------- | ----------------------- |
|
|
355
|
+
| `revenue` | Money in decimal MAJOR units of the project currency (49.99, not 4999). | `metric_events.revenue` |
|
|
356
|
+
| `value` | Numeric metric value for average-value metrics: items, seats, seconds. | `metric_events.value` |
|
|
357
|
+
|
|
358
|
+
One conversion can carry money, a quantity, or both. Earlier releases mapped `value` into the revenue column and could never write the value column, so a quantity was recorded as money and every average-value metric read empty.
|
|
359
|
+
|
|
360
|
+
`track` is synchronous: it queues the event and the tracker batches it. Events tracked before the datafile arrives are QUEUED (bounded by `events.maxQueueSize`) and sent once it does.
|
|
361
|
+
|
|
362
|
+
`payload.properties` is accepted by the shared type but NOT stored for conversions: the ingestion contract for a metric event keeps the event name, the two numbers, the timestamp, and the visitor id. The SDK warns once and drops them rather than pretending. Exposure events do carry properties.
|
|
363
|
+
|
|
364
|
+
### Purchases
|
|
235
365
|
|
|
236
366
|
```ts
|
|
237
|
-
server.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
367
|
+
await server.trackPurchase('u_123', {
|
|
368
|
+
orderId: 'order_1001',
|
|
369
|
+
total: 199.0,
|
|
370
|
+
currency: 'USD',
|
|
371
|
+
items: [{ sku: 'PRO_ANNUAL', price: 199.0, quantity: 1 }],
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Or from a bound client, attributed to its context key:
|
|
375
|
+
await user.trackPurchase({ orderId: 'order_1001', total: 199.0 });
|
|
242
376
|
```
|
|
243
377
|
|
|
244
|
-
|
|
378
|
+
Purchases are queued before ready exactly like `track`. They used to be dropped with a warning while `track` queued.
|
|
379
|
+
|
|
380
|
+
### Manual exposure
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
const flag = user.getBoolFlag('checkout-v2', false, {
|
|
384
|
+
decideOptions: [DecideOption.DISABLE_EXPOSURE],
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Later, at the point the visitor actually sees the variation:
|
|
388
|
+
user.manualExposure('checkout-v2');
|
|
389
|
+
```
|
|
245
390
|
|
|
246
391
|
---
|
|
247
392
|
|
|
248
|
-
##
|
|
393
|
+
## 7. Errors, logging, and events
|
|
394
|
+
|
|
395
|
+
### Logger
|
|
249
396
|
|
|
250
|
-
|
|
397
|
+
The default is a console logger at WARN level when `NODE_ENV` is neither `production` nor `test`, and a noop otherwise. A wrong SDK key is loud in development and silent in production logs.
|
|
251
398
|
|
|
252
399
|
```ts
|
|
253
|
-
import { AvsbServer, createLogger } from '@avsbhq/node'
|
|
254
|
-
import pino from 'pino'
|
|
255
|
-
import { createPinoTransport } from '@avsbhq/utils/log/pino'
|
|
400
|
+
import { AvsbServer, createLogger } from '@avsbhq/node';
|
|
401
|
+
import pino from 'pino';
|
|
402
|
+
import { createPinoTransport } from '@avsbhq/utils/log/pino';
|
|
256
403
|
|
|
257
404
|
const server = new AvsbServer({
|
|
258
405
|
sdkKey: process.env.AVSB_SDK_KEY!,
|
|
@@ -260,242 +407,399 @@ const server = new AvsbServer({
|
|
|
260
407
|
level: 'info',
|
|
261
408
|
transports: [createPinoTransport({ logger: pino(), level: 'info' })],
|
|
262
409
|
}),
|
|
263
|
-
})
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Or just turn the volume down:
|
|
413
|
+
new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY!, logLevel: 'silent' });
|
|
264
414
|
```
|
|
265
415
|
|
|
266
|
-
### `onReady()`
|
|
416
|
+
### `onReady()`
|
|
267
417
|
|
|
268
418
|
```ts
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
419
|
+
interface InitResult {
|
|
420
|
+
success: boolean;
|
|
421
|
+
source: 'network' | 'bootstrap' | 'timeout' | 'error';
|
|
422
|
+
error?: Error;
|
|
423
|
+
/** Serving stale-but-real values after a failed refresh. Not set by this SDK today. */
|
|
424
|
+
degraded?: boolean;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const result = await server.onReady();
|
|
428
|
+
if (!result.success) {
|
|
429
|
+
// result.source is 'timeout' or 'error'; result.error says which.
|
|
275
430
|
}
|
|
276
431
|
```
|
|
277
432
|
|
|
278
|
-
`onReady()` never rejects
|
|
433
|
+
`onReady()` never rejects and returns the same promise on repeated calls. `isReady()` answers "are flags readable right now" synchronously.
|
|
279
434
|
|
|
280
|
-
###
|
|
435
|
+
### Events
|
|
281
436
|
|
|
282
437
|
```ts
|
|
283
|
-
|
|
284
|
-
myLogger.error('SDK error', { error: err.message })
|
|
285
|
-
})
|
|
286
|
-
|
|
438
|
+
server.on('ready', () => {});
|
|
287
439
|
server.on('configUpdate', ({ publishedAt, reason }) => {
|
|
288
|
-
|
|
289
|
-
})
|
|
440
|
+
// reason: 'poll' | 'stream' | 'manual'
|
|
441
|
+
});
|
|
442
|
+
server.on('evaluation', ({ flagKey, flag, contextKeys }) => {});
|
|
443
|
+
server.on('exposure', (event) => {
|
|
444
|
+
// Same eventId the ingestion pipeline receives, so your own analytics rows
|
|
445
|
+
// reconcile with A vs B results.
|
|
446
|
+
});
|
|
447
|
+
server.on('error', (error) => {});
|
|
448
|
+
server.on('stickyOrphan', ({ flagKey, userId, assignment }) => {});
|
|
449
|
+
|
|
450
|
+
// on() returns the unsubscribe:
|
|
451
|
+
const unsubscribe = server.on('error', (error) => console.error(error));
|
|
452
|
+
unsubscribe();
|
|
290
453
|
```
|
|
291
454
|
|
|
455
|
+
The payloads are the shared `ClientEventMap` shapes, so a listener written against `@avsbhq/browser` reads the same fields here. The server does not emit `flagChange`: it holds one datafile for the process and does not diff individual flags. Subscribing to it through `client.utils` warns and hands back a no-op disposer.
|
|
456
|
+
|
|
292
457
|
---
|
|
293
458
|
|
|
294
|
-
##
|
|
459
|
+
## 8. Heartbeat and SDK health
|
|
460
|
+
|
|
461
|
+
The SDK reports liveness to the endpoint the datafile publishes
|
|
462
|
+
(`heartbeatEndpoint`), so the dashboard can show whether an environment is
|
|
463
|
+
actually being served.
|
|
464
|
+
|
|
465
|
+
- **What it posts**: `{ sdkKey, sdkType, sdkVersion }`, nothing else. No flag
|
|
466
|
+
values, no context, no counts.
|
|
467
|
+
- **When**: on every successful datafile REQUEST (init, poll, stream-triggered
|
|
468
|
+
refresh, and `refreshDatafile()`), throttled to at most one every 2 minutes.
|
|
469
|
+
A `304 Not Modified` counts: the SDK sends a conditional request once it has
|
|
470
|
+
an ETag, so an unchanged datafile answers 304 on every poll, and that is
|
|
471
|
+
exactly the steady state of a healthy process.
|
|
472
|
+
- **Fire and forget**: a failed heartbeat is dropped, never retried, and never
|
|
473
|
+
affects evaluation.
|
|
474
|
+
- **Where it shows up**: Settings, then Environments, in the dashboard. Each
|
|
475
|
+
environment shows Connected, Stale, or Not connected, plus the `sdkType` and
|
|
476
|
+
`sdkVersion` that last reported, which is how you see "production is running
|
|
477
|
+
@avsbhq/node 1.6.2". An environment goes Stale after five minutes without a
|
|
478
|
+
heartbeat.
|
|
479
|
+
- **Turning it off**: publish a datafile with no `heartbeatEndpoint`. The SDK
|
|
480
|
+
never guesses a host, so an absent field simply means no heartbeats.
|
|
481
|
+
|
|
482
|
+
---
|
|
295
483
|
|
|
296
|
-
|
|
484
|
+
## 9. Streaming
|
|
297
485
|
|
|
298
|
-
|
|
486
|
+
Streaming is ON by default. The endpoint comes from the datafile's `streamEndpoint` field, which the publisher controls, so a self-hosted or proxied deployment needs no client-side configuration.
|
|
299
487
|
|
|
300
488
|
```ts
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
})
|
|
305
|
-
|
|
489
|
+
const sdkKey = process.env.AVSB_SDK_KEY!;
|
|
490
|
+
const streamingEndpoint = 'https://stream.avsb.cloud/v1/ticket';
|
|
491
|
+
|
|
492
|
+
new AvsbServer({ sdkKey }); // streaming on, endpoint from the datafile
|
|
493
|
+
new AvsbServer({ sdkKey, streaming: false }); // polling only
|
|
494
|
+
new AvsbServer({ sdkKey, streamingEndpoint }); // explicit endpoint wins
|
|
306
495
|
```
|
|
307
496
|
|
|
308
|
-
|
|
497
|
+
If no endpoint is available, or the stream drops, the SDK keeps polling and logs the reason at debug level. Nothing is guessed from another host: pointing a reconnect loop at DNS that does not resolve is the failure mode this design exists to prevent.
|
|
309
498
|
|
|
310
|
-
|
|
499
|
+
---
|
|
311
500
|
|
|
312
|
-
|
|
501
|
+
## 10. Bootstrap and serverless cold starts
|
|
313
502
|
|
|
314
503
|
```ts
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
504
|
+
import type { BootstrapBlob } from '@avsbhq/utils';
|
|
505
|
+
|
|
506
|
+
// You already have the datafile (KV, a file, a cache), and a blob from a
|
|
507
|
+
// server that called serializeBootstrap.
|
|
508
|
+
declare const cachedDatafile: FlagDatafile;
|
|
509
|
+
declare const blob: BootstrapBlob;
|
|
510
|
+
|
|
511
|
+
const server = new AvsbServer({ sdkKey, bootstrap: cachedDatafile });
|
|
512
|
+
// onReady() resolves immediately with source: 'bootstrap'.
|
|
513
|
+
|
|
514
|
+
// Or hand it over after construction:
|
|
515
|
+
server.applyDatafileBootstrap(cachedDatafile);
|
|
516
|
+
|
|
517
|
+
// Same thing through the bootstrap blob helpers:
|
|
518
|
+
server.utils.hydrateBootstrap(blob); // blob must carry `datafile`
|
|
319
519
|
```
|
|
320
520
|
|
|
321
|
-
`
|
|
521
|
+
`hydrateBootstrap` THROWS on a blob that carries only a pre-evaluated snapshot: a snapshot belongs to one context, and a server evaluates a different context per request, so adopting one process-wide would serve one user's values to another. Serialize with `{ includeDatafile: true }`, or pass `bootstrap: blob.datafile`.
|
|
522
|
+
|
|
523
|
+
For Next.js SSR see `@avsbhq/next`.
|
|
524
|
+
|
|
525
|
+
---
|
|
322
526
|
|
|
323
|
-
|
|
527
|
+
## 11. Shutdown
|
|
324
528
|
|
|
325
529
|
```ts
|
|
326
|
-
|
|
530
|
+
process.on('SIGTERM', async () => {
|
|
531
|
+
await server.close();
|
|
532
|
+
process.exit(0);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
await server.flush(); // drain queued events without tearing down
|
|
327
536
|
```
|
|
328
537
|
|
|
538
|
+
`close()` aborts in-flight fetches, disconnects the stream, stops polling, and drains the event queue.
|
|
539
|
+
|
|
329
540
|
---
|
|
330
541
|
|
|
331
|
-
##
|
|
542
|
+
## 12. Verifying inbound webhooks
|
|
543
|
+
|
|
544
|
+
AvsB signs every outbound webhook delivery with HMAC-SHA256 over `deliveryId + "." + timestamp + "." + rawBody` (the Standard Webhooks scheme). `verifyWebhookSignature` checks it against the **raw** body bytes, enforces a replay window, and compares digests in constant time.
|
|
332
545
|
|
|
333
546
|
```ts
|
|
334
|
-
|
|
547
|
+
// docs-example: not typechecked here, because express is not a dependency of the A vs B repository, so its Request and Response types cannot be resolved for the route handler
|
|
548
|
+
|
|
549
|
+
import express from 'express';
|
|
550
|
+
import { verifyWebhookSignature } from '@avsbhq/node';
|
|
335
551
|
|
|
336
|
-
const
|
|
337
|
-
.booleanFlag()
|
|
338
|
-
.variationForUser('u_test', true)
|
|
339
|
-
.fallthroughVariation(false)
|
|
552
|
+
const app = express();
|
|
340
553
|
|
|
341
|
-
|
|
342
|
-
//
|
|
554
|
+
async function handleEvent(deliveryId: string, event: unknown): Promise<void> {
|
|
555
|
+
// your own idempotent processing, keyed on deliveryId
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// express.raw(), NOT express.json(): parsing and re-serialising the body changes
|
|
559
|
+
// its bytes, and the signature is over the bytes we sent.
|
|
560
|
+
app.post('/avsb-webhook', express.raw({ type: 'application/json' }), (req, res) => {
|
|
561
|
+
const result = verifyWebhookSignature(req.body, req.headers, process.env.AVSB_WEBHOOK_SECRET!);
|
|
562
|
+
if (!result.ok) {
|
|
563
|
+
// 'missing_headers' | 'malformed_signature' | 'malformed_timestamp'
|
|
564
|
+
// | 'timestamp_out_of_tolerance' | 'signature_mismatch'
|
|
565
|
+
return res.status(401).send(result.reason);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const event = JSON.parse(req.body.toString('utf8'));
|
|
569
|
+
void handleEvent(result.deliveryId, event); // retries reuse the id: dedupe on it
|
|
570
|
+
res.sendStatus(200);
|
|
571
|
+
});
|
|
343
572
|
```
|
|
344
573
|
|
|
345
|
-
|
|
574
|
+
| Option | Default | Notes |
|
|
575
|
+
| ------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
576
|
+
| `toleranceSeconds` | `300` | Reject deliveries signed more than this long ago (or ahead, so clock skew is caught). `0` disables the age check. |
|
|
577
|
+
| `now` | `Date.now` | Injectable clock, for tests. |
|
|
346
578
|
|
|
347
|
-
|
|
348
|
-
|
|
579
|
+
Works with Node's `req.headers`, a plain object with any casing, or anything exposing `get(name)` (a `Headers` instance, a Next.js `Request`).
|
|
580
|
+
|
|
581
|
+
---
|
|
349
582
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
583
|
+
## 13. Testing
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
import { createMockServer, flagsFromTestData, TestData } from '@avsbhq/test';
|
|
587
|
+
|
|
588
|
+
const server = createMockServer(
|
|
589
|
+
flagsFromTestData([
|
|
590
|
+
TestData.flag('checkout-v2')
|
|
591
|
+
.booleanFlag()
|
|
592
|
+
.variationForUser('u_paying', true)
|
|
593
|
+
.fallthroughVariation(false)
|
|
594
|
+
.build(),
|
|
595
|
+
]),
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const user = server.forUser({ kind: 'user', key: 'u_paying' });
|
|
599
|
+
expect(user.getBoolFlag('checkout-v2', false).value).toBe(true);
|
|
600
|
+
expect(server._evaluations()).toHaveLength(1);
|
|
355
601
|
```
|
|
356
602
|
|
|
603
|
+
`createMockServer` mirrors this SDK's surface, `forUser` included. `createMockClient` mirrors the browser client. Both read the same fixture through `flagsFromTestData`.
|
|
604
|
+
|
|
357
605
|
---
|
|
358
606
|
|
|
359
|
-
##
|
|
607
|
+
## 14. Framework integrations
|
|
360
608
|
|
|
361
609
|
### Express
|
|
362
610
|
|
|
363
611
|
```ts
|
|
364
|
-
import express from 'express'
|
|
365
|
-
import { AvsbServer, expressMiddleware } from '@avsbhq/node'
|
|
612
|
+
import express from 'express';
|
|
613
|
+
import { AvsbServer, expressMiddleware } from '@avsbhq/node';
|
|
614
|
+
import { getRequestClient } from '@avsbhq/utils';
|
|
615
|
+
|
|
616
|
+
const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! });
|
|
617
|
+
await avsb.onReady();
|
|
618
|
+
|
|
619
|
+
const app = express();
|
|
620
|
+
app.use(
|
|
621
|
+
expressMiddleware(avsb, {
|
|
622
|
+
// The middleware reads the request structurally, so narrow it to whatever
|
|
623
|
+
// your auth layer put there.
|
|
624
|
+
contextFrom: (req) => {
|
|
625
|
+
const authed = req.user as { id?: string; plan?: string } | undefined;
|
|
626
|
+
return { kind: 'user', key: authed?.id ?? 'anon', plan: authed?.plan };
|
|
627
|
+
},
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
366
630
|
|
|
367
|
-
|
|
368
|
-
|
|
631
|
+
// Deep inside a service, with no `req` in scope:
|
|
632
|
+
function pricingService() {
|
|
633
|
+
return getRequestClient()?.getBoolFlag('new-pricing', false).value ?? false;
|
|
634
|
+
}
|
|
635
|
+
```
|
|
369
636
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
key: req.user?.id ?? 'anon',
|
|
375
|
-
plan: req.user?.plan,
|
|
376
|
-
}),
|
|
377
|
-
}))
|
|
637
|
+
Every route then reads flags off `req.avsb`:
|
|
638
|
+
|
|
639
|
+
```ts
|
|
640
|
+
// docs-example: not typechecked here, because express is not a dependency of the A vs B repository, so a route handler's Request and Response types cannot be resolved
|
|
378
641
|
|
|
379
642
|
app.get('/checkout', (req, res) => {
|
|
380
|
-
const flag = req.avsb.getBoolFlag('new-checkout-flow', false)
|
|
381
|
-
res.render(flag.isEnabled() ? 'checkout-v2' : 'checkout-v1')
|
|
382
|
-
})
|
|
643
|
+
const flag = req.avsb.getBoolFlag('new-checkout-flow', false);
|
|
644
|
+
res.render(flag.isEnabled() ? 'checkout-v2' : 'checkout-v1');
|
|
645
|
+
});
|
|
383
646
|
```
|
|
384
647
|
|
|
385
|
-
|
|
648
|
+
Return `undefined` from `contextFrom` to skip AvsB for that request.
|
|
649
|
+
|
|
650
|
+
`expressMiddleware` and `fastifyPlugin` ARE the implementations from `@avsbhq/utils/middleware/*`, re-exported here so one dependency is enough. There is exactly one signature for each; this package used to ship a second, incompatible `expressMiddleware(client, { userIdFrom })`.
|
|
651
|
+
|
|
652
|
+
Type `req.avsb` in your own project (four lines, once):
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
// src/types/express.d.ts
|
|
656
|
+
import type { UserBoundClient } from '@avsbhq/node';
|
|
657
|
+
|
|
658
|
+
declare global {
|
|
659
|
+
namespace Express {
|
|
660
|
+
interface Request {
|
|
661
|
+
avsb: UserBoundClient;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
```
|
|
386
666
|
|
|
387
667
|
### Fastify
|
|
388
668
|
|
|
389
669
|
```ts
|
|
390
|
-
|
|
391
|
-
|
|
670
|
+
// docs-example: not typechecked here, because fastify is not a dependency of the A vs B repository, so neither the instance nor its route handler types can be resolved
|
|
671
|
+
|
|
672
|
+
import Fastify from 'fastify';
|
|
673
|
+
import { AvsbServer, fastifyPlugin } from '@avsbhq/node';
|
|
392
674
|
|
|
393
|
-
const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! })
|
|
394
|
-
await avsb.onReady()
|
|
675
|
+
const avsb = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! });
|
|
676
|
+
await avsb.onReady();
|
|
395
677
|
|
|
396
|
-
const app = Fastify()
|
|
678
|
+
const app = Fastify();
|
|
397
679
|
await app.register(fastifyPlugin, {
|
|
398
680
|
server: avsb,
|
|
399
|
-
contextFrom: (req) => ({
|
|
400
|
-
|
|
401
|
-
key: req.headers['x-user-id'] as string,
|
|
402
|
-
}),
|
|
403
|
-
})
|
|
681
|
+
contextFrom: (req) => ({ kind: 'user', key: req.headers['x-user-id'] as string }),
|
|
682
|
+
});
|
|
404
683
|
|
|
405
684
|
app.get('/features', (req, reply) => {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
})
|
|
685
|
+
reply.send({ enabled: req.avsb.getBoolFlag('new-feature', false).isEnabled() });
|
|
686
|
+
});
|
|
409
687
|
```
|
|
410
688
|
|
|
689
|
+
### Other frameworks
|
|
690
|
+
|
|
691
|
+
Hono, Koa, Next (app and pages), Lambda, and Cloudflare Workers adapters live in `@avsbhq/utils/middleware/*`: `honoMiddleware`, `koaMiddleware`, `nextAppHandler`, `nextPagesApi`, `lambdaHandler`, `workersHandler`. All open the same AsyncLocalStorage scope, so `getRequestClient()` works under every one of them.
|
|
692
|
+
|
|
411
693
|
### Sticky bucketing
|
|
412
694
|
|
|
413
|
-
Keep users in the same variation across rule changes.
|
|
695
|
+
Keep users in the same variation across rule changes. Sticky lookup runs BEFORE the engine, so a stored assignment survives a rollout edit.
|
|
414
696
|
|
|
415
697
|
```ts
|
|
416
|
-
import {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
698
|
+
import { createClient } from 'redis';
|
|
699
|
+
import { InMemoryStickyBucketService, RedisStickyBucketService } from '@avsbhq/node';
|
|
700
|
+
|
|
701
|
+
// Single process:
|
|
702
|
+
new AvsbServer({ sdkKey, stickyBucketService: new InMemoryStickyBucketService() });
|
|
703
|
+
|
|
704
|
+
// Multi-process, multi-region:
|
|
705
|
+
const redis = createClient({ url: process.env.REDIS_URL });
|
|
706
|
+
await redis.connect();
|
|
707
|
+
new AvsbServer({
|
|
708
|
+
sdkKey,
|
|
709
|
+
stickyBucketService: new RedisStickyBucketService({ redis, prefix: 'avsb:sticky:' }),
|
|
710
|
+
});
|
|
422
711
|
```
|
|
423
712
|
|
|
424
|
-
Redis
|
|
713
|
+
The Redis service keeps evaluation synchronous: a lookup is a cache hit or null, with a background fill, so the first lookup per process misses by design. DynamoDB, Postgres, and Durable Object services live in `@avsbhq/utils`.
|
|
425
714
|
|
|
426
|
-
|
|
427
|
-
import { createClient } from 'redis'
|
|
428
|
-
import { RedisStickyBucketService } from '@avsbhq/node'
|
|
715
|
+
### Decision logs
|
|
429
716
|
|
|
430
|
-
|
|
431
|
-
|
|
717
|
+
```ts
|
|
718
|
+
const user = server.forUser({ kind: 'user', key: 'u_123' });
|
|
719
|
+
user.getBoolFlag('checkout-v2', false);
|
|
432
720
|
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
redis,
|
|
437
|
-
prefix: 'avsb:sticky:',
|
|
438
|
-
}),
|
|
439
|
-
})
|
|
721
|
+
const entries = user.getDecisionLog().entries;
|
|
722
|
+
// [{ flagKey, value, variationKey, source, ruleId, ruleType, reasons,
|
|
723
|
+
// evaluatedAt, durationMicros, contextSummary }]
|
|
440
724
|
```
|
|
441
725
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
### Real-time streaming
|
|
445
|
-
|
|
446
|
-
Enable SSE streaming to receive datafile updates the instant a flag changes, with no polling delay:
|
|
726
|
+
Redaction is real: any attribute named in `_meta.privateAttributes` has its VALUE replaced with `[REDACTED]` wherever it appears in a logged value or a reason string. Values shorter than two characters are matched exactly rather than as substrings, because a one-character value cannot be told apart from ordinary text.
|
|
447
727
|
|
|
448
728
|
```ts
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
729
|
+
server.forUser({
|
|
730
|
+
kind: 'user',
|
|
731
|
+
key: 'u_123',
|
|
732
|
+
email: 'ada@example.com',
|
|
733
|
+
_meta: { privateAttributes: ['email'] },
|
|
734
|
+
});
|
|
453
735
|
```
|
|
454
736
|
|
|
455
|
-
|
|
737
|
+
---
|
|
456
738
|
|
|
457
|
-
|
|
739
|
+
## 15. Migration
|
|
458
740
|
|
|
459
|
-
|
|
460
|
-
import { createDecisionLog } from '@avsbhq/node'
|
|
741
|
+
### From LaunchDarkly Node
|
|
461
742
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
743
|
+
| LaunchDarkly Node | `@avsbhq/node` |
|
|
744
|
+
| ------------------------------------------------- | ------------------------------------------------------------ |
|
|
745
|
+
| `init(sdkKey, options)` | `new AvsbServer({ sdkKey })` |
|
|
746
|
+
| `client.waitForInitialization()` | `await server.onReady()` |
|
|
747
|
+
| `client.variation('key', context, default)` | `server.forUser(ctx).getBoolFlag('key', default).value` |
|
|
748
|
+
| `client.variationDetail('key', context, default)` | `server.forUser(ctx).getFlag('key', default)` |
|
|
749
|
+
| `client.track('event', context, { metricValue })` | `server.forUser(ctx).track('event', { value })` |
|
|
750
|
+
| `client.identify(context)` | not applicable (the server is stateless); use `forUser(ctx)` |
|
|
751
|
+
| `client.allFlagsState(context)` | `server.getAllFlags(ctx)` |
|
|
752
|
+
| `client.close()` | `await server.close()` |
|
|
753
|
+
|
|
754
|
+
Key differences: `getFlag` returns `Flag<T>`, not a raw value (`.value` for the primitive); `track` uses `value`, not `metricValue`; all evaluation is per-request via `forUser()`; multi-context is native.
|
|
755
|
+
|
|
756
|
+
### From Statsig Node
|
|
757
|
+
|
|
758
|
+
| Statsig Node | `@avsbhq/node` |
|
|
759
|
+
| ---------------------------------------------------------- | ------------------------------------------------------------ |
|
|
760
|
+
| `Statsig.initialize(serverSecret)` | `new AvsbServer({ sdkKey })` |
|
|
761
|
+
| `Statsig.checkGate(user, 'gate')` | `server.forUser(ctx).getBoolFlag('gate', false).isEnabled()` |
|
|
762
|
+
| `Statsig.getExperiment(user, 'exp').get('param', default)` | `server.forUser(ctx).getFlag('exp', default).value` |
|
|
763
|
+
| `Statsig.logEvent(user, 'event', value, metadata)` | `server.forUser(ctx).track('event', { value })` |
|
|
764
|
+
| `Statsig.flush()` | `await server.flush()` |
|
|
765
|
+
|
|
766
|
+
### From an earlier @avsbhq/node
|
|
767
|
+
|
|
768
|
+
| Was | Now |
|
|
769
|
+
| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
770
|
+
| `getFlag(key, default, { context })` | `getFlag(key, default, context)` |
|
|
771
|
+
| `getAllFlags(context, opts)` | same call; `context` is now optional and warns when omitted |
|
|
772
|
+
| `expressMiddleware(client, { userIdFrom })` | `expressMiddleware(server, { contextFrom })` |
|
|
773
|
+
| Pre-ready reads reported `not_found` | they report `not_ready` |
|
|
774
|
+
| `evaluation` event `{ flagKey, value, userId, source }` | `{ flagKey, flag, contextKeys }` |
|
|
775
|
+
| `DecideOption` members beyond `DISABLE_EXPOSURE` and `INCLUDE_REASONS` | deleted; they were never implemented |
|
|
776
|
+
| `streaming` defaulted to false | defaults to true, with silent fallback to polling |
|
|
777
|
+
| `track(key, { value: 199.0 })` for money | `track(key, { revenue: 199.0 })`; `value` now means an average-value quantity |
|
|
778
|
+
| `server.alias(...)` documented but absent | implemented, synchronous, and documented for what it really does |
|
|
469
779
|
|
|
470
780
|
---
|
|
471
781
|
|
|
472
|
-
##
|
|
782
|
+
## 16. Set this up with your AI assistant
|
|
473
783
|
|
|
474
|
-
|
|
784
|
+
Paste this into Claude Code, Cursor, or any coding assistant:
|
|
475
785
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
| `init(sdkKey, options)` | `new AvsbServer({ sdkKey })` |
|
|
479
|
-
| `client.waitForInitialization()` | `await server.onReady()` |
|
|
480
|
-
| `client.variation('key', context, default)` | `server.forUser(ctx).getBoolFlag('key', default).value` |
|
|
481
|
-
| `client.variationDetail('key', context, default)` | `server.forUser(ctx).getFlag('key', default)` |
|
|
482
|
-
| `client.track('event', context, { metricValue })` | `server.forUser(ctx).track('event', { value })` |
|
|
483
|
-
| `client.identify(context)` | not applicable (server is stateless); use `forUser(ctx)` |
|
|
484
|
-
| `client.allFlagsState(context)` | `server.getAllFlags(ctx)` |
|
|
485
|
-
| `client.close()` | `await server.close()` |
|
|
486
|
-
|
|
487
|
-
Key differences:
|
|
488
|
-
- `getFlag` returns `Flag<T>` not a raw value. Access `.value` for the primitive.
|
|
489
|
-
- `track` uses `value` (not `metricValue`) for the numeric metric.
|
|
490
|
-
- The server is context-free at construction; all evaluation is per-request via `forUser()`.
|
|
491
|
-
- Multi-context is native, with no `ldUser` adapter needed.
|
|
786
|
+
```text
|
|
787
|
+
Set up A vs B feature flags in this Node.js project.
|
|
492
788
|
|
|
493
|
-
|
|
789
|
+
1. Run: npx @avsbhq/cli init
|
|
790
|
+
Use my saved CLI login: do not ask me for a token and do not put one in any file.
|
|
791
|
+
It writes the SDK key into .env as AVSB_SDK_KEY and writes an example file.
|
|
792
|
+
2. Install @avsbhq/node with this project's package manager.
|
|
793
|
+
3. Construct AvsbServer once at boot with that key, await server.onReady(), and
|
|
794
|
+
bind a context per request with server.forUser({ kind: 'user', key: userId }).
|
|
795
|
+
4. Every getter returns a Flag object, so read .value or call .isEnabled(), and
|
|
796
|
+
always pass a fallback. Before the datafile arrives, reads return your fallback
|
|
797
|
+
and flag.source is 'not_ready'.
|
|
798
|
+
5. Then run: npx @avsbhq/cli codegen
|
|
799
|
+
|
|
800
|
+
Done looks like: the process starts, the flag reads without throwing, and avsb
|
|
801
|
+
init prints the line confirming it saw the first check-in.
|
|
802
|
+
```
|
|
494
803
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
| `Statsig.initialize(serverSecret)` | `new AvsbServer({ sdkKey })` |
|
|
498
|
-
| `Statsig.checkGate(user, 'gate')` | `server.forUser(ctx).getBoolFlag('gate', false).isEnabled()` |
|
|
499
|
-
| `Statsig.getExperiment(user, 'exp').get('param', default)` | `server.forUser(ctx).getFlag('exp', default).value` |
|
|
500
|
-
| `Statsig.logEvent(user, 'event', value, metadata)` | `server.forUser(ctx).track('event', { value, properties: metadata })` |
|
|
501
|
-
| `Statsig.flush()` | `await server.flush()` |
|
|
804
|
+
`avsb init` ends by waiting for your app's first check-in and printing what it
|
|
805
|
+
saw, so the terminal tells you it works rather than the dashboard.
|