@sessionsight/insights 1.0.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.
@@ -0,0 +1,241 @@
1
+ import type { IngestPayload, PrivacyConfig } from './types.js';
2
+
3
+ const MAX_KEEPALIVE_BYTES = 60_000;
4
+
5
+ /**
6
+ * WebSocket-first transport with HTTP fallback.
7
+ *
8
+ * On init, opens a persistent WebSocket to /ws/ingest. While the socket is
9
+ * open, payloads are sent as WS messages (zero network tab noise). If the
10
+ * socket isn't ready yet, drops, or fails to connect, falls back to HTTP
11
+ * fetch. sendBeacon (page unload) always uses HTTP since the WS may be
12
+ * closing.
13
+ */
14
+ export class Transport {
15
+ private apiUrl: string;
16
+ private publicApiKey: string;
17
+ private propertyId: string;
18
+ private killed = false;
19
+
20
+ // WebSocket state
21
+ private ws: WebSocket | null = null;
22
+ private wsReady = false;
23
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
24
+ private reconnectDelay = 1_000;
25
+ private static readonly MAX_RECONNECT_DELAY = 30_000;
26
+ private closed = false;
27
+
28
+ // Privacy config callback
29
+ private onPrivacyConfig: ((config: PrivacyConfig) => void) | null = null;
30
+
31
+ constructor(apiUrl: string, publicApiKey: string, propertyId: string) {
32
+ this.apiUrl = apiUrl;
33
+ this.publicApiKey = publicApiKey;
34
+ this.propertyId = propertyId;
35
+ this.connectWs();
36
+ }
37
+
38
+ /** Register a callback for when the server sends privacy configuration. */
39
+ onPrivacy(callback: (config: PrivacyConfig) => void): void {
40
+ this.onPrivacyConfig = callback;
41
+ }
42
+
43
+ isKilled(): boolean {
44
+ return this.killed;
45
+ }
46
+
47
+ // ── WebSocket lifecycle ────────────────────────────────────────────
48
+
49
+ private connectWs(): void {
50
+ if (this.killed || this.closed) return;
51
+
52
+ try {
53
+ const wsUrl = this.apiUrl
54
+ .replace(/^http/, 'ws')
55
+ .replace(/\/$/, '');
56
+ this.ws = new WebSocket(`${wsUrl}/ws/ingest?key=${encodeURIComponent(this.publicApiKey)}&propertyId=${encodeURIComponent(this.propertyId)}`);
57
+
58
+ this.ws.onopen = () => {
59
+ // Wait for the 'ready' message before marking as ready
60
+ };
61
+
62
+ this.ws.onmessage = (event) => {
63
+ try {
64
+ const msg = JSON.parse(typeof event.data === 'string' ? event.data : '');
65
+ if (msg.type === 'ready') {
66
+ this.wsReady = true;
67
+ this.reconnectDelay = 1_000; // reset backoff on successful connection
68
+ if (msg.privacy && this.onPrivacyConfig) {
69
+ this.onPrivacyConfig(msg.privacy);
70
+ }
71
+ }
72
+ if (msg.type === 'error' && msg.code === 'QUOTA_EXCEEDED') {
73
+ // Server says quota exceeded; stop sending but don't kill (quota resets monthly)
74
+ }
75
+ } catch {
76
+ // ignore non-JSON messages
77
+ }
78
+ };
79
+
80
+ this.ws.onclose = (event) => {
81
+ this.wsReady = false;
82
+ this.ws = null;
83
+
84
+ // 4001 = invalid API key, same as HTTP 401/403
85
+ if (event.code === 4001) {
86
+ this.killed = true;
87
+ console.warn('SessionSight: invalid API key. Ingestion disabled.');
88
+ return;
89
+ }
90
+
91
+ // 4002 = subscription required
92
+ if (event.code === 4002) {
93
+ this.killed = true;
94
+ return;
95
+ }
96
+
97
+ this.scheduleReconnect();
98
+ };
99
+
100
+ this.ws.onerror = () => {
101
+ // onclose fires after onerror, so reconnect is handled there
102
+ };
103
+ } catch {
104
+ this.scheduleReconnect();
105
+ }
106
+ }
107
+
108
+ private scheduleReconnect(): void {
109
+ if (this.killed || this.closed || this.reconnectTimer) return;
110
+
111
+ this.reconnectTimer = setTimeout(() => {
112
+ this.reconnectTimer = null;
113
+ this.connectWs();
114
+ }, this.reconnectDelay);
115
+
116
+ // Exponential backoff with jitter
117
+ this.reconnectDelay = Math.min(
118
+ this.reconnectDelay * 2 + Math.random() * 500,
119
+ Transport.MAX_RECONNECT_DELAY,
120
+ );
121
+ }
122
+
123
+ // ── Public API ─────────────────────────────────────────────────────
124
+
125
+ async send(payload: IngestPayload): Promise<void> {
126
+ if (this.killed) return;
127
+
128
+ // Try WebSocket first
129
+ if (this.wsReady && this.ws?.readyState === WebSocket.OPEN) {
130
+ try {
131
+ this.ws.send(JSON.stringify(payload));
132
+ return;
133
+ } catch {
134
+ // WS send failed, fall through to HTTP
135
+ }
136
+ }
137
+
138
+ // HTTP fallback
139
+ const chunks = this.chunkEvents(payload);
140
+ for (const chunk of chunks) {
141
+ await this.sendHttpChunk(chunk);
142
+ if (this.killed) return;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Best-effort send for page unload. Always uses HTTP (sendBeacon or fetch)
148
+ * since the WebSocket may be in the process of closing.
149
+ */
150
+ sendBeacon(payload: IngestPayload): void {
151
+ if (this.killed) return;
152
+
153
+ const chunks = this.chunkEvents(payload);
154
+ for (const chunk of chunks) {
155
+ try {
156
+ const body = JSON.stringify({ ...chunk, apiKey: this.publicApiKey });
157
+ if (body.length > MAX_KEEPALIVE_BYTES) {
158
+ // sendBeacon also has size limits; fall back to fetch for oversized chunks
159
+ this.sendHttpChunk(chunk);
160
+ continue;
161
+ }
162
+ const blob = new Blob([body], { type: 'application/json' });
163
+ navigator.sendBeacon(`${this.apiUrl}/v1/ingest`, blob);
164
+ } catch {
165
+ // Silently fail
166
+ }
167
+ }
168
+ }
169
+
170
+ /** Tear down the WebSocket (called when the recorder stops). */
171
+ destroy(): void {
172
+ this.closed = true;
173
+ if (this.reconnectTimer) {
174
+ clearTimeout(this.reconnectTimer);
175
+ this.reconnectTimer = null;
176
+ }
177
+ if (this.ws) {
178
+ try { this.ws.close(1000); } catch {}
179
+ this.ws = null;
180
+ }
181
+ this.wsReady = false;
182
+ }
183
+
184
+ // ── Internal helpers ───────────────────────────────────────────────
185
+
186
+ /**
187
+ * Split a payload into chunks that each fit under the keepalive size limit.
188
+ * Events that are individually larger than the limit get their own chunk.
189
+ */
190
+ private chunkEvents(payload: IngestPayload): IngestPayload[] {
191
+ const { events, ...rest } = payload;
192
+ if (events.length === 0) return [payload];
193
+
194
+ // Build the envelope once to know its overhead
195
+ const envelopeSize = JSON.stringify({ ...rest, events: [] }).length;
196
+
197
+ const chunks: IngestPayload[] = [];
198
+ let currentEvents: any[] = [];
199
+ let currentSize = envelopeSize;
200
+
201
+ for (const event of events) {
202
+ const eventSize = JSON.stringify(event).length + 1; // +1 for comma separator
203
+
204
+ if (currentEvents.length > 0 && currentSize + eventSize > MAX_KEEPALIVE_BYTES) {
205
+ chunks.push({ ...rest, events: currentEvents });
206
+ currentEvents = [];
207
+ currentSize = envelopeSize;
208
+ }
209
+
210
+ currentEvents.push(event);
211
+ currentSize += eventSize;
212
+ }
213
+
214
+ if (currentEvents.length > 0) {
215
+ chunks.push({ ...rest, events: currentEvents });
216
+ }
217
+
218
+ return chunks;
219
+ }
220
+
221
+ private async sendHttpChunk(payload: IngestPayload): Promise<void> {
222
+ try {
223
+ const body = JSON.stringify(payload);
224
+ const res = await fetch(`${this.apiUrl}/v1/ingest`, {
225
+ method: 'POST',
226
+ headers: {
227
+ 'Content-Type': 'application/json',
228
+ 'x-api-key': this.publicApiKey,
229
+ },
230
+ body,
231
+ keepalive: body.length < MAX_KEEPALIVE_BYTES,
232
+ });
233
+ if (res.status === 401 || res.status === 403) {
234
+ this.killed = true;
235
+ console.warn('SessionSight: invalid API key. Ingestion disabled.');
236
+ }
237
+ } catch {
238
+ // Silently fail — don't break the host page
239
+ }
240
+ }
241
+ }
package/src/types.ts ADDED
@@ -0,0 +1,56 @@
1
+ export interface SessionSightConfig {
2
+ publicApiKey: string;
3
+ propertyId?: string; // defaults to 'dev' (localhost)
4
+ apiUrl?: string; // defaults to https://api.sessionsight.com
5
+ autoRecord?: boolean; // default true — set to false for manual recording control
6
+ enabled?: boolean | (() => boolean); // default true — pass false to defer, or a getter function for reactive consent
7
+ }
8
+
9
+ export interface PrivacyConfig {
10
+ privacyMode: 'default' | 'relaxed';
11
+ excludePages: string[];
12
+ }
13
+
14
+ export interface RecordOptions {
15
+ preRecordSecs?: number; // 0-5, default 0 — include N seconds of pre-buffer
16
+ }
17
+
18
+ export interface SessionMetadata {
19
+ url: string;
20
+ referrer: string;
21
+ userAgent: string;
22
+ screenWidth: number;
23
+ screenHeight: number;
24
+ language: string;
25
+ }
26
+
27
+ export interface IngestPayload {
28
+ sessionId: string;
29
+ propertyId: string;
30
+ visitorId: string;
31
+ events: any[];
32
+ metadata?: SessionMetadata;
33
+ userId?: string | null;
34
+ userProperties?: Record<string, string | number | boolean>;
35
+ seqStart?: number;
36
+ seqEnd?: number;
37
+ final?: boolean;
38
+ }
39
+
40
+ // ── Worker message protocol ──────────────────────────────────────
41
+
42
+ /** Main thread → Worker */
43
+ export type WorkerInMessage =
44
+ | { type: 'init'; apiUrl: string; publicApiKey: string; propertyId: string; sessionId: string; visitorId: string }
45
+ | { type: 'event'; event: any; seq: number }
46
+ | { type: 'metadata'; metadata: SessionMetadata }
47
+ | { type: 'identify'; userId: string; userProperties?: Record<string, string | number | boolean> }
48
+ | { type: 'flush' }
49
+ | { type: 'flush-final' };
50
+
51
+ /** Worker → Main thread */
52
+ export type WorkerOutMessage =
53
+ | { type: 'ack'; seq: number }
54
+ | { type: 'privacy'; config: PrivacyConfig }
55
+ | { type: 'killed'; reason: string }
56
+ | { type: 'ready' };
@@ -0,0 +1,315 @@
1
+ /**
2
+ * WorkerBridge: main-thread coordinator for the Web Worker transport.
3
+ *
4
+ * Assigns sequence numbers to events, maintains a mirror buffer of unacked
5
+ * events for sendBeacon fallback, and relays worker messages (privacy config,
6
+ * kill signals) back to the recorder.
7
+ *
8
+ * Falls back to inline Transport when Workers are unavailable.
9
+ */
10
+
11
+ import { Transport } from './transport.js';
12
+ import { createInlineWorker } from './worker-inline.js';
13
+ import type { PrivacyConfig, SessionMetadata, WorkerOutMessage, IngestPayload } from './types.js';
14
+
15
+ const FLUSH_INTERVAL_MS = 6_000;
16
+ const FLUSH_EVENT_THRESHOLD = 50;
17
+ const MAX_KEEPALIVE_BYTES = 60_000;
18
+
19
+ export class WorkerBridge {
20
+ private worker: Worker | null = null;
21
+ private seq = 0;
22
+ private lastAckedSeq = 0;
23
+ private mirrorBuffer: Array<{ event: any; seq: number }> = [];
24
+ private _killed = false;
25
+
26
+ // Callbacks
27
+ private onPrivacyCallback: ((config: PrivacyConfig) => void) | null = null;
28
+ private onKilledCallback: (() => void) | null = null;
29
+
30
+ // Session context (needed for sendBeacon payloads)
31
+ private sessionId: string;
32
+ private propertyId: string;
33
+ private visitorId: string;
34
+ private apiUrl: string;
35
+ private publicApiKey: string;
36
+
37
+ // Metadata and identify state (for sendBeacon payloads)
38
+ private metadata: SessionMetadata | null = null;
39
+ private metadataSent = false;
40
+ private userId: string | null = null;
41
+ private userProperties: Record<string, string | number | boolean> | null = null;
42
+ private userPropertiesDirty = false;
43
+
44
+ // Fallback: inline transport + buffer when Workers unavailable
45
+ private fallbackTransport: Transport | null = null;
46
+ private fallbackBuffer: any[] = [];
47
+ private fallbackFlushTimer: ReturnType<typeof setInterval> | null = null;
48
+
49
+ constructor(
50
+ apiUrl: string,
51
+ publicApiKey: string,
52
+ propertyId: string,
53
+ sessionId: string,
54
+ visitorId: string,
55
+ ) {
56
+ this.apiUrl = apiUrl;
57
+ this.publicApiKey = publicApiKey;
58
+ this.propertyId = propertyId;
59
+ this.sessionId = sessionId;
60
+ this.visitorId = visitorId;
61
+
62
+ const worker = createInlineWorker();
63
+ if (worker) {
64
+ this.worker = worker;
65
+ this.worker.onmessage = this.handleWorkerMessage;
66
+ this.worker.onerror = (e) => {
67
+ console.warn('SessionSight: worker error, falling back to main thread', e);
68
+ this.switchToFallback();
69
+ };
70
+ this.worker.postMessage({
71
+ type: 'init',
72
+ apiUrl,
73
+ publicApiKey,
74
+ propertyId,
75
+ sessionId,
76
+ visitorId,
77
+ });
78
+ } else {
79
+ this.initFallback();
80
+ }
81
+ }
82
+
83
+ // ── Public API ─────────────────────────────────────────────────────
84
+
85
+ postEvent(event: any): void {
86
+ if (this._killed) return;
87
+
88
+ const seq = ++this.seq;
89
+
90
+ if (this.worker) {
91
+ this.mirrorBuffer.push({ event, seq });
92
+ try {
93
+ this.worker.postMessage({ type: 'event', event, seq });
94
+ } catch (e) {
95
+ console.warn('SessionSight: postMessage failed, falling back', e);
96
+ this.switchToFallback();
97
+ this.fallbackBuffer.push(event);
98
+ }
99
+ } else {
100
+ this.fallbackBuffer.push(event);
101
+ if (this.fallbackBuffer.length >= FLUSH_EVENT_THRESHOLD) {
102
+ this.fallbackFlush();
103
+ }
104
+ }
105
+ }
106
+
107
+ postMetadata(metadata: SessionMetadata): void {
108
+ this.metadata = metadata;
109
+ if (this.worker) {
110
+ try { this.worker.postMessage({ type: 'metadata', metadata }); } catch {}
111
+ }
112
+ }
113
+
114
+ postIdentify(userId: string, userProperties?: Record<string, string | number | boolean>): void {
115
+ this.userId = userId;
116
+ if (userProperties) {
117
+ this.userProperties = { ...(this.userProperties || {}), ...userProperties };
118
+ this.userPropertiesDirty = true;
119
+ }
120
+ if (this.worker) {
121
+ try { this.worker.postMessage({ type: 'identify', userId, userProperties }); } catch {}
122
+ }
123
+ }
124
+
125
+ /** Trigger an immediate flush (e.g. before page exclusion pause). */
126
+ flush(): void {
127
+ if (this.worker) {
128
+ try { this.worker.postMessage({ type: 'flush' }); } catch {}
129
+ } else {
130
+ this.fallbackFlush();
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Page unload handler. Sends flush-final to worker AND fires sendBeacon
136
+ * with unacked events from the mirror buffer as a safety net.
137
+ */
138
+ sendBeacon(): void {
139
+ // Tell the worker to flush and close
140
+ if (this.worker) {
141
+ try { this.worker.postMessage({ type: 'flush-final' }); } catch {}
142
+ }
143
+
144
+ // Main-thread sendBeacon with unacked events
145
+ const unacked = this.worker
146
+ ? this.mirrorBuffer.filter(e => e.seq > this.lastAckedSeq)
147
+ : this.fallbackBuffer.splice(0).map((event, i) => ({ event, seq: this.lastAckedSeq + i + 1 }));
148
+
149
+ if (unacked.length === 0) return;
150
+
151
+ const events = unacked.map(e => e.event);
152
+ const payload: IngestPayload = {
153
+ sessionId: this.sessionId,
154
+ propertyId: this.propertyId,
155
+ visitorId: this.visitorId,
156
+ events,
157
+ seqStart: unacked[0]!.seq,
158
+ seqEnd: unacked[unacked.length - 1]!.seq,
159
+ final: true,
160
+ };
161
+
162
+ if (this.userId) payload.userId = this.userId;
163
+ if (this.userPropertiesDirty && this.userProperties) {
164
+ payload.userProperties = { ...this.userProperties };
165
+ }
166
+ if (!this.metadataSent && this.metadata) {
167
+ payload.metadata = this.metadata;
168
+ this.metadataSent = true;
169
+ }
170
+
171
+ try {
172
+ const body = JSON.stringify({ ...payload, apiKey: this.publicApiKey });
173
+ if (body.length <= MAX_KEEPALIVE_BYTES) {
174
+ const blob = new Blob([body], { type: 'application/json' });
175
+ navigator.sendBeacon(`${this.apiUrl}/v1/ingest`, blob);
176
+ } else {
177
+ // Oversized: fire fetch with keepalive as best-effort
178
+ fetch(`${this.apiUrl}/v1/ingest`, {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json', 'x-api-key': this.publicApiKey },
181
+ body,
182
+ keepalive: true,
183
+ }).catch(() => {});
184
+ }
185
+ } catch {
186
+ // Silently fail
187
+ }
188
+ }
189
+
190
+ onPrivacy(callback: (config: PrivacyConfig) => void): void {
191
+ this.onPrivacyCallback = callback;
192
+ // Also register on fallback transport if active
193
+ if (this.fallbackTransport) {
194
+ this.fallbackTransport.onPrivacy(callback);
195
+ }
196
+ }
197
+
198
+ onKilled(callback: () => void): void {
199
+ this.onKilledCallback = callback;
200
+ }
201
+
202
+ isKilled(): boolean {
203
+ return this._killed;
204
+ }
205
+
206
+ destroy(): void {
207
+ if (this.worker) {
208
+ try { this.worker.terminate(); } catch {}
209
+ this.worker = null;
210
+ }
211
+ if (this.fallbackTransport) {
212
+ this.fallbackTransport.destroy();
213
+ this.fallbackTransport = null;
214
+ }
215
+ if (this.fallbackFlushTimer) {
216
+ clearInterval(this.fallbackFlushTimer);
217
+ this.fallbackFlushTimer = null;
218
+ }
219
+ this.mirrorBuffer = [];
220
+ this.fallbackBuffer = [];
221
+ }
222
+
223
+ // ── Worker message handling ────────────────────────────────────────
224
+
225
+ private handleWorkerMessage = (e: MessageEvent<WorkerOutMessage>): void => {
226
+ try {
227
+ const msg = e.data;
228
+ switch (msg.type) {
229
+ case 'ack':
230
+ this.lastAckedSeq = msg.seq;
231
+ // Prune mirror buffer: remove all entries up to acked seq
232
+ while (this.mirrorBuffer.length > 0 && this.mirrorBuffer[0]!.seq <= msg.seq) {
233
+ this.mirrorBuffer.shift();
234
+ }
235
+ break;
236
+
237
+ case 'privacy':
238
+ if (this.onPrivacyCallback) this.onPrivacyCallback(msg.config);
239
+ break;
240
+
241
+ case 'killed':
242
+ this._killed = true;
243
+ if (this.onKilledCallback) this.onKilledCallback();
244
+ break;
245
+
246
+ case 'ready':
247
+ // WebSocket connected in worker
248
+ break;
249
+ }
250
+ } catch (err) {
251
+ console.warn('SessionSight: error handling worker message', err);
252
+ }
253
+ };
254
+
255
+ // ── Fallback (inline transport) ────────────────────────────────────
256
+
257
+ private initFallback(): void {
258
+ this.fallbackTransport = new Transport(this.apiUrl, this.publicApiKey, this.propertyId);
259
+ if (this.onPrivacyCallback) {
260
+ this.fallbackTransport.onPrivacy(this.onPrivacyCallback);
261
+ }
262
+ this.fallbackFlushTimer = setInterval(() => this.fallbackFlush(), FLUSH_INTERVAL_MS);
263
+ }
264
+
265
+ /** Switch from worker mode to fallback after a worker error. */
266
+ private switchToFallback(): void {
267
+ if (this.fallbackTransport) return; // already switched
268
+
269
+ // Terminate broken worker
270
+ if (this.worker) {
271
+ try { this.worker.terminate(); } catch {}
272
+ this.worker = null;
273
+ }
274
+
275
+ this.initFallback();
276
+
277
+ // Re-send unacked events from mirror
278
+ for (const entry of this.mirrorBuffer) {
279
+ this.fallbackBuffer.push(entry.event);
280
+ }
281
+ this.mirrorBuffer = [];
282
+ }
283
+
284
+ private fallbackFlush(): void {
285
+ if (!this.fallbackTransport || this.fallbackBuffer.length === 0) return;
286
+
287
+ if (this.fallbackTransport.isKilled()) {
288
+ this._killed = true;
289
+ this.fallbackBuffer = [];
290
+ if (this.fallbackFlushTimer) { clearInterval(this.fallbackFlushTimer); this.fallbackFlushTimer = null; }
291
+ if (this.onKilledCallback) this.onKilledCallback();
292
+ return;
293
+ }
294
+
295
+ const events = this.fallbackBuffer.splice(0);
296
+ const payload: IngestPayload = {
297
+ sessionId: this.sessionId,
298
+ propertyId: this.propertyId,
299
+ visitorId: this.visitorId,
300
+ events,
301
+ };
302
+
303
+ if (this.userId) payload.userId = this.userId;
304
+ if (this.userPropertiesDirty && this.userProperties) {
305
+ payload.userProperties = { ...this.userProperties };
306
+ this.userPropertiesDirty = false;
307
+ }
308
+ if (!this.metadataSent && this.metadata) {
309
+ payload.metadata = this.metadata;
310
+ this.metadataSent = true;
311
+ }
312
+
313
+ this.fallbackTransport.send(payload);
314
+ }
315
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Creates a Web Worker from an inlined blob URL.
3
+ *
4
+ * The WORKER_SOURCE string is imported from _worker-bundle.ts, which is generated
5
+ * by running `bun run build:worker` (or the full `bun run build`).
6
+ * Run `bun run build:worker` once before starting the dev server.
7
+ */
8
+
9
+ import { WORKER_SOURCE } from './_worker-bundle.js';
10
+
11
+ export function createInlineWorker(): Worker | null {
12
+ try {
13
+ if (typeof Worker === 'undefined' || !WORKER_SOURCE) return null;
14
+ const blob = new Blob([WORKER_SOURCE], { type: 'application/javascript' });
15
+ const url = URL.createObjectURL(blob);
16
+ const worker = new Worker(url);
17
+ setTimeout(() => URL.revokeObjectURL(url), 0);
18
+ return worker;
19
+ } catch (e) {
20
+ console.warn('SessionSight: failed to create worker, using main-thread fallback', e);
21
+ return null;
22
+ }
23
+ }