@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.
- package/dist/index.d.mts +46 -9
- package/dist/index.d.ts +46 -9
- package/dist/index.js +346 -83
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +344 -84
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/__tests__/Experiment.test.tsx +6 -5
- package/src/__tests__/Track.test.tsx +1 -1
- package/src/__tests__/eventQueue.test.ts +68 -0
- package/src/__tests__/setup.ts +55 -0
- package/src/__tests__/useExperiment.test.tsx +3 -2
- package/src/__tests__/useTrack.test.tsx +4 -4
- package/src/__tests__/utils.test.ts +24 -1
- package/src/components/Experiment.tsx +2 -1
- package/src/context/ProbatContext.tsx +32 -5
- package/src/hooks/useExperiment.ts +26 -4
- package/src/hooks/useProbatMetrics.ts +49 -5
- package/src/hooks/useTrack.ts +11 -7
- package/src/index.ts +11 -1
- package/src/types/events.ts +48 -0
- package/src/utils/api.ts +40 -22
- package/src/utils/environment.ts +6 -5
- package/src/utils/eventContext.ts +117 -17
- package/src/utils/eventQueue.ts +157 -0
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Event context helpers:
|
|
3
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
}
|