@shelbyb/daywalker-sdk 0.1.3 → 0.2.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/README.md +26 -1
- package/dist/analytics/index.d.ts +99 -0
- package/dist/analytics/index.js +352 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +31 -3
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/dist/flags/client.d.ts +101 -0
- package/dist/flags/client.js +293 -0
- package/dist/flags/common/events.d.ts +59 -0
- package/dist/flags/common/events.js +178 -0
- package/dist/flags/common/index.d.ts +5 -0
- package/dist/flags/common/index.js +12 -0
- package/dist/flags/common/sse.d.ts +62 -0
- package/dist/flags/common/sse.js +183 -0
- package/dist/flags/engine/bucket.d.ts +21 -0
- package/dist/flags/engine/bucket.js +41 -0
- package/dist/flags/engine/context.d.ts +30 -0
- package/dist/flags/engine/context.js +80 -0
- package/dist/flags/engine/evaluate.d.ts +54 -0
- package/dist/flags/engine/evaluate.js +177 -0
- package/dist/flags/engine/index.d.ts +7 -0
- package/dist/flags/engine/index.js +7 -0
- package/dist/flags/engine/operators.d.ts +5 -0
- package/dist/flags/engine/operators.js +89 -0
- package/dist/flags/engine/semver.d.ts +13 -0
- package/dist/flags/engine/semver.js +35 -0
- package/dist/flags/engine/sha1.d.ts +6 -0
- package/dist/flags/engine/sha1.js +43 -0
- package/dist/flags/engine/types.d.ts +120 -0
- package/dist/flags/engine/types.js +17 -0
- package/dist/flags/node.d.ts +97 -0
- package/dist/flags/node.js +260 -0
- package/dist/flags/react.d.ts +27 -0
- package/dist/flags/react.js +62 -0
- package/dist/openfeature/server.d.ts +26 -0
- package/dist/openfeature/server.js +42 -0
- package/dist/openfeature/shared.d.ts +19 -0
- package/dist/openfeature/shared.js +40 -0
- package/dist/openfeature/web.d.ts +29 -0
- package/dist/openfeature/web.js +50 -0
- package/dist/types.d.ts +2 -0
- package/docs/analytics.md +32 -0
- package/docs/flags-node.md +49 -0
- package/docs/flags-react.md +35 -0
- package/docs/flags.md +53 -0
- package/docs/openfeature.md +30 -0
- package/package.json +61 -6
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ContextInput, EvaluationReason, FlagValue } from "./engine/index.js";
|
|
2
|
+
import { type Logger } from "./common/index.js";
|
|
3
|
+
export type { ContextInput, EvaluationReason, FlagValue, Logger };
|
|
4
|
+
export interface FlagState {
|
|
5
|
+
value: FlagValue;
|
|
6
|
+
variation: number | null;
|
|
7
|
+
version: number;
|
|
8
|
+
reason?: EvaluationReason;
|
|
9
|
+
trackEvents?: boolean;
|
|
10
|
+
trackReason?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export type FlagSet = Record<string, FlagState>;
|
|
13
|
+
export interface ClientOptions {
|
|
14
|
+
/** Base URL of your DevPulse deployment, e.g. https://flags.example.com */
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
/** Receive changes in real time over Server-Sent Events (default true). */
|
|
17
|
+
streaming?: boolean;
|
|
18
|
+
/** Initial flag values, e.g. from the Node SDK's allFlagsState() for SSR. */
|
|
19
|
+
bootstrap?: FlagSet | Record<string, FlagValue>;
|
|
20
|
+
/** Secure mode hash for the context (HMAC-SHA256 computed on your server). */
|
|
21
|
+
hash?: string;
|
|
22
|
+
/** Include evaluation reasons (for variationDetail). */
|
|
23
|
+
withReasons?: boolean;
|
|
24
|
+
sendEvents?: boolean;
|
|
25
|
+
flushIntervalMs?: number;
|
|
26
|
+
privateAttributes?: string[];
|
|
27
|
+
allAttributesPrivate?: boolean;
|
|
28
|
+
/** Cache the last flags in localStorage and use them while loading. */
|
|
29
|
+
cache?: boolean;
|
|
30
|
+
fetch?: typeof fetch;
|
|
31
|
+
logger?: Logger;
|
|
32
|
+
}
|
|
33
|
+
export interface EvaluationDetail<T = FlagValue> {
|
|
34
|
+
value: T;
|
|
35
|
+
variationIndex: number | null;
|
|
36
|
+
reason: EvaluationReason | null;
|
|
37
|
+
}
|
|
38
|
+
type Listener = (...args: unknown[]) => void;
|
|
39
|
+
/**
|
|
40
|
+
* A client for one evaluation context. Fetches evaluated flags, keeps them
|
|
41
|
+
* current over SSE, and reports analytics.
|
|
42
|
+
*
|
|
43
|
+
* const client = createClient("<client-side id>", { key: user.id }, { baseUrl });
|
|
44
|
+
* await client.waitForInitialization();
|
|
45
|
+
* client.on("change:new-checkout", (value) => render(value));
|
|
46
|
+
* client.variation("new-checkout", false);
|
|
47
|
+
*/
|
|
48
|
+
export declare class DevPulseClient {
|
|
49
|
+
private readonly clientSideId;
|
|
50
|
+
private context;
|
|
51
|
+
private readonly options;
|
|
52
|
+
private flags;
|
|
53
|
+
private listeners;
|
|
54
|
+
private stream;
|
|
55
|
+
private events;
|
|
56
|
+
private ready;
|
|
57
|
+
private resolveReady;
|
|
58
|
+
private rejectReady;
|
|
59
|
+
private initialized;
|
|
60
|
+
private closed;
|
|
61
|
+
private readonly logger;
|
|
62
|
+
private readonly base;
|
|
63
|
+
constructor(clientSideId: string, context: ContextInput, options: ClientOptions);
|
|
64
|
+
private query;
|
|
65
|
+
private start;
|
|
66
|
+
private fetchFlags;
|
|
67
|
+
private onStreamEvent;
|
|
68
|
+
private replaceAll;
|
|
69
|
+
private markReady;
|
|
70
|
+
private fail;
|
|
71
|
+
private emitChanges;
|
|
72
|
+
private cacheKey;
|
|
73
|
+
private readCache;
|
|
74
|
+
private writeCache;
|
|
75
|
+
/** Resolves once flags are loaded (or rejects on a fatal error such as an invalid client-side ID). */
|
|
76
|
+
waitForInitialization(opts?: {
|
|
77
|
+
timeoutMs?: number;
|
|
78
|
+
}): Promise<void>;
|
|
79
|
+
get isInitialized(): boolean;
|
|
80
|
+
variation<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
81
|
+
variationDetail<T extends FlagValue>(key: string, defaultValue: T): EvaluationDetail<T>;
|
|
82
|
+
boolVariation(key: string, defaultValue: boolean): boolean;
|
|
83
|
+
stringVariation(key: string, defaultValue: string): string;
|
|
84
|
+
numberVariation(key: string, defaultValue: number): number;
|
|
85
|
+
jsonVariation<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
86
|
+
/** All flag values (doesn't record evaluations). */
|
|
87
|
+
allFlags(): Record<string, FlagValue>;
|
|
88
|
+
/** Switch to a different context (e.g. after login). Resolves with the new flags. */
|
|
89
|
+
identify(context: ContextInput, opts?: {
|
|
90
|
+
hash?: string;
|
|
91
|
+
}): Promise<Record<string, FlagValue>>;
|
|
92
|
+
getContext(): ContextInput;
|
|
93
|
+
/** Record a custom event for metrics and experiments. */
|
|
94
|
+
track(key: string, data?: unknown, metricValue?: number): void;
|
|
95
|
+
on(event: "ready" | "failed" | "change" | `change:${string}`, fn: Listener): () => void;
|
|
96
|
+
off(event: string, fn: Listener): void;
|
|
97
|
+
private emit;
|
|
98
|
+
flush(): Promise<void>;
|
|
99
|
+
close(): Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
export declare function createClient(clientSideId: string, context: ContextInput, options: ClientOptions): DevPulseClient;
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { EventProcessor, SseClient, encodeContext, noopLogger } from "./common/index.js";
|
|
2
|
+
function isFlagSet(x) {
|
|
3
|
+
return !!x && typeof x === "object" && Object.values(x).every((v) => v && typeof v === "object" && "value" in v && "version" in v);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* A client for one evaluation context. Fetches evaluated flags, keeps them
|
|
7
|
+
* current over SSE, and reports analytics.
|
|
8
|
+
*
|
|
9
|
+
* const client = createClient("<client-side id>", { key: user.id }, { baseUrl });
|
|
10
|
+
* await client.waitForInitialization();
|
|
11
|
+
* client.on("change:new-checkout", (value) => render(value));
|
|
12
|
+
* client.variation("new-checkout", false);
|
|
13
|
+
*/
|
|
14
|
+
export class DevPulseClient {
|
|
15
|
+
clientSideId;
|
|
16
|
+
context;
|
|
17
|
+
options;
|
|
18
|
+
flags = {};
|
|
19
|
+
listeners = new Map();
|
|
20
|
+
stream = null;
|
|
21
|
+
events = null;
|
|
22
|
+
ready;
|
|
23
|
+
resolveReady;
|
|
24
|
+
rejectReady;
|
|
25
|
+
initialized = false;
|
|
26
|
+
closed = false;
|
|
27
|
+
logger;
|
|
28
|
+
base;
|
|
29
|
+
constructor(clientSideId, context, options) {
|
|
30
|
+
this.clientSideId = clientSideId;
|
|
31
|
+
this.context = context;
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.logger = options.logger ?? noopLogger;
|
|
34
|
+
this.base = options.baseUrl.replace(/\/$/, "");
|
|
35
|
+
this.ready = new Promise((res, rej) => {
|
|
36
|
+
this.resolveReady = res;
|
|
37
|
+
this.rejectReady = rej;
|
|
38
|
+
});
|
|
39
|
+
this.ready.catch(() => undefined);
|
|
40
|
+
if (options.bootstrap) {
|
|
41
|
+
this.flags = isFlagSet(options.bootstrap)
|
|
42
|
+
? { ...options.bootstrap }
|
|
43
|
+
: Object.fromEntries(Object.entries(options.bootstrap).map(([k, v]) => [k, { value: v, variation: null, version: 0 }]));
|
|
44
|
+
this.markReady();
|
|
45
|
+
}
|
|
46
|
+
else if (options.cache) {
|
|
47
|
+
const cached = this.readCache();
|
|
48
|
+
if (cached)
|
|
49
|
+
this.flags = cached;
|
|
50
|
+
}
|
|
51
|
+
if (options.sendEvents !== false && typeof globalThis.fetch === "function") {
|
|
52
|
+
this.events = new EventProcessor({
|
|
53
|
+
url: `${this.base}/sdk/v1/events/client/${encodeURIComponent(clientSideId)}`,
|
|
54
|
+
flushIntervalMs: options.flushIntervalMs ?? 5000,
|
|
55
|
+
privateAttributes: options.privateAttributes,
|
|
56
|
+
allAttributesPrivate: options.allAttributesPrivate,
|
|
57
|
+
fetch: options.fetch,
|
|
58
|
+
beacon: true,
|
|
59
|
+
logger: this.logger,
|
|
60
|
+
});
|
|
61
|
+
this.events.identify(context);
|
|
62
|
+
const doc = globalThis.document;
|
|
63
|
+
doc?.addEventListener("visibilitychange", () => {
|
|
64
|
+
if (doc.visibilityState === "hidden")
|
|
65
|
+
void this.events?.flush();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
void this.start();
|
|
69
|
+
}
|
|
70
|
+
query() {
|
|
71
|
+
const p = new URLSearchParams({ context: encodeContext(this.context) });
|
|
72
|
+
if (this.options.hash)
|
|
73
|
+
p.set("h", this.options.hash);
|
|
74
|
+
if (this.options.withReasons)
|
|
75
|
+
p.set("withReasons", "true");
|
|
76
|
+
return p.toString();
|
|
77
|
+
}
|
|
78
|
+
async start() {
|
|
79
|
+
if (this.options.streaming === false) {
|
|
80
|
+
try {
|
|
81
|
+
this.replaceAll(await this.fetchFlags());
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
this.fail(err);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.stream = new SseClient({
|
|
89
|
+
url: () => `${this.base}/sdk/v1/client/${encodeURIComponent(this.clientSideId)}/stream?${this.query()}`,
|
|
90
|
+
fetch: this.options.fetch,
|
|
91
|
+
onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
|
|
92
|
+
onOpen: () => this.logger.debug("DevPulse stream connected"),
|
|
93
|
+
onError: (err) => {
|
|
94
|
+
this.logger.warn(`DevPulse stream error: ${err.message}`);
|
|
95
|
+
if (!err.recoverable) {
|
|
96
|
+
this.fail(err);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
// Keep serving flags while reconnecting; fetch once so a first load still succeeds.
|
|
100
|
+
if (!this.initialized)
|
|
101
|
+
this.fetchFlags().then((f) => this.replaceAll(f), () => undefined);
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
this.stream.start();
|
|
106
|
+
}
|
|
107
|
+
async fetchFlags() {
|
|
108
|
+
const res = await (this.options.fetch ?? fetch)(`${this.base}/sdk/v1/client/${encodeURIComponent(this.clientSideId)}/flags?${this.query()}`);
|
|
109
|
+
if (!res.ok)
|
|
110
|
+
throw new Error(`Flag request failed with ${res.status}`);
|
|
111
|
+
return (await res.json());
|
|
112
|
+
}
|
|
113
|
+
onStreamEvent(type, data) {
|
|
114
|
+
try {
|
|
115
|
+
const payload = JSON.parse(data);
|
|
116
|
+
if (type === "put")
|
|
117
|
+
this.replaceAll(payload);
|
|
118
|
+
else if (type === "patch") {
|
|
119
|
+
const { key, ...state } = payload;
|
|
120
|
+
const prev = this.flags[key];
|
|
121
|
+
if (prev && prev.version > state.version)
|
|
122
|
+
return;
|
|
123
|
+
this.flags[key] = state;
|
|
124
|
+
this.emitChanges({ [key]: { current: state.value, previous: prev?.value } });
|
|
125
|
+
this.writeCache();
|
|
126
|
+
}
|
|
127
|
+
else if (type === "delete") {
|
|
128
|
+
const { key, version } = payload;
|
|
129
|
+
const prev = this.flags[key];
|
|
130
|
+
if (!prev || prev.version > version)
|
|
131
|
+
return;
|
|
132
|
+
delete this.flags[key];
|
|
133
|
+
this.emitChanges({ [key]: { current: null, previous: prev.value } });
|
|
134
|
+
this.writeCache();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
this.logger.error("Malformed stream event", err);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
replaceAll(next) {
|
|
142
|
+
const changes = {};
|
|
143
|
+
for (const key of new Set([...Object.keys(this.flags), ...Object.keys(next)])) {
|
|
144
|
+
const a = this.flags[key]?.value;
|
|
145
|
+
const b = next[key]?.value;
|
|
146
|
+
if (JSON.stringify(a) !== JSON.stringify(b))
|
|
147
|
+
changes[key] = { current: b ?? null, previous: a };
|
|
148
|
+
}
|
|
149
|
+
this.flags = { ...next };
|
|
150
|
+
this.writeCache();
|
|
151
|
+
this.markReady();
|
|
152
|
+
if (Object.keys(changes).length)
|
|
153
|
+
this.emitChanges(changes);
|
|
154
|
+
}
|
|
155
|
+
markReady() {
|
|
156
|
+
if (this.initialized)
|
|
157
|
+
return;
|
|
158
|
+
this.initialized = true;
|
|
159
|
+
this.resolveReady();
|
|
160
|
+
this.emit("ready");
|
|
161
|
+
}
|
|
162
|
+
fail(err) {
|
|
163
|
+
if (!this.initialized)
|
|
164
|
+
this.rejectReady(err);
|
|
165
|
+
this.emit("failed", err);
|
|
166
|
+
}
|
|
167
|
+
emitChanges(changes) {
|
|
168
|
+
this.emit("change", changes);
|
|
169
|
+
for (const [key, c] of Object.entries(changes))
|
|
170
|
+
this.emit(`change:${key}`, c.current, c.previous);
|
|
171
|
+
}
|
|
172
|
+
cacheKey() {
|
|
173
|
+
return `devpulse:${this.clientSideId}:${encodeContext(this.context)}`;
|
|
174
|
+
}
|
|
175
|
+
readCache() {
|
|
176
|
+
try {
|
|
177
|
+
const raw = globalThis.localStorage?.getItem(this.cacheKey());
|
|
178
|
+
return raw ? JSON.parse(raw) : null;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
writeCache() {
|
|
185
|
+
if (!this.options.cache)
|
|
186
|
+
return;
|
|
187
|
+
try {
|
|
188
|
+
globalThis.localStorage?.setItem(this.cacheKey(), JSON.stringify(this.flags));
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* storage full or unavailable */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ---------- Public API ----------
|
|
195
|
+
/** Resolves once flags are loaded (or rejects on a fatal error such as an invalid client-side ID). */
|
|
196
|
+
waitForInitialization(opts = {}) {
|
|
197
|
+
if (!opts.timeoutMs)
|
|
198
|
+
return this.ready;
|
|
199
|
+
let timer;
|
|
200
|
+
const timeout = new Promise((_, rej) => {
|
|
201
|
+
timer = setTimeout(() => rej(new Error("DevPulse initialization timed out")), opts.timeoutMs);
|
|
202
|
+
});
|
|
203
|
+
return Promise.race([this.ready, timeout]).finally(() => clearTimeout(timer));
|
|
204
|
+
}
|
|
205
|
+
get isInitialized() {
|
|
206
|
+
return this.initialized;
|
|
207
|
+
}
|
|
208
|
+
variation(key, defaultValue) {
|
|
209
|
+
return this.variationDetail(key, defaultValue).value;
|
|
210
|
+
}
|
|
211
|
+
variationDetail(key, defaultValue) {
|
|
212
|
+
const f = this.flags[key];
|
|
213
|
+
let detail;
|
|
214
|
+
if (!f)
|
|
215
|
+
detail = { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: this.initialized ? "FLAG_NOT_FOUND" : "CLIENT_NOT_READY" } };
|
|
216
|
+
// A null value means "off with no off variation": serve the caller's default.
|
|
217
|
+
else if (f.value === null)
|
|
218
|
+
detail = { value: defaultValue, variationIndex: f.variation, reason: f.reason ?? null };
|
|
219
|
+
else if (defaultValue !== null && typeof defaultValue !== typeof f.value)
|
|
220
|
+
detail = { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: "WRONG_TYPE" } };
|
|
221
|
+
else
|
|
222
|
+
detail = { value: f.value, variationIndex: f.variation, reason: f.reason ?? null };
|
|
223
|
+
this.events?.recordEvaluation({ key, context: this.context, value: detail.value, default: defaultValue, variation: detail.variationIndex, version: f?.version, reason: f?.reason, trackEvents: f?.trackEvents });
|
|
224
|
+
return detail;
|
|
225
|
+
}
|
|
226
|
+
boolVariation(key, defaultValue) {
|
|
227
|
+
return this.variation(key, defaultValue);
|
|
228
|
+
}
|
|
229
|
+
stringVariation(key, defaultValue) {
|
|
230
|
+
return this.variation(key, defaultValue);
|
|
231
|
+
}
|
|
232
|
+
numberVariation(key, defaultValue) {
|
|
233
|
+
return this.variation(key, defaultValue);
|
|
234
|
+
}
|
|
235
|
+
jsonVariation(key, defaultValue) {
|
|
236
|
+
return this.variation(key, defaultValue);
|
|
237
|
+
}
|
|
238
|
+
/** All flag values (doesn't record evaluations). */
|
|
239
|
+
allFlags() {
|
|
240
|
+
return Object.fromEntries(Object.entries(this.flags).map(([k, v]) => [k, v.value]));
|
|
241
|
+
}
|
|
242
|
+
/** Switch to a different context (e.g. after login). Resolves with the new flags. */
|
|
243
|
+
async identify(context, opts = {}) {
|
|
244
|
+
this.context = context;
|
|
245
|
+
if (opts.hash !== undefined)
|
|
246
|
+
this.options.hash = opts.hash;
|
|
247
|
+
this.events?.identify(context);
|
|
248
|
+
this.replaceAll(await this.fetchFlags());
|
|
249
|
+
this.stream?.restart();
|
|
250
|
+
return this.allFlags();
|
|
251
|
+
}
|
|
252
|
+
getContext() {
|
|
253
|
+
return this.context;
|
|
254
|
+
}
|
|
255
|
+
/** Record a custom event for metrics and experiments. */
|
|
256
|
+
track(key, data, metricValue) {
|
|
257
|
+
this.events?.track(key, this.context, data, metricValue);
|
|
258
|
+
}
|
|
259
|
+
on(event, fn) {
|
|
260
|
+
let set = this.listeners.get(event);
|
|
261
|
+
if (!set)
|
|
262
|
+
this.listeners.set(event, (set = new Set()));
|
|
263
|
+
set.add(fn);
|
|
264
|
+
return () => this.off(event, fn);
|
|
265
|
+
}
|
|
266
|
+
off(event, fn) {
|
|
267
|
+
this.listeners.get(event)?.delete(fn);
|
|
268
|
+
}
|
|
269
|
+
emit(event, ...args) {
|
|
270
|
+
for (const fn of this.listeners.get(event) ?? []) {
|
|
271
|
+
try {
|
|
272
|
+
fn(...args);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
this.logger.error(`Listener for "${event}" threw`, err);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async flush() {
|
|
280
|
+
await this.events?.flush();
|
|
281
|
+
}
|
|
282
|
+
async close() {
|
|
283
|
+
if (this.closed)
|
|
284
|
+
return;
|
|
285
|
+
this.closed = true;
|
|
286
|
+
this.stream?.stop();
|
|
287
|
+
await this.events?.close();
|
|
288
|
+
this.listeners.clear();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
export function createClient(clientSideId, context, options) {
|
|
292
|
+
return new DevPulseClient(clientSideId, context, options);
|
|
293
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type ContextInput, type EvaluationReason, type FlagValue } from "../engine/index.js";
|
|
2
|
+
export interface EventProcessorOptions {
|
|
3
|
+
url: string;
|
|
4
|
+
headers?: Record<string, string>;
|
|
5
|
+
fetch?: typeof fetch;
|
|
6
|
+
flushIntervalMs?: number;
|
|
7
|
+
capacity?: number;
|
|
8
|
+
/** Treat every attribute (except key/kind) as private. */
|
|
9
|
+
allAttributesPrivate?: boolean;
|
|
10
|
+
/** Attribute names (or RFC 6901 pointers) never sent to DevPulse. */
|
|
11
|
+
privateAttributes?: string[];
|
|
12
|
+
/** Use navigator.sendBeacon when available (browsers, on page hide). */
|
|
13
|
+
beacon?: boolean;
|
|
14
|
+
logger?: Logger;
|
|
15
|
+
}
|
|
16
|
+
export interface Logger {
|
|
17
|
+
debug(...args: unknown[]): void;
|
|
18
|
+
info(...args: unknown[]): void;
|
|
19
|
+
warn(...args: unknown[]): void;
|
|
20
|
+
error(...args: unknown[]): void;
|
|
21
|
+
}
|
|
22
|
+
export declare const noopLogger: Logger;
|
|
23
|
+
/**
|
|
24
|
+
* Buffers analytics: per-flag summaries (counts per variation), context
|
|
25
|
+
* index events (deduplicated), full feature events for tracked flags and
|
|
26
|
+
* experiments, and custom events. Flushes on an interval and on demand.
|
|
27
|
+
*/
|
|
28
|
+
export declare class EventProcessor {
|
|
29
|
+
private readonly opts;
|
|
30
|
+
private summary;
|
|
31
|
+
private summaryStart;
|
|
32
|
+
private queue;
|
|
33
|
+
private seenContexts;
|
|
34
|
+
private timer;
|
|
35
|
+
private disabled;
|
|
36
|
+
private readonly logger;
|
|
37
|
+
constructor(opts: EventProcessorOptions);
|
|
38
|
+
/** Strip private attributes and record which were removed. */
|
|
39
|
+
redact(context: ContextInput): Record<string, unknown>;
|
|
40
|
+
private contextKeys;
|
|
41
|
+
/** Queue an index event the first time a context is seen in a while. */
|
|
42
|
+
private noticeContext;
|
|
43
|
+
recordEvaluation(e: {
|
|
44
|
+
key: string;
|
|
45
|
+
context: ContextInput;
|
|
46
|
+
value: FlagValue;
|
|
47
|
+
default: FlagValue;
|
|
48
|
+
variation: number | null;
|
|
49
|
+
version?: number;
|
|
50
|
+
reason?: EvaluationReason;
|
|
51
|
+
trackEvents?: boolean;
|
|
52
|
+
}): void;
|
|
53
|
+
track(key: string, context: ContextInput, data?: unknown, metricValue?: number): void;
|
|
54
|
+
identify(context: ContextInput): void;
|
|
55
|
+
private push;
|
|
56
|
+
private drain;
|
|
57
|
+
flush(): Promise<void>;
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { parseContext } from "../engine/index.js";
|
|
2
|
+
export const noopLogger = { debug() { }, info() { }, warn() { }, error() { } };
|
|
3
|
+
/**
|
|
4
|
+
* Buffers analytics: per-flag summaries (counts per variation), context
|
|
5
|
+
* index events (deduplicated), full feature events for tracked flags and
|
|
6
|
+
* experiments, and custom events. Flushes on an interval and on demand.
|
|
7
|
+
*/
|
|
8
|
+
export class EventProcessor {
|
|
9
|
+
opts;
|
|
10
|
+
summary = new Map();
|
|
11
|
+
summaryStart = 0;
|
|
12
|
+
queue = [];
|
|
13
|
+
seenContexts = new Map();
|
|
14
|
+
timer = null;
|
|
15
|
+
disabled = false;
|
|
16
|
+
logger;
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
this.opts = opts;
|
|
19
|
+
this.logger = opts.logger ?? noopLogger;
|
|
20
|
+
this.timer = setInterval(() => void this.flush(), opts.flushIntervalMs ?? 5000);
|
|
21
|
+
this.timer.unref?.();
|
|
22
|
+
}
|
|
23
|
+
/** Strip private attributes and record which were removed. */
|
|
24
|
+
redact(context) {
|
|
25
|
+
const strip = (c) => {
|
|
26
|
+
const meta = (c._meta ?? {});
|
|
27
|
+
const priv = new Set([...(this.opts.privateAttributes ?? []), ...(meta.privateAttributes ?? [])]);
|
|
28
|
+
const out = {};
|
|
29
|
+
const redacted = [];
|
|
30
|
+
for (const [k, v] of Object.entries(c)) {
|
|
31
|
+
if (k === "_meta")
|
|
32
|
+
continue;
|
|
33
|
+
if (k !== "key" && k !== "kind" && k !== "anonymous" && (this.opts.allAttributesPrivate || priv.has(k) || priv.has(`/${k}`)))
|
|
34
|
+
redacted.push(k);
|
|
35
|
+
else
|
|
36
|
+
out[k] = v;
|
|
37
|
+
}
|
|
38
|
+
if (redacted.length)
|
|
39
|
+
out.redactedAttributes = redacted;
|
|
40
|
+
return out;
|
|
41
|
+
};
|
|
42
|
+
if (context.kind === "multi") {
|
|
43
|
+
const out = { kind: "multi" };
|
|
44
|
+
for (const [k, v] of Object.entries(context))
|
|
45
|
+
if (k !== "kind")
|
|
46
|
+
out[k] = strip(v);
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
return strip(context);
|
|
50
|
+
}
|
|
51
|
+
contextKeys(context) {
|
|
52
|
+
try {
|
|
53
|
+
const ctx = parseContext(context);
|
|
54
|
+
return Object.fromEntries([...ctx.kinds.values()].map((c) => [c.kind, c.key]));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Queue an index event the first time a context is seen in a while. */
|
|
61
|
+
noticeContext(context) {
|
|
62
|
+
let canonical;
|
|
63
|
+
try {
|
|
64
|
+
canonical = parseContext(context).canonicalKey;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const last = this.seenContexts.get(canonical);
|
|
71
|
+
if (last && now - last < 5 * 60_000)
|
|
72
|
+
return;
|
|
73
|
+
if (this.seenContexts.size > 1000)
|
|
74
|
+
this.seenContexts.clear();
|
|
75
|
+
this.seenContexts.set(canonical, now);
|
|
76
|
+
this.push({ kind: "index", creationDate: now, context: this.redact(context) });
|
|
77
|
+
}
|
|
78
|
+
recordEvaluation(e) {
|
|
79
|
+
if (this.disabled)
|
|
80
|
+
return;
|
|
81
|
+
this.noticeContext(e.context);
|
|
82
|
+
if (!this.summaryStart)
|
|
83
|
+
this.summaryStart = Date.now();
|
|
84
|
+
let s = this.summary.get(e.key);
|
|
85
|
+
if (!s)
|
|
86
|
+
this.summary.set(e.key, (s = { default: e.default, counters: new Map() }));
|
|
87
|
+
const unknown = e.version === undefined;
|
|
88
|
+
const ck = `${e.variation ?? "default"}:${e.version ?? "unknown"}`;
|
|
89
|
+
const c = s.counters.get(ck) ?? { value: e.value, count: 0, ...(unknown ? { unknown: true } : { variation: e.variation ?? undefined, version: e.version }) };
|
|
90
|
+
c.count++;
|
|
91
|
+
s.counters.set(ck, c);
|
|
92
|
+
const inExperiment = !!e.reason && "inExperiment" in e.reason && e.reason.inExperiment;
|
|
93
|
+
if (e.trackEvents || inExperiment) {
|
|
94
|
+
this.push({ kind: "feature", creationDate: Date.now(), key: e.key, version: e.version, variation: e.variation, value: e.value, default: e.default, contextKeys: this.contextKeys(e.context), ...(e.reason ? { reason: e.reason } : {}) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
track(key, context, data, metricValue) {
|
|
98
|
+
if (this.disabled)
|
|
99
|
+
return;
|
|
100
|
+
this.noticeContext(context);
|
|
101
|
+
const keys = this.contextKeys(context);
|
|
102
|
+
if (!keys)
|
|
103
|
+
return this.logger.warn("track() called with an invalid context");
|
|
104
|
+
this.push({ kind: "custom", key, creationDate: Date.now(), contextKeys: keys, ...(data !== undefined ? { data } : {}), ...(typeof metricValue === "number" ? { metricValue } : {}) });
|
|
105
|
+
}
|
|
106
|
+
identify(context) {
|
|
107
|
+
if (this.disabled)
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
parseContext(context);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
this.push({ kind: "identify", creationDate: Date.now(), context: this.redact(context) });
|
|
116
|
+
}
|
|
117
|
+
push(e) {
|
|
118
|
+
if (this.queue.length >= (this.opts.capacity ?? 10_000))
|
|
119
|
+
return this.logger.warn("Event buffer full; dropping events");
|
|
120
|
+
this.queue.push(e);
|
|
121
|
+
}
|
|
122
|
+
drain() {
|
|
123
|
+
const events = this.queue;
|
|
124
|
+
this.queue = [];
|
|
125
|
+
if (this.summary.size) {
|
|
126
|
+
events.push({
|
|
127
|
+
kind: "summary",
|
|
128
|
+
startDate: this.summaryStart,
|
|
129
|
+
endDate: Date.now(),
|
|
130
|
+
features: Object.fromEntries([...this.summary.entries()].map(([k, s]) => [k, { default: s.default, counters: [...s.counters.values()] }])),
|
|
131
|
+
});
|
|
132
|
+
this.summary.clear();
|
|
133
|
+
this.summaryStart = 0;
|
|
134
|
+
}
|
|
135
|
+
return events;
|
|
136
|
+
}
|
|
137
|
+
async flush() {
|
|
138
|
+
if (this.disabled)
|
|
139
|
+
return;
|
|
140
|
+
const events = this.drain();
|
|
141
|
+
if (!events.length)
|
|
142
|
+
return;
|
|
143
|
+
const body = JSON.stringify(events);
|
|
144
|
+
const nav = globalThis.navigator;
|
|
145
|
+
if (this.opts.beacon && nav?.sendBeacon && document?.visibilityState === "hidden") {
|
|
146
|
+
if (nav.sendBeacon(this.opts.url, body))
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
150
|
+
try {
|
|
151
|
+
const res = await (this.opts.fetch ?? fetch)(this.opts.url, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "content-type": "application/json", ...this.opts.headers },
|
|
154
|
+
body,
|
|
155
|
+
keepalive: body.length < 60_000,
|
|
156
|
+
});
|
|
157
|
+
if (res.ok)
|
|
158
|
+
return;
|
|
159
|
+
if (res.status === 401 || res.status === 403) {
|
|
160
|
+
this.disabled = true;
|
|
161
|
+
return this.logger.error(`Events rejected (${res.status}); analytics disabled. Check your SDK key.`);
|
|
162
|
+
}
|
|
163
|
+
if (res.status < 500 && res.status !== 429)
|
|
164
|
+
return this.logger.warn(`Events rejected (${res.status})`);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (attempt === 1)
|
|
168
|
+
this.logger.warn("Failed to send events", err);
|
|
169
|
+
}
|
|
170
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async close() {
|
|
174
|
+
if (this.timer)
|
|
175
|
+
clearInterval(this.timer);
|
|
176
|
+
await this.flush();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SseParser, SseClient, SseError, backoffDelay, type SseEvent, type SseClientOptions } from "./sse.js";
|
|
2
|
+
export { EventProcessor, noopLogger, type EventProcessorOptions, type Logger } from "./events.js";
|
|
3
|
+
/** Encode a context for URLs (base64url JSON), matching the DevPulse SDK endpoints. */
|
|
4
|
+
export declare function encodeContext(context: unknown): string;
|
|
5
|
+
export declare const SDK_VERSION = "0.1.0";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { SseParser, SseClient, SseError, backoffDelay } from "./sse.js";
|
|
2
|
+
export { EventProcessor, noopLogger } from "./events.js";
|
|
3
|
+
/** Encode a context for URLs (base64url JSON), matching the DevPulse SDK endpoints. */
|
|
4
|
+
export function encodeContext(context) {
|
|
5
|
+
const json = JSON.stringify(context);
|
|
6
|
+
const bytes = new TextEncoder().encode(json);
|
|
7
|
+
let bin = "";
|
|
8
|
+
for (const b of bytes)
|
|
9
|
+
bin += String.fromCharCode(b);
|
|
10
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
11
|
+
}
|
|
12
|
+
export const SDK_VERSION = "0.1.0";
|