@probat/react 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,27 @@
1
1
  /**
2
- * Event context helpers: distinct_id, session_id, page info.
3
- * All browser-safe — no-ops when window is unavailable.
2
+ * Event context helpers: identity/session/page metadata.
3
+ * Browser-safe — returns static values on the server.
4
4
  */
5
5
 
6
6
  const DISTINCT_ID_KEY = "probat:distinct_id";
7
7
  const SESSION_ID_KEY = "probat:session_id";
8
+ const SESSION_START_AT_KEY = "probat:session_start_at";
9
+ const SESSION_SEQUENCE_KEY = "probat:session_sequence";
10
+ const SESSION_LAST_ACTIVITY_AT_KEY = "probat:session_last_activity_at";
11
+
12
+ const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
8
13
 
9
14
  let cachedDistinctId: string | null = null;
10
15
  let cachedSessionId: string | null = null;
16
+ let cachedSessionStartAt: string | null = null;
17
+ let cachedSessionSequence = 0;
18
+ let cachedLastActivityAt = 0;
11
19
 
12
20
  function generateId(): string {
13
21
  // crypto.randomUUID where available, else fallback
14
22
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
15
23
  return crypto.randomUUID();
16
24
  }
17
- // fallback: random hex
18
25
  const bytes = new Uint8Array(16);
19
26
  if (typeof crypto !== "undefined" && crypto.getRandomValues) {
20
27
  crypto.getRandomValues(bytes);
@@ -24,6 +31,92 @@ function generateId(): string {
24
31
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
25
32
  }
26
33
 
34
+ function nowMs(): number {
35
+ return Date.now();
36
+ }
37
+
38
+ function startNewSession(tsMs: number): void {
39
+ if (typeof window === "undefined") return;
40
+ const sid = `sess_${generateId()}`;
41
+ const startedAt = new Date(tsMs).toISOString();
42
+ cachedSessionId = sid;
43
+ cachedSessionStartAt = startedAt;
44
+ cachedSessionSequence = 0;
45
+ cachedLastActivityAt = tsMs;
46
+ try {
47
+ sessionStorage.setItem(SESSION_ID_KEY, sid);
48
+ sessionStorage.setItem(SESSION_START_AT_KEY, startedAt);
49
+ sessionStorage.setItem(SESSION_SEQUENCE_KEY, "0");
50
+ sessionStorage.setItem(SESSION_LAST_ACTIVITY_AT_KEY, String(tsMs));
51
+ } catch {}
52
+ }
53
+
54
+ function hydrateSessionFromStorage(tsMs: number): void {
55
+ if (typeof window === "undefined") return;
56
+
57
+ if (cachedSessionId && cachedSessionStartAt) {
58
+ const expiredInMemory =
59
+ !Number.isFinite(cachedLastActivityAt) ||
60
+ cachedLastActivityAt <= 0 ||
61
+ tsMs - cachedLastActivityAt > SESSION_IDLE_TIMEOUT_MS;
62
+ if (expiredInMemory) {
63
+ startNewSession(tsMs);
64
+ }
65
+ return;
66
+ }
67
+
68
+ try {
69
+ const sid = sessionStorage.getItem(SESSION_ID_KEY);
70
+ const startedAt = sessionStorage.getItem(SESSION_START_AT_KEY);
71
+ const sequenceRaw = sessionStorage.getItem(SESSION_SEQUENCE_KEY);
72
+ const lastActivityRaw = sessionStorage.getItem(SESSION_LAST_ACTIVITY_AT_KEY);
73
+ const sequence = Number(sequenceRaw || "0");
74
+ const lastActivity = Number(lastActivityRaw || "0");
75
+
76
+ if (!sid || !startedAt) {
77
+ startNewSession(tsMs);
78
+ return;
79
+ }
80
+
81
+ const expired =
82
+ !Number.isFinite(lastActivity) ||
83
+ lastActivity <= 0 ||
84
+ tsMs - lastActivity > SESSION_IDLE_TIMEOUT_MS;
85
+
86
+ if (expired) {
87
+ startNewSession(tsMs);
88
+ return;
89
+ }
90
+
91
+ cachedSessionId = sid;
92
+ cachedSessionStartAt = startedAt;
93
+ cachedSessionSequence = Number.isFinite(sequence) && sequence >= 0 ? sequence : 0;
94
+ cachedLastActivityAt = lastActivity > 0 ? lastActivity : tsMs;
95
+ } catch {
96
+ startNewSession(tsMs);
97
+ }
98
+ }
99
+
100
+ function ensureSession(tsMs: number): void {
101
+ if (typeof window === "undefined") return;
102
+ hydrateSessionFromStorage(tsMs);
103
+ if (!cachedSessionId || !cachedSessionStartAt) {
104
+ startNewSession(tsMs);
105
+ }
106
+ }
107
+
108
+ function bumpSequence(tsMs: number): number {
109
+ if (typeof window === "undefined") return 0;
110
+ ensureSession(tsMs);
111
+ cachedSessionSequence += 1;
112
+ cachedLastActivityAt = tsMs;
113
+ try {
114
+ sessionStorage.setItem(SESSION_SEQUENCE_KEY, String(cachedSessionSequence));
115
+ sessionStorage.setItem(SESSION_LAST_ACTIVITY_AT_KEY, String(tsMs));
116
+ } catch {}
117
+ return cachedSessionSequence;
118
+ }
119
+
27
120
  export function getDistinctId(): string {
28
121
  if (cachedDistinctId) return cachedDistinctId;
29
122
  if (typeof window === "undefined") return "server";
@@ -43,21 +136,15 @@ export function getDistinctId(): string {
43
136
  }
44
137
 
45
138
  export function getSessionId(): string {
46
- if (cachedSessionId) return cachedSessionId;
47
139
  if (typeof window === "undefined") return "server";
48
- try {
49
- const stored = sessionStorage.getItem(SESSION_ID_KEY);
50
- if (stored) {
51
- cachedSessionId = stored;
52
- return stored;
53
- }
54
- } catch {}
55
- const id = `sess_${generateId()}`;
56
- cachedSessionId = id;
57
- try {
58
- sessionStorage.setItem(SESSION_ID_KEY, id);
59
- } catch {}
60
- return id;
140
+ ensureSession(nowMs());
141
+ return cachedSessionId || "server";
142
+ }
143
+
144
+ export function getSessionStartAt(): string {
145
+ if (typeof window === "undefined") return new Date(0).toISOString();
146
+ ensureSession(nowMs());
147
+ return cachedSessionStartAt || new Date(0).toISOString();
61
148
  }
62
149
 
63
150
  export function getPageKey(): string {
@@ -81,14 +168,27 @@ export interface EventContext {
81
168
  $page_url: string;
82
169
  $pathname: string;
83
170
  $referrer: string;
171
+ $session_sequence: number;
172
+ $session_start_at: string;
84
173
  }
85
174
 
86
175
  export function buildEventContext(): EventContext {
176
+ const ts = nowMs();
87
177
  return {
88
178
  distinct_id: getDistinctId(),
89
179
  session_id: getSessionId(),
90
180
  $page_url: getPageUrl(),
91
181
  $pathname: typeof window !== "undefined" ? window.location.pathname : "",
92
182
  $referrer: getReferrer(),
183
+ $session_sequence: bumpSequence(ts),
184
+ $session_start_at: getSessionStartAt(),
93
185
  };
94
186
  }
187
+
188
+ export function resetEventContextStateForTests(): void {
189
+ cachedDistinctId = null;
190
+ cachedSessionId = null;
191
+ cachedSessionStartAt = null;
192
+ cachedSessionSequence = 0;
193
+ cachedLastActivityAt = 0;
194
+ }
@@ -0,0 +1,157 @@
1
+ import type { StructuredEvent } from "../types/events";
2
+
3
+ interface BatchBody {
4
+ events: StructuredEvent[];
5
+ }
6
+
7
+ export class EventQueue {
8
+ private queue: StructuredEvent[] = [];
9
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
10
+ private readonly maxBatchSize = 20;
11
+ private readonly flushIntervalMs = 5000;
12
+ private readonly endpointBatch: string;
13
+ private readonly endpointSingle: string;
14
+ private readonly apiKey?: string;
15
+
16
+ constructor(host: string, apiKey?: string) {
17
+ const normalized = host.replace(/\/$/, "");
18
+ this.endpointBatch = `${normalized}/experiment/metrics/batch`;
19
+ this.endpointSingle = `${normalized}/experiment/metrics`;
20
+ this.apiKey = apiKey;
21
+ }
22
+
23
+ enqueue(event: StructuredEvent): void {
24
+ this.queue.push(event);
25
+ if (this.queue.length >= this.maxBatchSize) {
26
+ this.flush();
27
+ return;
28
+ }
29
+ if (!this.flushTimer) {
30
+ this.flushTimer = setTimeout(() => {
31
+ this.flush();
32
+ }, this.flushIntervalMs);
33
+ }
34
+ }
35
+
36
+ flush(forceBeacon = false): void {
37
+ if (this.flushTimer) {
38
+ clearTimeout(this.flushTimer);
39
+ this.flushTimer = null;
40
+ }
41
+ if (this.queue.length === 0) return;
42
+
43
+ const batch = this.queue.splice(0, this.maxBatchSize);
44
+ this.send(batch, forceBeacon);
45
+
46
+ if (this.queue.length > 0) {
47
+ this.flushTimer = setTimeout(() => {
48
+ this.flush();
49
+ }, this.flushIntervalMs);
50
+ }
51
+ }
52
+
53
+ private send(batch: StructuredEvent[], forceBeacon = false): void {
54
+ const body: BatchBody = { events: batch };
55
+ const encodedBody = JSON.stringify(body);
56
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
57
+ if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
58
+ const canBeacon =
59
+ typeof navigator !== "undefined" &&
60
+ typeof navigator.sendBeacon === "function" &&
61
+ !this.apiKey &&
62
+ (
63
+ forceBeacon ||
64
+ (typeof document !== "undefined" && document.visibilityState === "hidden")
65
+ );
66
+
67
+ if (canBeacon) {
68
+ navigator.sendBeacon(this.endpointBatch, encodedBody);
69
+ return;
70
+ }
71
+
72
+ try {
73
+ if (batch.length === 1) {
74
+ fetch(this.endpointSingle, {
75
+ method: "POST",
76
+ headers,
77
+ credentials: "include",
78
+ body: JSON.stringify(batch[0]),
79
+ keepalive: true,
80
+ }).catch(() => {});
81
+ return;
82
+ }
83
+ fetch(this.endpointBatch, {
84
+ method: "POST",
85
+ headers,
86
+ credentials: "include",
87
+ body: encodedBody,
88
+ keepalive: true,
89
+ }).catch(() => {});
90
+ } catch {
91
+ // Deliberately drop failed batches (current behavior parity).
92
+ }
93
+ }
94
+ }
95
+
96
+ const queuesByHost = new Map<string, EventQueue>();
97
+ let lifecycleListenersInstalled = false;
98
+
99
+ function installLifecycleListeners(): void {
100
+ if (lifecycleListenersInstalled || typeof window === "undefined") return;
101
+ lifecycleListenersInstalled = true;
102
+
103
+ const flushWithBeacon = () => {
104
+ for (const queue of queuesByHost.values()) {
105
+ queue.flush(true);
106
+ }
107
+ };
108
+
109
+ const flushNormally = () => {
110
+ for (const queue of queuesByHost.values()) {
111
+ queue.flush(false);
112
+ }
113
+ };
114
+
115
+ window.addEventListener("pagehide", flushWithBeacon);
116
+ window.addEventListener("beforeunload", flushWithBeacon);
117
+ document.addEventListener("visibilitychange", () => {
118
+ if (document.visibilityState === "hidden") {
119
+ flushWithBeacon();
120
+ } else {
121
+ flushNormally();
122
+ }
123
+ });
124
+ }
125
+
126
+ function queueKey(host: string, apiKey?: string): string {
127
+ const normalized = host.replace(/\/$/, "");
128
+ return apiKey ? `${normalized}::${apiKey}` : `${normalized}::__no_key__`;
129
+ }
130
+
131
+ export function getEventQueue(host: string, apiKey?: string): EventQueue {
132
+ const normalized = host.replace(/\/$/, "");
133
+ const key = queueKey(normalized, apiKey);
134
+ let queue = queuesByHost.get(key);
135
+ if (!queue) {
136
+ queue = new EventQueue(normalized, apiKey);
137
+ queuesByHost.set(key, queue);
138
+ }
139
+ installLifecycleListeners();
140
+ return queue;
141
+ }
142
+
143
+ export function flushEventQueue(host: string, forceBeacon = false, apiKey?: string): void {
144
+ const queue = queuesByHost.get(queueKey(host, apiKey));
145
+ if (!queue) return;
146
+ queue.flush(forceBeacon);
147
+ }
148
+
149
+ export function flushAllEventQueues(forceBeacon = false): void {
150
+ for (const queue of queuesByHost.values()) {
151
+ queue.flush(forceBeacon);
152
+ }
153
+ }
154
+
155
+ export function resetEventQueuesForTests(): void {
156
+ queuesByHost.clear();
157
+ }