@probat/react 0.4.5 → 0.4.7
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 +42 -9
- package/dist/index.d.ts +42 -9
- package/dist/index.js +327 -70
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +325 -71
- 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/context/ProbatContext.tsx +27 -5
- package/src/hooks/useExperiment.ts +28 -2
- package/src/hooks/useProbatMetrics.ts +47 -3
- package/src/index.ts +11 -1
- package/src/types/events.ts +48 -0
- package/src/utils/api.ts +30 -18
- 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
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
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;
|
|
@@ -17,9 +18,9 @@ export interface ProbatProviderProps {
|
|
|
17
18
|
/** Your end-user's ID. When provided, used as the distinct_id for variant
|
|
18
19
|
* assignment (consistent across devices) and attached to all events. */
|
|
19
20
|
customerId?: string;
|
|
20
|
-
/** Base URL for the Probat API. Defaults to https://
|
|
21
|
+
/** Base URL for the Probat API. Defaults to https://api.probat.app */
|
|
21
22
|
host?: string;
|
|
22
|
-
/** API key for authenticating SDK requests (
|
|
23
|
+
/** Publishable API key for authenticating SDK requests (probat_pk_...) */
|
|
23
24
|
apiKey?: string;
|
|
24
25
|
/**
|
|
25
26
|
* Bootstrap assignments to avoid flash on first render.
|
|
@@ -27,6 +28,8 @@ export interface ProbatProviderProps {
|
|
|
27
28
|
* e.g. { "cta-copy-test": "ai_v1" }
|
|
28
29
|
*/
|
|
29
30
|
bootstrap?: Record<string, string>;
|
|
31
|
+
/** Automatically send $session_start/$session_end lifecycle events. */
|
|
32
|
+
trackSessionLifecycle?: boolean;
|
|
30
33
|
children: React.ReactNode;
|
|
31
34
|
}
|
|
32
35
|
|
|
@@ -35,16 +38,35 @@ export function ProbatProvider({
|
|
|
35
38
|
host = DEFAULT_HOST,
|
|
36
39
|
apiKey,
|
|
37
40
|
bootstrap,
|
|
41
|
+
trackSessionLifecycle = true,
|
|
38
42
|
children,
|
|
39
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
|
+
|
|
40
62
|
const value = useMemo<ProbatContextValue>(
|
|
41
63
|
() => ({
|
|
42
|
-
host:
|
|
64
|
+
host: normalizedHost,
|
|
43
65
|
apiKey,
|
|
44
66
|
customerId,
|
|
45
67
|
bootstrap: bootstrap ?? {},
|
|
46
68
|
}),
|
|
47
|
-
[customerId,
|
|
69
|
+
[customerId, normalizedHost, apiKey, bootstrap]
|
|
48
70
|
);
|
|
49
71
|
|
|
50
72
|
return (
|
|
@@ -52,11 +52,28 @@ export interface UseExperimentReturn {
|
|
|
52
52
|
|
|
53
53
|
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Reads the __probat_force query param for a specific experiment ID.
|
|
57
|
+
* Format: ?__probat_force=<experimentId>:<variantKey>
|
|
58
|
+
* Used in preview sandboxes to pin a specific variant without an API call.
|
|
59
|
+
*/
|
|
60
|
+
function readForceParam(id: string): string | null {
|
|
61
|
+
if (typeof window === "undefined") return null;
|
|
62
|
+
try {
|
|
63
|
+
const raw = new URLSearchParams(window.location.search).get("__probat_force");
|
|
64
|
+
if (!raw) return null;
|
|
65
|
+
const [forceId, forceKey] = raw.split(":");
|
|
66
|
+
return forceId === id ? (forceKey ?? null) : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
/**
|
|
56
73
|
* Resolves the variant assignment for an experiment.
|
|
57
74
|
* No tracking — use `useTrack` or `<Track>` to observe impressions/clicks.
|
|
58
75
|
*
|
|
59
|
-
* Priority: bootstrap > localStorage cache > fetchDecision.
|
|
76
|
+
* Priority: __probat_force URL param > bootstrap > localStorage cache > fetchDecision.
|
|
60
77
|
*/
|
|
61
78
|
export function useExperiment(
|
|
62
79
|
id: string,
|
|
@@ -66,14 +83,23 @@ export function useExperiment(
|
|
|
66
83
|
const { host, bootstrap, customerId, apiKey } = useProbatContext();
|
|
67
84
|
|
|
68
85
|
const [variantKey, setVariantKey] = useState<string>(() => {
|
|
86
|
+
const forced = readForceParam(id);
|
|
87
|
+
if (forced) return forced;
|
|
69
88
|
if (bootstrap[id]) return bootstrap[id];
|
|
70
89
|
return "control";
|
|
71
90
|
});
|
|
72
91
|
const [resolved, setResolved] = useState<boolean>(() => {
|
|
73
|
-
return !!bootstrap[id];
|
|
92
|
+
return !!(readForceParam(id) || bootstrap[id]);
|
|
74
93
|
});
|
|
75
94
|
|
|
76
95
|
useEffect(() => {
|
|
96
|
+
const forced = readForceParam(id);
|
|
97
|
+
if (forced) {
|
|
98
|
+
setVariantKey(forced);
|
|
99
|
+
setResolved(true);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
if (bootstrap[id] || readAssignment(id)) {
|
|
78
104
|
const key = bootstrap[id] ?? readAssignment(id) ?? "control";
|
|
79
105
|
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
|
/**
|
|
@@ -26,7 +36,7 @@ export function useProbatMetrics(): UseProbatMetricsReturn {
|
|
|
26
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,
|
|
@@ -35,5 +45,39 @@ export function useProbatMetrics(): UseProbatMetricsReturn {
|
|
|
35
45
|
[host, customerId, apiKey]
|
|
36
46
|
);
|
|
37
47
|
|
|
38
|
-
|
|
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,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
[host, customerId],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
capture,
|
|
80
|
+
captureGoal,
|
|
81
|
+
captureFeatureInteraction,
|
|
82
|
+
};
|
|
39
83
|
}
|
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 ────────────────────────────────────────────────────
|
|
@@ -67,39 +74,44 @@ export async function fetchDecision(
|
|
|
67
74
|
*/
|
|
68
75
|
export function sendMetric(
|
|
69
76
|
host: string,
|
|
70
|
-
event:
|
|
77
|
+
event: ProbatEventType,
|
|
71
78
|
properties: Record<string, unknown>,
|
|
72
79
|
apiKey?: string
|
|
73
80
|
): void {
|
|
74
81
|
if (typeof window === "undefined") return;
|
|
75
82
|
|
|
83
|
+
const environment = detectEnvironment();
|
|
76
84
|
const ctx = buildEventContext();
|
|
77
85
|
const payload: MetricPayload = {
|
|
78
86
|
event,
|
|
79
|
-
environment
|
|
87
|
+
environment,
|
|
80
88
|
properties: {
|
|
81
89
|
...ctx,
|
|
90
|
+
environment,
|
|
82
91
|
source: "react-sdk",
|
|
83
92
|
captured_at: new Date().toISOString(),
|
|
84
93
|
...properties,
|
|
85
|
-
},
|
|
94
|
+
} as StructuredEventProperties,
|
|
86
95
|
};
|
|
87
96
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// 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);
|
|
100
108
|
}
|
|
101
109
|
}
|
|
102
110
|
|
|
111
|
+
export function flushMetrics(host: string, forceBeacon = false, apiKey?: string): void {
|
|
112
|
+
flushEventQueue(host, forceBeacon, apiKey);
|
|
113
|
+
}
|
|
114
|
+
|
|
103
115
|
// ── Click metadata extraction ──────────────────────────────────────────────
|
|
104
116
|
|
|
105
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
|
-
|
|
@@ -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
|
+
}
|