@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,6 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
2
|
import { makeDedupeKey, hasSeen, markSeen, resetDedupe } from "../utils/dedupeStorage";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getDistinctId,
|
|
5
|
+
getSessionId,
|
|
6
|
+
getSessionStartAt,
|
|
7
|
+
buildEventContext,
|
|
8
|
+
resetEventContextStateForTests,
|
|
9
|
+
} from "../utils/eventContext";
|
|
4
10
|
|
|
5
11
|
// ── dedupeStorage ──────────────────────────────────────────────────────────
|
|
6
12
|
|
|
@@ -50,6 +56,7 @@ describe("eventContext", () => {
|
|
|
50
56
|
beforeEach(() => {
|
|
51
57
|
localStorage.clear();
|
|
52
58
|
sessionStorage.clear();
|
|
59
|
+
resetEventContextStateForTests();
|
|
53
60
|
});
|
|
54
61
|
|
|
55
62
|
it("getDistinctId returns a stable id", () => {
|
|
@@ -68,6 +75,13 @@ describe("eventContext", () => {
|
|
|
68
75
|
expect(id1).toMatch(/^sess_|^server$/);
|
|
69
76
|
});
|
|
70
77
|
|
|
78
|
+
it("getSessionStartAt returns a stable timestamp within session", () => {
|
|
79
|
+
const s1 = getSessionStartAt();
|
|
80
|
+
const s2 = getSessionStartAt();
|
|
81
|
+
expect(s1).toBe(s2);
|
|
82
|
+
expect(new Date(s1).toString()).not.toBe("Invalid Date");
|
|
83
|
+
});
|
|
84
|
+
|
|
71
85
|
it("buildEventContext includes all required fields", () => {
|
|
72
86
|
const ctx = buildEventContext();
|
|
73
87
|
expect(ctx).toHaveProperty("distinct_id");
|
|
@@ -75,5 +89,14 @@ describe("eventContext", () => {
|
|
|
75
89
|
expect(ctx).toHaveProperty("$page_url");
|
|
76
90
|
expect(ctx).toHaveProperty("$pathname");
|
|
77
91
|
expect(ctx).toHaveProperty("$referrer");
|
|
92
|
+
expect(ctx).toHaveProperty("$session_sequence");
|
|
93
|
+
expect(ctx).toHaveProperty("$session_start_at");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("increments $session_sequence monotonically", () => {
|
|
97
|
+
const c1 = buildEventContext();
|
|
98
|
+
const c2 = buildEventContext();
|
|
99
|
+
expect(c2.$session_sequence).toBe(c1.$session_sequence + 1);
|
|
100
|
+
expect(c2.$session_start_at).toBe(c1.$session_start_at);
|
|
78
101
|
});
|
|
79
102
|
});
|
|
@@ -63,7 +63,8 @@ export function Experiment({
|
|
|
63
63
|
experimentId: id,
|
|
64
64
|
variantKey,
|
|
65
65
|
componentInstanceId,
|
|
66
|
-
|
|
66
|
+
resolved,
|
|
67
|
+
impression: track?.impression !== false,
|
|
67
68
|
click: track?.primaryClick !== false,
|
|
68
69
|
impressionEventName: track?.impressionEventName,
|
|
69
70
|
clickEventName: track?.clickEventName,
|
|
@@ -1,45 +1,72 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { createContext, useContext, useMemo } from "react";
|
|
3
|
+
import React, { createContext, useContext, useEffect, useMemo } from "react";
|
|
4
|
+
import { flushMetrics, sendMetric } from "../utils/api";
|
|
4
5
|
|
|
5
6
|
export interface ProbatContextValue {
|
|
6
7
|
host: string;
|
|
8
|
+
apiKey?: string;
|
|
7
9
|
customerId?: string;
|
|
8
10
|
bootstrap: Record<string, string>;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const ProbatContext = createContext<ProbatContextValue | null>(null);
|
|
12
14
|
|
|
13
|
-
const DEFAULT_HOST = "https://
|
|
15
|
+
const DEFAULT_HOST = "https://api.probat.app";
|
|
14
16
|
|
|
15
17
|
export interface ProbatProviderProps {
|
|
16
18
|
/** Your end-user's ID. When provided, used as the distinct_id for variant
|
|
17
19
|
* assignment (consistent across devices) and attached to all events. */
|
|
18
20
|
customerId?: string;
|
|
19
|
-
/** Base URL for the Probat API. Defaults to https://
|
|
21
|
+
/** Base URL for the Probat API. Defaults to https://api.probat.app */
|
|
20
22
|
host?: string;
|
|
23
|
+
/** Publishable API key for authenticating SDK requests (probat_pk_...) */
|
|
24
|
+
apiKey?: string;
|
|
21
25
|
/**
|
|
22
26
|
* Bootstrap assignments to avoid flash on first render.
|
|
23
27
|
* Map of experiment id → variant key.
|
|
24
28
|
* e.g. { "cta-copy-test": "ai_v1" }
|
|
25
29
|
*/
|
|
26
30
|
bootstrap?: Record<string, string>;
|
|
31
|
+
/** Automatically send $session_start/$session_end lifecycle events. */
|
|
32
|
+
trackSessionLifecycle?: boolean;
|
|
27
33
|
children: React.ReactNode;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export function ProbatProvider({
|
|
31
37
|
customerId,
|
|
32
38
|
host = DEFAULT_HOST,
|
|
39
|
+
apiKey,
|
|
33
40
|
bootstrap,
|
|
41
|
+
trackSessionLifecycle = true,
|
|
34
42
|
children,
|
|
35
43
|
}: ProbatProviderProps) {
|
|
44
|
+
const normalizedHost = host.replace(/\/$/, "");
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!trackSessionLifecycle) return;
|
|
48
|
+
if (typeof window === "undefined") return;
|
|
49
|
+
|
|
50
|
+
sendMetric(normalizedHost, "$session_start", {
|
|
51
|
+
...(customerId ? { distinct_id: customerId } : {}),
|
|
52
|
+
}, apiKey);
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
sendMetric(normalizedHost, "$session_end", {
|
|
56
|
+
...(customerId ? { distinct_id: customerId } : {}),
|
|
57
|
+
}, apiKey);
|
|
58
|
+
flushMetrics(normalizedHost, true, apiKey);
|
|
59
|
+
};
|
|
60
|
+
}, [normalizedHost, customerId, trackSessionLifecycle, apiKey]);
|
|
61
|
+
|
|
36
62
|
const value = useMemo<ProbatContextValue>(
|
|
37
63
|
() => ({
|
|
38
|
-
host:
|
|
64
|
+
host: normalizedHost,
|
|
65
|
+
apiKey,
|
|
39
66
|
customerId,
|
|
40
67
|
bootstrap: bootstrap ?? {},
|
|
41
68
|
}),
|
|
42
|
-
[customerId,
|
|
69
|
+
[customerId, normalizedHost, apiKey, bootstrap]
|
|
43
70
|
);
|
|
44
71
|
|
|
45
72
|
return (
|
|
@@ -52,28 +52,50 @@ export interface UseExperimentReturn {
|
|
|
52
52
|
|
|
53
53
|
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Reads the __probat_force query param from the current URL.
|
|
57
|
+
* Used in preview sandboxes to pin a specific variant without an API call.
|
|
58
|
+
*/
|
|
59
|
+
function readForceParam(): string | null {
|
|
60
|
+
if (typeof window === "undefined") return null;
|
|
61
|
+
try {
|
|
62
|
+
return new URLSearchParams(window.location.search).get("__probat_force");
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
/**
|
|
56
69
|
* Resolves the variant assignment for an experiment.
|
|
57
70
|
* No tracking — use `useTrack` or `<Track>` to observe impressions/clicks.
|
|
58
71
|
*
|
|
59
|
-
* Priority: bootstrap > localStorage cache > fetchDecision.
|
|
72
|
+
* Priority: __probat_force URL param > bootstrap > localStorage cache > fetchDecision.
|
|
60
73
|
*/
|
|
61
74
|
export function useExperiment(
|
|
62
75
|
id: string,
|
|
63
76
|
options: UseExperimentOptions = {}
|
|
64
77
|
): UseExperimentReturn {
|
|
65
78
|
const { fallback = "control", debug = false } = options;
|
|
66
|
-
const { host, bootstrap, customerId } = useProbatContext();
|
|
79
|
+
const { host, bootstrap, customerId, apiKey } = useProbatContext();
|
|
67
80
|
|
|
68
81
|
const [variantKey, setVariantKey] = useState<string>(() => {
|
|
82
|
+
const forced = readForceParam();
|
|
83
|
+
if (forced) return forced;
|
|
69
84
|
if (bootstrap[id]) return bootstrap[id];
|
|
70
85
|
return "control";
|
|
71
86
|
});
|
|
72
87
|
const [resolved, setResolved] = useState<boolean>(() => {
|
|
73
|
-
return !!bootstrap[id];
|
|
88
|
+
return !!(readForceParam() || bootstrap[id]);
|
|
74
89
|
});
|
|
75
90
|
|
|
76
91
|
useEffect(() => {
|
|
92
|
+
const forced = readForceParam();
|
|
93
|
+
if (forced) {
|
|
94
|
+
setVariantKey(forced);
|
|
95
|
+
setResolved(true);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
77
99
|
if (bootstrap[id] || readAssignment(id)) {
|
|
78
100
|
const key = bootstrap[id] ?? readAssignment(id) ?? "control";
|
|
79
101
|
setVariantKey(key);
|
|
@@ -86,7 +108,7 @@ export function useExperiment(
|
|
|
86
108
|
(async () => {
|
|
87
109
|
try {
|
|
88
110
|
const distinctId = customerId ?? getDistinctId();
|
|
89
|
-
const key = await fetchDecision(host, id, distinctId);
|
|
111
|
+
const key = await fetchDecision(host, id, distinctId, apiKey);
|
|
90
112
|
if (cancelled) return;
|
|
91
113
|
|
|
92
114
|
setVariantKey(key);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useCallback } from "react";
|
|
4
4
|
import { useProbatContext } from "../context/ProbatContext";
|
|
5
5
|
import { sendMetric } from "../utils/api";
|
|
6
|
+
import type { ProbatEventType } from "../types/events";
|
|
6
7
|
|
|
7
8
|
export interface UseProbatMetricsReturn {
|
|
8
9
|
/**
|
|
@@ -15,7 +16,16 @@ export interface UseProbatMetricsReturn {
|
|
|
15
16
|
* capture("purchase", { revenue: 42, currency: "USD" });
|
|
16
17
|
* ```
|
|
17
18
|
*/
|
|
18
|
-
capture: (event:
|
|
19
|
+
capture: (event: ProbatEventType, properties?: Record<string, unknown>) => void;
|
|
20
|
+
captureGoal: (
|
|
21
|
+
funnelId: string,
|
|
22
|
+
funnelStep: number,
|
|
23
|
+
properties?: Record<string, unknown>,
|
|
24
|
+
) => void;
|
|
25
|
+
captureFeatureInteraction: (
|
|
26
|
+
interactionName: string,
|
|
27
|
+
properties?: Record<string, unknown>,
|
|
28
|
+
) => void;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
/**
|
|
@@ -23,17 +33,51 @@ export interface UseProbatMetricsReturn {
|
|
|
23
33
|
* that sends events to the Probat backend using the provider's host config.
|
|
24
34
|
*/
|
|
25
35
|
export function useProbatMetrics(): UseProbatMetricsReturn {
|
|
26
|
-
const { host, customerId } = useProbatContext();
|
|
36
|
+
const { host, customerId, apiKey } = useProbatContext();
|
|
27
37
|
|
|
28
38
|
const capture = useCallback(
|
|
29
|
-
(event:
|
|
39
|
+
(event: ProbatEventType, properties: Record<string, unknown> = {}) => {
|
|
30
40
|
sendMetric(host, event, {
|
|
31
41
|
...(customerId ? { distinct_id: customerId } : {}),
|
|
32
42
|
...properties,
|
|
43
|
+
}, apiKey);
|
|
44
|
+
},
|
|
45
|
+
[host, customerId, apiKey]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const captureGoal = useCallback(
|
|
49
|
+
(
|
|
50
|
+
funnelId: string,
|
|
51
|
+
funnelStep: number,
|
|
52
|
+
properties: Record<string, unknown> = {},
|
|
53
|
+
) => {
|
|
54
|
+
sendMetric(host, "$goal_reached", {
|
|
55
|
+
...(customerId ? { distinct_id: customerId } : {}),
|
|
56
|
+
$funnel_id: funnelId,
|
|
57
|
+
$funnel_step: funnelStep,
|
|
58
|
+
...properties,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
[host, customerId],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const captureFeatureInteraction = useCallback(
|
|
65
|
+
(
|
|
66
|
+
interactionName: string,
|
|
67
|
+
properties: Record<string, unknown> = {},
|
|
68
|
+
) => {
|
|
69
|
+
sendMetric(host, "$feature_interaction", {
|
|
70
|
+
...(customerId ? { distinct_id: customerId } : {}),
|
|
71
|
+
interaction_name: interactionName,
|
|
72
|
+
...properties,
|
|
33
73
|
});
|
|
34
74
|
},
|
|
35
|
-
[host, customerId]
|
|
75
|
+
[host, customerId],
|
|
36
76
|
);
|
|
37
77
|
|
|
38
|
-
return {
|
|
78
|
+
return {
|
|
79
|
+
capture,
|
|
80
|
+
captureGoal,
|
|
81
|
+
captureFeatureInteraction,
|
|
82
|
+
};
|
|
39
83
|
}
|
package/src/hooks/useTrack.ts
CHANGED
|
@@ -14,6 +14,8 @@ interface UseTrackBaseOptions {
|
|
|
14
14
|
experimentId: string;
|
|
15
15
|
/** Stable instance id when multiple instances of the same experiment exist on a page */
|
|
16
16
|
componentInstanceId?: string;
|
|
17
|
+
/** Whether the variant assignment has been resolved. When false, all tracking is suppressed. */
|
|
18
|
+
resolved?: boolean;
|
|
17
19
|
/** Auto-track impressions (default true) */
|
|
18
20
|
impression?: boolean;
|
|
19
21
|
/** Auto-track clicks (default true) */
|
|
@@ -65,6 +67,7 @@ export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement
|
|
|
65
67
|
const {
|
|
66
68
|
experimentId,
|
|
67
69
|
componentInstanceId,
|
|
70
|
+
resolved = true,
|
|
68
71
|
impression: trackImpression = true,
|
|
69
72
|
click: trackClick = true,
|
|
70
73
|
impressionEventName = "$experiment_exposure",
|
|
@@ -75,7 +78,7 @@ export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement
|
|
|
75
78
|
const variantKey = options.variantKey ?? undefined;
|
|
76
79
|
const explicitCustomerId = "customerId" in options ? options.customerId : undefined;
|
|
77
80
|
|
|
78
|
-
const { host, customerId: providerCustomerId } = useProbatContext();
|
|
81
|
+
const { host, customerId: providerCustomerId, apiKey } = useProbatContext();
|
|
79
82
|
|
|
80
83
|
// In customer mode, use explicit customerId or fall back to provider's
|
|
81
84
|
const resolvedCustomerId = explicitCustomerId ?? providerCustomerId;
|
|
@@ -113,7 +116,7 @@ export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement
|
|
|
113
116
|
const dedupeVariant = variantKey ?? resolvedCustomerId ?? "__anon__";
|
|
114
117
|
|
|
115
118
|
useEffect(() => {
|
|
116
|
-
if (!trackImpression) return;
|
|
119
|
+
if (!trackImpression || !resolved) return;
|
|
117
120
|
|
|
118
121
|
impressionSent.current = false;
|
|
119
122
|
|
|
@@ -133,7 +136,7 @@ export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement
|
|
|
133
136
|
if (!impressionSent.current) {
|
|
134
137
|
impressionSent.current = true;
|
|
135
138
|
markSeen(dedupeKey);
|
|
136
|
-
sendMetric(host, impressionEventName, eventProps);
|
|
139
|
+
sendMetric(host, impressionEventName, eventProps, apiKey);
|
|
137
140
|
if (debug) console.log(`[probat] Impression sent (no IO) for "${experimentId}"`);
|
|
138
141
|
}
|
|
139
142
|
return;
|
|
@@ -150,7 +153,7 @@ export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement
|
|
|
150
153
|
if (impressionSent.current) return;
|
|
151
154
|
impressionSent.current = true;
|
|
152
155
|
markSeen(dedupeKey);
|
|
153
|
-
sendMetric(host, impressionEventName, eventProps);
|
|
156
|
+
sendMetric(host, impressionEventName, eventProps, apiKey);
|
|
154
157
|
if (debug) console.log(`[probat] Impression sent for "${experimentId}"`);
|
|
155
158
|
observer.disconnect();
|
|
156
159
|
}, 250);
|
|
@@ -170,6 +173,7 @@ export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement
|
|
|
170
173
|
};
|
|
171
174
|
}, [
|
|
172
175
|
trackImpression,
|
|
176
|
+
resolved,
|
|
173
177
|
experimentId,
|
|
174
178
|
dedupeVariant,
|
|
175
179
|
instanceId,
|
|
@@ -183,7 +187,7 @@ export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement
|
|
|
183
187
|
|
|
184
188
|
const handleClick = useCallback(
|
|
185
189
|
(e: Event) => {
|
|
186
|
-
if (!trackClick) return;
|
|
190
|
+
if (!trackClick || !resolved) return;
|
|
187
191
|
|
|
188
192
|
const meta = extractClickMeta(e.target as EventTarget);
|
|
189
193
|
if (!meta) return;
|
|
@@ -191,12 +195,12 @@ export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement
|
|
|
191
195
|
sendMetric(host, clickEventName, {
|
|
192
196
|
...eventProps,
|
|
193
197
|
...meta,
|
|
194
|
-
});
|
|
198
|
+
}, apiKey);
|
|
195
199
|
if (debug) {
|
|
196
200
|
console.log(`[probat] Click tracked for "${experimentId}"`, meta);
|
|
197
201
|
}
|
|
198
202
|
},
|
|
199
|
-
[trackClick, host, clickEventName, eventProps, experimentId, debug]
|
|
203
|
+
[trackClick, resolved, host, clickEventName, eventProps, experimentId, debug]
|
|
200
204
|
);
|
|
201
205
|
|
|
202
206
|
useEffect(() => {
|
package/src/index.ts
CHANGED
|
@@ -19,8 +19,18 @@ export { useTrack } from "./hooks/useTrack";
|
|
|
19
19
|
export type { UseTrackOptions, UseTrackExplicitOptions, UseTrackCustomerOptions } from "./hooks/useTrack";
|
|
20
20
|
export { useProbatMetrics } from "./hooks/useProbatMetrics";
|
|
21
21
|
export type { UseProbatMetricsReturn } from "./hooks/useProbatMetrics";
|
|
22
|
+
export {
|
|
23
|
+
PROBAT_ENV_DEV,
|
|
24
|
+
PROBAT_ENV_PROD,
|
|
25
|
+
} from "./types/events";
|
|
26
|
+
export type {
|
|
27
|
+
ProbatEnvironment,
|
|
28
|
+
ProbatEventType,
|
|
29
|
+
StructuredEvent,
|
|
30
|
+
StructuredEventProperties,
|
|
31
|
+
} from "./types/events";
|
|
22
32
|
|
|
23
33
|
// ── Utilities (advanced) ───────────────────────────────────────────────────
|
|
24
|
-
export { sendMetric, fetchDecision } from "./utils/api";
|
|
34
|
+
export { sendMetric, fetchDecision, flushMetrics } from "./utils/api";
|
|
25
35
|
export type { MetricPayload, DecisionResponse } from "./utils/api";
|
|
26
36
|
export { createExperimentContext } from "./utils/createExperimentContext";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const PROBAT_ENV_DEV = "dev" as const;
|
|
2
|
+
export const PROBAT_ENV_PROD = "prod" as const;
|
|
3
|
+
export type ProbatEnvironment = typeof PROBAT_ENV_DEV | typeof PROBAT_ENV_PROD;
|
|
4
|
+
|
|
5
|
+
export type ProbatEventType =
|
|
6
|
+
| "$experiment_exposure"
|
|
7
|
+
| "$experiment_click"
|
|
8
|
+
| "$pageview"
|
|
9
|
+
| "$pageleave"
|
|
10
|
+
| "$session_start"
|
|
11
|
+
| "$session_end"
|
|
12
|
+
| "$goal_reached"
|
|
13
|
+
| "$feature_interaction"
|
|
14
|
+
| string;
|
|
15
|
+
|
|
16
|
+
export interface StructuredEventProperties {
|
|
17
|
+
// Always present (auto-injected by SDK)
|
|
18
|
+
distinct_id: string;
|
|
19
|
+
session_id: string;
|
|
20
|
+
$page_url: string;
|
|
21
|
+
$pathname: string;
|
|
22
|
+
$referrer: string;
|
|
23
|
+
captured_at: string;
|
|
24
|
+
environment: ProbatEnvironment;
|
|
25
|
+
source: "react-sdk" | "react-native-sdk";
|
|
26
|
+
|
|
27
|
+
// Session context
|
|
28
|
+
$session_sequence: number;
|
|
29
|
+
$session_start_at: string;
|
|
30
|
+
|
|
31
|
+
// Optional context
|
|
32
|
+
experiment_id?: string;
|
|
33
|
+
variant_key?: string;
|
|
34
|
+
component_instance_id?: string;
|
|
35
|
+
|
|
36
|
+
// Funnel context
|
|
37
|
+
$funnel_id?: string;
|
|
38
|
+
$funnel_step?: number;
|
|
39
|
+
|
|
40
|
+
// Custom
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StructuredEvent {
|
|
45
|
+
event: ProbatEventType;
|
|
46
|
+
environment: ProbatEnvironment;
|
|
47
|
+
properties: StructuredEventProperties;
|
|
48
|
+
}
|
package/src/utils/api.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { detectEnvironment } from "./environment";
|
|
2
2
|
import { buildEventContext } from "./eventContext";
|
|
3
|
+
import { getEventQueue, flushEventQueue } from "./eventQueue";
|
|
4
|
+
import type {
|
|
5
|
+
ProbatEnvironment,
|
|
6
|
+
ProbatEventType,
|
|
7
|
+
StructuredEvent,
|
|
8
|
+
StructuredEventProperties,
|
|
9
|
+
} from "../types/events";
|
|
3
10
|
|
|
4
11
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
5
12
|
|
|
@@ -8,9 +15,9 @@ export interface DecisionResponse {
|
|
|
8
15
|
}
|
|
9
16
|
|
|
10
17
|
export interface MetricPayload {
|
|
11
|
-
event:
|
|
12
|
-
environment:
|
|
13
|
-
properties:
|
|
18
|
+
event: ProbatEventType;
|
|
19
|
+
environment: ProbatEnvironment;
|
|
20
|
+
properties: StructuredEventProperties;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
// ── Assignment fetching ────────────────────────────────────────────────────
|
|
@@ -25,7 +32,8 @@ const pendingDecisions = new Map<string, Promise<string>>();
|
|
|
25
32
|
export async function fetchDecision(
|
|
26
33
|
host: string,
|
|
27
34
|
experimentId: string,
|
|
28
|
-
distinctId: string
|
|
35
|
+
distinctId: string,
|
|
36
|
+
apiKey?: string
|
|
29
37
|
): Promise<string> {
|
|
30
38
|
const existing = pendingDecisions.get(experimentId);
|
|
31
39
|
if (existing) return existing;
|
|
@@ -33,12 +41,14 @@ export async function fetchDecision(
|
|
|
33
41
|
const promise = (async () => {
|
|
34
42
|
try {
|
|
35
43
|
const url = `${host.replace(/\/$/, "")}/experiment/decide`;
|
|
44
|
+
const headers: Record<string, string> = {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
Accept: "application/json",
|
|
47
|
+
};
|
|
48
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
36
49
|
const res = await fetch(url, {
|
|
37
50
|
method: "POST",
|
|
38
|
-
headers
|
|
39
|
-
"Content-Type": "application/json",
|
|
40
|
-
Accept: "application/json",
|
|
41
|
-
},
|
|
51
|
+
headers,
|
|
42
52
|
credentials: "include",
|
|
43
53
|
body: JSON.stringify({
|
|
44
54
|
experiment_id: experimentId,
|
|
@@ -64,36 +74,44 @@ export async function fetchDecision(
|
|
|
64
74
|
*/
|
|
65
75
|
export function sendMetric(
|
|
66
76
|
host: string,
|
|
67
|
-
event:
|
|
68
|
-
properties: Record<string, unknown
|
|
77
|
+
event: ProbatEventType,
|
|
78
|
+
properties: Record<string, unknown>,
|
|
79
|
+
apiKey?: string
|
|
69
80
|
): void {
|
|
70
81
|
if (typeof window === "undefined") return;
|
|
71
82
|
|
|
83
|
+
const environment = detectEnvironment();
|
|
72
84
|
const ctx = buildEventContext();
|
|
73
85
|
const payload: MetricPayload = {
|
|
74
86
|
event,
|
|
75
|
-
environment
|
|
87
|
+
environment,
|
|
76
88
|
properties: {
|
|
77
89
|
...ctx,
|
|
90
|
+
environment,
|
|
78
91
|
source: "react-sdk",
|
|
79
92
|
captured_at: new Date().toISOString(),
|
|
80
93
|
...properties,
|
|
81
|
-
},
|
|
94
|
+
} as StructuredEventProperties,
|
|
82
95
|
};
|
|
83
96
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
const queuePayload: StructuredEvent = {
|
|
98
|
+
event: payload.event,
|
|
99
|
+
environment: payload.environment,
|
|
100
|
+
properties: payload.properties,
|
|
101
|
+
};
|
|
102
|
+
const queue = getEventQueue(host, apiKey);
|
|
103
|
+
queue.enqueue(queuePayload);
|
|
104
|
+
|
|
105
|
+
// Preserve immediate delivery semantics for core experiment events.
|
|
106
|
+
if (event === "$experiment_exposure" || event === "$experiment_click") {
|
|
107
|
+
queue.flush(false);
|
|
94
108
|
}
|
|
95
109
|
}
|
|
96
110
|
|
|
111
|
+
export function flushMetrics(host: string, forceBeacon = false, apiKey?: string): void {
|
|
112
|
+
flushEventQueue(host, forceBeacon, apiKey);
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
// ── Click metadata extraction ──────────────────────────────────────────────
|
|
98
116
|
|
|
99
117
|
export interface ClickMeta {
|
package/src/utils/environment.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { PROBAT_ENV_DEV, PROBAT_ENV_PROD, type ProbatEnvironment } from "../types/events";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Detect if the code is running on localhost (development environment).
|
|
3
5
|
* Returns "dev" for localhost, "prod" for production.
|
|
4
6
|
*/
|
|
5
|
-
export function detectEnvironment():
|
|
7
|
+
export function detectEnvironment(): ProbatEnvironment {
|
|
6
8
|
if (typeof window === "undefined") {
|
|
7
|
-
return
|
|
9
|
+
return PROBAT_ENV_PROD; // Server-side, default to prod
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
const hostname = window.location.hostname;
|
|
@@ -33,9 +35,8 @@ export function detectEnvironment(): "dev" | "prod" {
|
|
|
33
35
|
hostname.startsWith("172.30.") ||
|
|
34
36
|
hostname.startsWith("172.31.")
|
|
35
37
|
) {
|
|
36
|
-
return
|
|
38
|
+
return PROBAT_ENV_DEV;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
return
|
|
41
|
+
return PROBAT_ENV_PROD;
|
|
40
42
|
}
|
|
41
|
-
|