@emit-vision/sdk-js 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emit Vision
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # @emit-vision/sdk-js
2
+
3
+ Browser SDK for Emit Vision, the self-hostable analytics and error tracking stack in this repo.
4
+
5
+ For a full local walkthrough, see [docs/sdk-guide.md](../../docs/sdk-guide.md).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @emit-vision/sdk-js
11
+ ```
12
+
13
+ To consume the package from another local project before publishing:
14
+
15
+ ```bash
16
+ pnpm nx run sdk-js:build
17
+ cd packages/sdk-js
18
+ pnpm pack
19
+ ```
20
+
21
+ Then install the generated tarball:
22
+
23
+ ```bash
24
+ npm install /absolute/path/to/emit-vision/packages/sdk-js/emit-vision-sdk-js-0.1.0.tgz
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```ts
30
+ import { init, captureEvent } from "@emit-vision/sdk-js";
31
+
32
+ init({
33
+ dsn: "http://evk_local_development_seed_key_000000000000@localhost:4301/v1",
34
+ environment: "development",
35
+ release: "web@1.0.0",
36
+ autoCapture: {
37
+ errors: true,
38
+ unhandledRejections: true,
39
+ },
40
+ });
41
+
42
+ captureEvent("user_signed_up", { plan: "free" });
43
+ ```
44
+
45
+ ## When to Use This Package
46
+
47
+ Use `@emit-vision/sdk-js` directly when your app is already client-side and you
48
+ want the thinnest possible integration path.
49
+
50
+ Good fits include:
51
+
52
+ - Vanilla TypeScript or JavaScript browser apps
53
+ - Vite or other framework-agnostic frontend bundles
54
+ - Apps that want full control over when `init()`, `captureEvent()`, and
55
+ `flush()` run
56
+
57
+ If you are in React or Next.js, the helper packages wrap this SDK without
58
+ changing the ingest contract:
59
+
60
+ - `@emit-vision/sdk-react` for provider and hook ergonomics in React trees
61
+ - `@emit-vision/sdk-next` for app-router projects that want a client provider
62
+ with server-side config composition
63
+
64
+ Those helpers are convenience layers, not alternate transports. The same
65
+ browser-only privacy and safety guidance still applies.
66
+
67
+ ## API Reference
68
+
69
+ ### `init(options: EmitVisionOptions): void`
70
+
71
+ Initializes the SDK. Call it once, as early as possible.
72
+
73
+ | Option | Type | Default | Description |
74
+ | ------------------- | ----------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- | ------ | --- | ---------------------------------------------------------- |
75
+ | `dsn` | `string` | — | Local DSN in the form `http://<api-key>@localhost:4301/v1` |
76
+ | `apiKey` | `string` | — | Explicit API key if you do not want to use a DSN |
77
+ | `endpoint` | `string` | `http://localhost:4301` | Explicit ingest API base URL |
78
+ | `environment` | `string` | — | Environment label such as `development` or `production` |
79
+ | `release` | `string` | — | Release or git SHA attached to outgoing events |
80
+ | `sessionId` | `string` | — | Session identifier attached to future events |
81
+ | `deployment` | `{ deploymentId?: string; buildId?: string }` | — | Optional deployment metadata attached to outgoing events |
82
+ | `featureFlags` | `Record<string, string | number | boolean | null>` | — | Optional feature-flag snapshot attached to outgoing events |
83
+ | `autoCapture` | `{ errors?: boolean; unhandledRejections?: boolean }` | `{ errors: true, unhandledRejections: true }` | Controls automatic browser error capture |
84
+ | `autoCaptureErrors` | `boolean` | `true` | Backward-compatible alias that enables or disables both auto-capture hooks |
85
+ | `debug` | `boolean` | `false` | Emits SDK lifecycle messages to `console.debug` |
86
+ | `flushIntervalMs` | `number` | `5000` | Automatic flush cadence in milliseconds |
87
+ | `batchSize` | `number` | `20` | Queue size that triggers an immediate flush |
88
+ | `flagEvalTtlMs` | `number` | `60000` | Default TTL (ms) for the in-memory flag evaluation cache |
89
+
90
+ ### `captureEvent(name: string, properties?, options?): void`
91
+
92
+ ```ts
93
+ captureEvent("checkout_completed", { value: 99.99, currency: "USD" });
94
+ ```
95
+
96
+ ### `captureError(error: unknown, options?): void`
97
+
98
+ ```ts
99
+ captureError(new Error("Payment failed"), {
100
+ context: { orderId: "123" },
101
+ });
102
+ ```
103
+
104
+ ### `identify(userId: string, traits?): void`
105
+
106
+ ### `identify(user: EmitVisionUser): void`
107
+
108
+ ```ts
109
+ identify("user_123", {
110
+ email: "user@example.com",
111
+ username: "jane",
112
+ });
113
+ ```
114
+
115
+ ### `setContext(context: Record<string, unknown>): void`
116
+
117
+ ```ts
118
+ setContext({ page: "/checkout", experiment: "variant_b" });
119
+ ```
120
+
121
+ ### `setDeploymentContext(context: { deploymentId?: string; buildId?: string }): void`
122
+
123
+ ```ts
124
+ setDeploymentContext({ deploymentId: "deploy_123", buildId: "build_456" });
125
+ ```
126
+
127
+ ### `setFeatureFlags(flags: Record<string, string | number | boolean | null>): void`
128
+
129
+ ```ts
130
+ setFeatureFlags({ newCheckout: true, pricingVariant: "control" });
131
+ ```
132
+
133
+ ### `setTags(tags: Record<string, string>): void`
134
+
135
+ ```ts
136
+ setTags({ team: "payments", feature: "checkout" });
137
+ ```
138
+
139
+ ### `flush(): Promise<void>`
140
+
141
+ ```ts
142
+ await flush();
143
+ ```
144
+
145
+ ## Feature Flag Evaluation
146
+
147
+ The SDK can fetch and evaluate feature flags from your Emit Vision project. Evaluated
148
+ variants are automatically merged into the telemetry context so that every subsequent
149
+ `captureEvent` or `captureError` call includes the active flag assignments.
150
+
151
+ ```ts
152
+ import {
153
+ init,
154
+ evaluateFlags,
155
+ getFlag,
156
+ refreshFlags,
157
+ } from "@emit-vision/sdk-js";
158
+
159
+ init({
160
+ dsn: "http://evk_local_development_seed_key_000000000000@localhost:4301/v1",
161
+ environment: "production",
162
+ });
163
+
164
+ // Fetch all active flags for a user. Pass evaluationKey explicitly —
165
+ // the SDK never reads email or username automatically.
166
+ const flags = await evaluateFlags({ evaluationKey: "user_abc123" });
167
+ // flags → { "new-checkout": true, "pricing-variant": "control", ... }
168
+
169
+ // Fetch a single flag with a typed fallback.
170
+ const showNewCheckout = await getFlag("new-checkout", false, {
171
+ evaluationKey: "user_abc123",
172
+ });
173
+
174
+ // Force a fresh fetch (bypass cache) after a flag change.
175
+ await refreshFlags({ evaluationKey: "user_abc123" });
176
+ ```
177
+
178
+ ### `evaluateFlags(options: FlagEvaluationOptions): Promise<FeatureFlagsContext>`
179
+
180
+ Fetches all active flags for the given evaluation key. Results are cached in memory
181
+ for `flagEvalTtlMs` milliseconds (default: `60 000`). Network failures return an empty
182
+ object without throwing.
183
+
184
+ ### `getFlag<T>(flagKey, fallback, options): Promise<T>`
185
+
186
+ Returns the value for a single flag. Returns `fallback` when the flag is absent or when
187
+ the network is unavailable. Never throws.
188
+
189
+ ### `refreshFlags(options: FlagEvaluationOptions): Promise<FeatureFlagsContext>`
190
+
191
+ Bypasses the in-memory cache and performs a fresh fetch. Useful immediately after a
192
+ flag change in the dashboard.
193
+
194
+ ### `FlagEvaluationOptions`
195
+
196
+ | Option | Type | Required | Description |
197
+ | --------------- | ---------- | -------- | --------------------------------------------------------------------------- |
198
+ | `evaluationKey` | `string` | Yes | Opaque key used to bucket the caller (e.g. user ID, device ID, session ID). |
199
+ | `environment` | `string` | No | Overrides the environment set at `init` time for this request. |
200
+ | `ttlMs` | `number` | No | Per-call TTL override in milliseconds. |
201
+ | `flagKeys` | `string[]` | No | Limit evaluation to specific flag keys. Omit to evaluate all active flags. |
202
+
203
+ Set the default cache TTL at init time with the `flagEvalTtlMs` option (default: `60 000`).
204
+
205
+ ```ts
206
+ init({
207
+ dsn: "...",
208
+ flagEvalTtlMs: 30_000, // refresh evaluations every 30 s
209
+ });
210
+ ```
211
+
212
+ ## Notes
213
+
214
+ - Manual `captureError()` calls mark errors as handled.
215
+ - Auto-captured `window.error` and `unhandledrejection` events are marked as unhandled.
216
+ - The queue is in-memory only, so call `flush()` before critical navigations if you need immediate delivery.
217
+ - Flag evaluations are cached in memory only — nothing is written to `localStorage`.
218
+ - Do not use `email` or `username` as the `evaluationKey` — use an opaque user ID or session ID.
219
+ - Use the helper packages when they reduce boilerplate, but keep direct browser
220
+ SDK usage for the simplest client-side apps.
@@ -0,0 +1,129 @@
1
+ export type EmitVisionUser = {
2
+ id?: string;
3
+ email?: string;
4
+ username?: string;
5
+ };
6
+ export type EmitVisionTraits = Omit<EmitVisionUser, "id">;
7
+ export type DeploymentContext = {
8
+ deploymentId?: string;
9
+ buildId?: string;
10
+ };
11
+ export type FeatureFlagsContext = Record<string, string | number | boolean | null>;
12
+ export type FlagEvaluationOptions = {
13
+ /** Opaque key used to bucket the caller. Must be provided explicitly — the SDK never reads email or username automatically. */
14
+ evaluationKey: string;
15
+ /** Overrides the environment set at init time for this evaluation request. */
16
+ environment?: string;
17
+ /** How long (ms) to cache this result. Defaults to the `flagEvalTtlMs` option set at init (default: 60 000). */
18
+ ttlMs?: number;
19
+ /** Limit evaluation to specific flag keys. Omit to evaluate all active flags. */
20
+ flagKeys?: string[];
21
+ };
22
+ export type AutoCaptureOptions = {
23
+ errors?: boolean;
24
+ unhandledRejections?: boolean;
25
+ /** Automatically fire a `$flag_exposure` event for each flag after evaluation. Default: true. */
26
+ flagExposures?: boolean;
27
+ };
28
+ export type CaptureOptions = {
29
+ eventId?: string;
30
+ timestamp?: string;
31
+ environment?: string;
32
+ release?: string;
33
+ sessionId?: string;
34
+ user?: EmitVisionUser;
35
+ context?: Record<string, unknown>;
36
+ deployment?: DeploymentContext;
37
+ featureFlags?: FeatureFlagsContext;
38
+ tags?: Record<string, string>;
39
+ };
40
+ export type EmitVisionOptions = {
41
+ apiKey?: string;
42
+ dsn?: string;
43
+ endpoint?: string;
44
+ environment?: string;
45
+ release?: string;
46
+ sessionId?: string;
47
+ label?: string;
48
+ deployment?: DeploymentContext;
49
+ featureFlags?: FeatureFlagsContext;
50
+ autoCapture?: AutoCaptureOptions;
51
+ autoCaptureErrors?: boolean;
52
+ debug?: boolean;
53
+ flushOnCapture?: boolean;
54
+ flushIntervalMs?: number;
55
+ batchSize?: number;
56
+ fetchImpl?: typeof fetch;
57
+ /** TTL in milliseconds for in-memory flag evaluation cache. Default: 60 000 (1 minute). */
58
+ flagEvalTtlMs?: number;
59
+ };
60
+ type RequiredEmitVisionOptions = Required<Pick<EmitVisionOptions, "apiKey" | "endpoint" | "flushIntervalMs" | "batchSize" | "fetchImpl" | "flushOnCapture" | "flagEvalTtlMs">> & Pick<EmitVisionOptions, "environment" | "release" | "sessionId" | "label" | "deployment" | "featureFlags" | "debug"> & {
61
+ autoCapture: Required<AutoCaptureOptions> & {
62
+ flagExposures: boolean;
63
+ };
64
+ };
65
+ declare class EmitVisionClient {
66
+ private readonly options;
67
+ private queue;
68
+ private user;
69
+ private context;
70
+ private deployment;
71
+ private featureFlags;
72
+ private tags;
73
+ private timer;
74
+ private flushScheduled;
75
+ private readonly fetchImpl;
76
+ private readonly flagCache;
77
+ constructor(options: RequiredEmitVisionOptions);
78
+ captureEvent(name: string, properties?: Record<string, unknown>, options?: CaptureOptions): void;
79
+ captureError(error: unknown, options?: CaptureOptions): void;
80
+ identify(userIdOrUser: string | EmitVisionUser, traits?: EmitVisionTraits): void;
81
+ setContext(context: Record<string, unknown>): void;
82
+ setDeploymentContext(context: DeploymentContext): void;
83
+ setFeatureFlags(featureFlags: FeatureFlagsContext): void;
84
+ /**
85
+ * Fetch and evaluate all active flags for the given evaluation key.
86
+ * Results are cached for `ttlMs` (defaults to `flagEvalTtlMs` from init options).
87
+ * On network failure returns an empty object and does not throw.
88
+ * Evaluated variants are automatically merged into the SDK's feature-flag context.
89
+ */
90
+ evaluateFlags(opts: FlagEvaluationOptions): Promise<FeatureFlagsContext>;
91
+ /**
92
+ * Return a single flag's value, evaluated for the given key.
93
+ * Returns `fallback` on network failure or when the flag is not found — never throws.
94
+ */
95
+ getFlag<T extends string | number | boolean | null>(flagKey: string, fallback: T, opts: FlagEvaluationOptions): Promise<T>;
96
+ /**
97
+ * Bypass the cache and re-fetch evaluations for the given key.
98
+ * Useful after a flag change in the dashboard that should apply immediately.
99
+ */
100
+ refreshFlags(opts: FlagEvaluationOptions): Promise<FeatureFlagsContext>;
101
+ captureExposure(flagKey: string, variantKey: string, variantValue?: string | number | boolean | null, reason?: string, environment?: string): void;
102
+ private fetchAndCacheFlags;
103
+ setTags(tags: Record<string, string>): void;
104
+ flush(): Promise<void>;
105
+ close(): void;
106
+ private enqueue;
107
+ private scheduleFlush;
108
+ private withDefaults;
109
+ private mergeDeployment;
110
+ private mergeFeatureFlags;
111
+ private readonly handleWindowError;
112
+ private readonly handleUnhandledRejection;
113
+ private debug;
114
+ }
115
+ export declare function init(options: EmitVisionOptions): EmitVisionClient;
116
+ export declare function captureEvent(name: string, properties?: Record<string, unknown>, options?: CaptureOptions): void;
117
+ export declare function captureError(error: unknown, options?: CaptureOptions): void;
118
+ export declare function identify(user: EmitVisionUser): void;
119
+ export declare function identify(userId: string, traits?: EmitVisionTraits): void;
120
+ export declare function setContext(context: Record<string, unknown>): void;
121
+ export declare function setDeploymentContext(context: DeploymentContext): void;
122
+ export declare function setFeatureFlags(featureFlags: FeatureFlagsContext): void;
123
+ export declare function setTags(tags: Record<string, string>): void;
124
+ export declare function flush(): Promise<void>;
125
+ export declare function evaluateFlags(options: FlagEvaluationOptions): Promise<FeatureFlagsContext>;
126
+ export declare function getFlag<T extends string | number | boolean | null>(flagKey: string, fallback: T, options: FlagEvaluationOptions): Promise<T>;
127
+ export declare function refreshFlags(options: FlagEvaluationOptions): Promise<FeatureFlagsContext>;
128
+ export declare function captureExposure(flagKey: string, variantKey: string, variantValue?: string | number | boolean | null, reason?: string, environment?: string): void;
129
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,462 @@
1
+ const SDK_NAME = "emit-vision-js";
2
+ const SDK_VERSION = "0.1.0";
3
+ class EmitVisionClient {
4
+ options;
5
+ queue = [];
6
+ user;
7
+ context = {};
8
+ deployment;
9
+ featureFlags = {};
10
+ tags = {};
11
+ timer;
12
+ flushScheduled = false;
13
+ fetchImpl;
14
+ flagCache = new Map();
15
+ constructor(options) {
16
+ this.options = options;
17
+ this.fetchImpl = options.fetchImpl;
18
+ this.deployment = options.deployment
19
+ ? { ...options.deployment }
20
+ : undefined;
21
+ this.featureFlags = options.featureFlags ? { ...options.featureFlags } : {};
22
+ this.timer = setInterval(() => {
23
+ if (this.queue.length === 0) {
24
+ return;
25
+ }
26
+ this.flush().catch((err) => this.debug("background flush error", { error: err }));
27
+ }, options.flushIntervalMs);
28
+ if (options.autoCapture.errors && typeof window !== "undefined") {
29
+ window.addEventListener("error", this.handleWindowError);
30
+ }
31
+ if (options.autoCapture.unhandledRejections &&
32
+ typeof window !== "undefined") {
33
+ window.addEventListener("unhandledrejection", this.handleUnhandledRejection);
34
+ }
35
+ this.debug("initialized", {
36
+ endpoint: options.endpoint,
37
+ environment: options.environment,
38
+ release: options.release,
39
+ autoCapture: options.autoCapture,
40
+ batchSize: options.batchSize,
41
+ flushIntervalMs: options.flushIntervalMs,
42
+ flushOnCapture: options.flushOnCapture,
43
+ });
44
+ }
45
+ captureEvent(name, properties, options = {}) {
46
+ this.debug("queue event", { name, properties, options });
47
+ this.enqueue({
48
+ type: "event",
49
+ name,
50
+ properties,
51
+ ...this.withDefaults(options),
52
+ });
53
+ }
54
+ captureError(error, options = {}) {
55
+ const serialized = serializeError(error);
56
+ this.debug("queue error", {
57
+ message: serialized.message,
58
+ handled: serialized.handled,
59
+ options,
60
+ });
61
+ this.enqueue({
62
+ type: "error",
63
+ name: "error",
64
+ error: serialized,
65
+ ...this.withDefaults(options),
66
+ });
67
+ }
68
+ identify(userIdOrUser, traits = {}) {
69
+ this.user =
70
+ typeof userIdOrUser === "string"
71
+ ? { id: userIdOrUser, ...traits }
72
+ : { ...userIdOrUser };
73
+ this.debug("identified user", this.user);
74
+ }
75
+ setContext(context) {
76
+ this.context = { ...this.context, ...context };
77
+ this.debug("updated context", this.context);
78
+ }
79
+ setDeploymentContext(context) {
80
+ this.deployment =
81
+ Object.keys(context).length > 0 ? { ...context } : undefined;
82
+ this.debug("updated deployment context", this.deployment);
83
+ }
84
+ setFeatureFlags(featureFlags) {
85
+ this.featureFlags = { ...featureFlags };
86
+ this.debug("updated feature flags", this.featureFlags);
87
+ }
88
+ /**
89
+ * Fetch and evaluate all active flags for the given evaluation key.
90
+ * Results are cached for `ttlMs` (defaults to `flagEvalTtlMs` from init options).
91
+ * On network failure returns an empty object and does not throw.
92
+ * Evaluated variants are automatically merged into the SDK's feature-flag context.
93
+ */
94
+ async evaluateFlags(opts) {
95
+ const env = opts.environment ?? this.options.environment ?? "";
96
+ const ttl = opts.ttlMs ?? this.options.flagEvalTtlMs;
97
+ const key = `${env}:${opts.evaluationKey}`;
98
+ const cached = this.flagCache.get(key);
99
+ if (cached && Date.now() < cached.expiresAt) {
100
+ this.debug("flag eval cache hit", { key });
101
+ return cached.flags;
102
+ }
103
+ return this.fetchAndCacheFlags(opts, env, ttl, key);
104
+ }
105
+ /**
106
+ * Return a single flag's value, evaluated for the given key.
107
+ * Returns `fallback` on network failure or when the flag is not found — never throws.
108
+ */
109
+ async getFlag(flagKey, fallback, opts) {
110
+ try {
111
+ const flags = await this.evaluateFlags(opts);
112
+ const value = flags[flagKey];
113
+ return (value !== undefined ? value : fallback);
114
+ }
115
+ catch {
116
+ return fallback;
117
+ }
118
+ }
119
+ /**
120
+ * Bypass the cache and re-fetch evaluations for the given key.
121
+ * Useful after a flag change in the dashboard that should apply immediately.
122
+ */
123
+ async refreshFlags(opts) {
124
+ const env = opts.environment ?? this.options.environment ?? "";
125
+ const ttl = opts.ttlMs ?? this.options.flagEvalTtlMs;
126
+ const key = `${env}:${opts.evaluationKey}`;
127
+ this.flagCache.delete(key);
128
+ return this.fetchAndCacheFlags(opts, env, ttl, key);
129
+ }
130
+ captureExposure(flagKey, variantKey, variantValue, reason, environment) {
131
+ this.captureEvent("$flag_exposure", {
132
+ flagKey,
133
+ variantKey,
134
+ ...(variantValue !== undefined ? { variantValue } : {}),
135
+ ...(reason ? { reason } : {}),
136
+ environment: environment ?? this.options.environment,
137
+ });
138
+ }
139
+ async fetchAndCacheFlags(opts, env, ttl, cacheKey) {
140
+ let flags = {};
141
+ let evaluations = {};
142
+ try {
143
+ const body = {
144
+ environment: env,
145
+ userKey: opts.evaluationKey,
146
+ };
147
+ if (opts.flagKeys && opts.flagKeys.length > 0) {
148
+ body.flagKeys = opts.flagKeys;
149
+ }
150
+ const response = await this.fetchImpl(`${this.options.endpoint}/v1/flags/evaluate`, {
151
+ method: "POST",
152
+ headers: {
153
+ "content-type": "application/json",
154
+ "x-emit-api-key": this.options.apiKey,
155
+ },
156
+ body: JSON.stringify(body),
157
+ });
158
+ if (response.ok) {
159
+ const data = (await response.json());
160
+ evaluations = data.evaluations;
161
+ flags = Object.fromEntries(Object.entries(evaluations).map(([k, v]) => [k, v.variantValue]));
162
+ this.debug("flag eval complete", { count: Object.keys(flags).length });
163
+ }
164
+ else {
165
+ this.debug("flag eval response error", { status: response.status });
166
+ }
167
+ }
168
+ catch (err) {
169
+ this.debug("flag eval network error", { error: err });
170
+ // Return empty flags on network error — never throw to callers
171
+ }
172
+ this.flagCache.set(cacheKey, { flags, expiresAt: Date.now() + ttl });
173
+ // Merge evaluated variants into the existing feature-flag context so that
174
+ // subsequent captureEvent / captureError calls include them automatically.
175
+ this.featureFlags = { ...this.featureFlags, ...flags };
176
+ if (this.options.autoCapture.flagExposures) {
177
+ for (const [flagKey, entry] of Object.entries(evaluations)) {
178
+ this.captureExposure(flagKey, entry.variantKey, entry.variantValue, entry.reason, env);
179
+ }
180
+ }
181
+ return flags;
182
+ }
183
+ setTags(tags) {
184
+ this.tags = { ...this.tags, ...tags };
185
+ this.debug("updated tags", this.tags);
186
+ }
187
+ async flush() {
188
+ const batch = this.queue.splice(0, this.options.batchSize);
189
+ if (batch.length === 0) {
190
+ return;
191
+ }
192
+ this.debug("flushing batch", { count: batch.length });
193
+ let response;
194
+ try {
195
+ response = await this.fetchImpl(`${this.options.endpoint}/v1/batch`, {
196
+ method: "POST",
197
+ headers: {
198
+ "content-type": "application/json",
199
+ "x-emit-api-key": this.options.apiKey,
200
+ },
201
+ body: JSON.stringify({
202
+ events: batch.map((item) => ({
203
+ ...item,
204
+ sdk: { name: SDK_NAME, version: SDK_VERSION },
205
+ })),
206
+ }),
207
+ keepalive: true,
208
+ });
209
+ }
210
+ catch (err) {
211
+ this.queue.unshift(...batch);
212
+ this.debug("flush network error", {
213
+ error: err,
214
+ restoredCount: batch.length,
215
+ });
216
+ throw err;
217
+ }
218
+ if (!response.ok) {
219
+ this.queue.unshift(...batch);
220
+ this.debug("flush failed", {
221
+ status: response.status,
222
+ restoredCount: batch.length,
223
+ });
224
+ throw new Error(`emit-vision flush failed with ${response.status}`);
225
+ }
226
+ this.debug("flush complete", { count: batch.length });
227
+ if (this.options.flushOnCapture && this.queue.length > 0) {
228
+ this.scheduleFlush();
229
+ }
230
+ }
231
+ close() {
232
+ if (this.timer) {
233
+ clearInterval(this.timer);
234
+ this.timer = undefined;
235
+ }
236
+ if (typeof window !== "undefined" && this.options.autoCapture.errors) {
237
+ window.removeEventListener("error", this.handleWindowError);
238
+ }
239
+ if (typeof window !== "undefined" &&
240
+ this.options.autoCapture.unhandledRejections) {
241
+ window.removeEventListener("unhandledrejection", this.handleUnhandledRejection);
242
+ }
243
+ this.debug("closed client");
244
+ }
245
+ enqueue(payload) {
246
+ this.queue.push(payload);
247
+ this.debug("queued payload", {
248
+ type: payload.type,
249
+ name: payload.name,
250
+ queueSize: this.queue.length,
251
+ flushOnCapture: this.options.flushOnCapture,
252
+ batchSize: this.options.batchSize,
253
+ });
254
+ if (this.options.flushOnCapture) {
255
+ this.debug("capture flush scheduled", {
256
+ queueSize: this.queue.length,
257
+ });
258
+ this.flush().catch((err) => this.debug("capture flush error", { error: err }));
259
+ return;
260
+ }
261
+ if (this.queue.length >= this.options.batchSize) {
262
+ this.flush().catch((err) => this.debug("batch flush error", { error: err }));
263
+ }
264
+ }
265
+ scheduleFlush() {
266
+ if (this.flushScheduled) {
267
+ return;
268
+ }
269
+ this.flushScheduled = true;
270
+ queueMicrotask(() => {
271
+ this.flushScheduled = false;
272
+ if (this.queue.length === 0) {
273
+ return;
274
+ }
275
+ this.flush().catch((err) => this.debug("scheduled flush error", { error: err }));
276
+ });
277
+ }
278
+ withDefaults(options) {
279
+ const { context, deployment, featureFlags, tags, user, environment, release, sessionId, ...rest } = options;
280
+ return {
281
+ ...rest,
282
+ environment: environment ?? this.options.environment,
283
+ release: release ?? this.options.release,
284
+ sessionId: sessionId ?? this.options.sessionId,
285
+ user: user ?? this.user,
286
+ context: Object.keys(this.context).length
287
+ ? { ...this.context, ...context }
288
+ : context,
289
+ deployment: this.mergeDeployment(deployment),
290
+ featureFlags: this.mergeFeatureFlags(featureFlags),
291
+ tags: Object.keys(this.tags).length ? { ...this.tags, ...tags } : tags,
292
+ };
293
+ }
294
+ mergeDeployment(deployment) {
295
+ const merged = {
296
+ ...(this.deployment ?? {}),
297
+ ...(deployment ?? {}),
298
+ };
299
+ return Object.keys(merged).length > 0 ? merged : undefined;
300
+ }
301
+ mergeFeatureFlags(featureFlags) {
302
+ const merged = {
303
+ ...this.featureFlags,
304
+ ...(featureFlags ?? {}),
305
+ };
306
+ return Object.keys(merged).length > 0 ? merged : undefined;
307
+ }
308
+ handleWindowError = (event) => {
309
+ const serialized = serializeError(event.error ?? event.message);
310
+ this.debug("auto-captured window error", {
311
+ message: serialized.message,
312
+ });
313
+ this.enqueue({
314
+ type: "error",
315
+ name: "error",
316
+ error: { ...serialized, handled: false },
317
+ ...this.withDefaults({ context: { source: "window.error" } }),
318
+ });
319
+ };
320
+ handleUnhandledRejection = (event) => {
321
+ const serialized = serializeError(event.reason);
322
+ this.debug("auto-captured rejection", {
323
+ message: serialized.message,
324
+ });
325
+ this.enqueue({
326
+ type: "error",
327
+ name: "error",
328
+ error: { ...serialized, handled: false },
329
+ ...this.withDefaults({
330
+ context: { source: "window.unhandledrejection" },
331
+ }),
332
+ });
333
+ };
334
+ debug(message, payload) {
335
+ if (!this.options.debug || typeof console.debug !== "function") {
336
+ return;
337
+ }
338
+ const prefix = this.options.label
339
+ ? `[emit-vision:${this.options.label}]`
340
+ : "[emit-vision]";
341
+ if (payload === undefined) {
342
+ console.debug(prefix, message);
343
+ return;
344
+ }
345
+ console.debug(prefix, message, payload);
346
+ }
347
+ }
348
+ let client;
349
+ export function init(options) {
350
+ const transport = resolveTransport(options);
351
+ const autoCapture = resolveAutoCapture(options);
352
+ client?.close();
353
+ client = new EmitVisionClient({
354
+ ...options,
355
+ flushIntervalMs: options.flushIntervalMs ?? 5000,
356
+ batchSize: options.batchSize ?? 20,
357
+ debug: options.debug ?? false,
358
+ fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
359
+ flushOnCapture: options.flushOnCapture ?? false,
360
+ flagEvalTtlMs: options.flagEvalTtlMs ?? 60_000,
361
+ apiKey: transport.apiKey,
362
+ endpoint: transport.endpoint ?? options.endpoint ?? "http://localhost:4301",
363
+ autoCapture,
364
+ });
365
+ return client;
366
+ }
367
+ export function captureEvent(name, properties, options) {
368
+ requireClient().captureEvent(name, properties, options);
369
+ }
370
+ export function captureError(error, options) {
371
+ requireClient().captureError(error, options);
372
+ }
373
+ export function identify(userIdOrUser, traits) {
374
+ requireClient().identify(userIdOrUser, traits);
375
+ }
376
+ export function setContext(context) {
377
+ requireClient().setContext(context);
378
+ }
379
+ export function setDeploymentContext(context) {
380
+ requireClient().setDeploymentContext(context);
381
+ }
382
+ export function setFeatureFlags(featureFlags) {
383
+ requireClient().setFeatureFlags(featureFlags);
384
+ }
385
+ export function setTags(tags) {
386
+ requireClient().setTags(tags);
387
+ }
388
+ export async function flush() {
389
+ await requireClient().flush();
390
+ }
391
+ export async function evaluateFlags(options) {
392
+ return requireClient().evaluateFlags(options);
393
+ }
394
+ export async function getFlag(flagKey, fallback, options) {
395
+ return requireClient().getFlag(flagKey, fallback, options);
396
+ }
397
+ export async function refreshFlags(options) {
398
+ return requireClient().refreshFlags(options);
399
+ }
400
+ export function captureExposure(flagKey, variantKey, variantValue, reason, environment) {
401
+ requireClient().captureExposure(flagKey, variantKey, variantValue, reason, environment);
402
+ }
403
+ function requireClient() {
404
+ if (!client) {
405
+ throw new Error("emit-vision SDK has not been initialized");
406
+ }
407
+ return client;
408
+ }
409
+ function resolveTransport(options) {
410
+ const dsn = options.dsn ? parseDsn(options.dsn) : null;
411
+ const apiKey = options.apiKey ?? dsn?.apiKey;
412
+ if (!apiKey) {
413
+ throw new Error("emit-vision init requires either apiKey or dsn");
414
+ }
415
+ return {
416
+ apiKey,
417
+ endpoint: stripTrailingSlash(options.endpoint ?? dsn?.endpoint),
418
+ };
419
+ }
420
+ function resolveAutoCapture(options) {
421
+ const legacyDefault = options.autoCaptureErrors === undefined ? true : options.autoCaptureErrors;
422
+ return {
423
+ errors: options.autoCapture?.errors ?? legacyDefault,
424
+ unhandledRejections: options.autoCapture?.unhandledRejections ?? legacyDefault,
425
+ flagExposures: options.autoCapture?.flagExposures ?? true,
426
+ };
427
+ }
428
+ function parseDsn(dsn) {
429
+ const parsed = new URL(dsn);
430
+ const apiKey = decodeURIComponent(parsed.username);
431
+ if (!apiKey) {
432
+ throw new Error("emit-vision dsn must include the API key before the @");
433
+ }
434
+ const pathname = stripTrailingSlash(parsed.pathname) ?? "";
435
+ const endpointPath = pathname.endsWith("/v1")
436
+ ? pathname.slice(0, -"/v1".length)
437
+ : pathname;
438
+ return {
439
+ apiKey,
440
+ endpoint: stripTrailingSlash(`${parsed.origin}${endpointPath}`),
441
+ };
442
+ }
443
+ function stripTrailingSlash(value) {
444
+ if (!value) {
445
+ return value;
446
+ }
447
+ return value.endsWith("/") ? value.slice(0, -1) : value;
448
+ }
449
+ function serializeError(error) {
450
+ if (error instanceof Error) {
451
+ return {
452
+ message: error.message,
453
+ name: error.name,
454
+ stack: error.stack,
455
+ handled: true,
456
+ };
457
+ }
458
+ return {
459
+ message: typeof error === "string" ? error : JSON.stringify(error),
460
+ handled: true,
461
+ };
462
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@emit-vision/sdk-js",
3
+ "version": "0.1.0",
4
+ "description": "Browser SDK for self-hosted emit-vision analytics and error tracking.",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "author": "Emit Vision",
8
+ "homepage": "https://github.com/develemit/emit-vision/tree/main/packages/sdk-js#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/develemit/emit-vision.git",
12
+ "directory": "packages/sdk-js"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/develemit/emit-vision/issues"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "scripts": {
37
+ "build": "rm -rf dist && tsc -p tsconfig.build.json"
38
+ }
39
+ }