@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,6 +1,12 @@
1
1
  import { describe, it, expect, beforeEach } from "vitest";
2
2
  import { makeDedupeKey, hasSeen, markSeen, resetDedupe } from "../utils/dedupeStorage";
3
- import { getDistinctId, getSessionId, buildEventContext } from "../utils/eventContext";
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
- impression: resolved ? (track?.impression !== false) : false,
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://gushi.onrender.com";
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://gushi.onrender.com */
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: host.replace(/\/$/, ""),
64
+ host: normalizedHost,
65
+ apiKey,
39
66
  customerId,
40
67
  bootstrap: bootstrap ?? {},
41
68
  }),
42
- [customerId, host, bootstrap]
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: string, properties?: Record<string, unknown>) => void;
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: string, properties: Record<string, unknown> = {}) => {
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 { capture };
78
+ return {
79
+ capture,
80
+ captureGoal,
81
+ captureFeatureInteraction,
82
+ };
39
83
  }
@@ -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: string;
12
- environment: "dev" | "prod";
13
- properties: Record<string, unknown>;
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: string,
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: detectEnvironment(),
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
- try {
85
- const url = `${host.replace(/\/$/, "")}/experiment/metrics`;
86
- fetch(url, {
87
- method: "POST",
88
- headers: { "Content-Type": "application/json" },
89
- credentials: "include",
90
- body: JSON.stringify(payload),
91
- }).catch(() => {});
92
- } catch {
93
- // silently drop
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 {
@@ -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(): "dev" | "prod" {
7
+ export function detectEnvironment(): ProbatEnvironment {
6
8
  if (typeof window === "undefined") {
7
- return "prod"; // Server-side, default to prod
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 "dev";
38
+ return PROBAT_ENV_DEV;
37
39
  }
38
40
 
39
- return "prod";
41
+ return PROBAT_ENV_PROD;
40
42
  }
41
-