@usereq/widget 0.2.3 → 0.2.5
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/widget.js +320 -320
- package/package.json +1 -1
- package/src/custom-element/agent-widget-element.tsx +5 -40
- package/src/index.ts +0 -1
- package/src/renderer/widget-runtime.tsx +4 -15
- package/src/runtime/api.ts +0 -28
- package/src/types.ts +1 -8
- package/src/runtime/analytics.ts +0 -431
- package/src/runtime/token-renewal.ts +0 -13
- package/src/shared/analytics.ts +0 -54
package/package.json
CHANGED
|
@@ -19,7 +19,6 @@ import { widgetTriggerRuleKey } from "../shared/widget-config";
|
|
|
19
19
|
import { WidgetRuntime, type WidgetRuntimeStatus } from "../renderer/index";
|
|
20
20
|
import { buildAssistantMessageRevealSteps } from "../chat-widget";
|
|
21
21
|
import { DEFAULT_STOP_CONVERSATION_PROMPT } from "../shared/stop-confirmation";
|
|
22
|
-
import type { WidgetTriggerRuleType } from "../shared/analytics";
|
|
23
22
|
import {
|
|
24
23
|
bootstrapWidget,
|
|
25
24
|
resetWidgetSession,
|
|
@@ -27,10 +26,6 @@ import {
|
|
|
27
26
|
startWidgetConversation,
|
|
28
27
|
submitWidgetMessage,
|
|
29
28
|
} from "../runtime/bootstrap";
|
|
30
|
-
import {
|
|
31
|
-
createWidgetAnalyticsTracker,
|
|
32
|
-
type WidgetAnalyticsTracker,
|
|
33
|
-
} from "../runtime/analytics";
|
|
34
29
|
import { getWidgetShadowCss } from "../shared/shadow-css";
|
|
35
30
|
import { WIDGET_SHADOW_THEME_CSS } from "../shared/shadow-theme";
|
|
36
31
|
|
|
@@ -72,8 +67,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
72
67
|
private sendVersion = 0;
|
|
73
68
|
private confirmationVersion = 0;
|
|
74
69
|
private bootstrapPhase: "idle" | "booting" | "ready" | "blocked" = "idle";
|
|
75
|
-
private analyticsTrackerKey = "";
|
|
76
|
-
private analyticsTracker: WidgetAnalyticsTracker | null = null;
|
|
77
70
|
private autoStartTriggered = false;
|
|
78
71
|
|
|
79
72
|
constructor() {
|
|
@@ -110,7 +103,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
110
103
|
|
|
111
104
|
connectedCallback() {
|
|
112
105
|
this.syncAttributes();
|
|
113
|
-
this.recreateAnalyticsTracker();
|
|
114
106
|
this.renderWidget();
|
|
115
107
|
if (this.agentId) {
|
|
116
108
|
void this.ensureBootstrap();
|
|
@@ -119,9 +111,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
119
111
|
|
|
120
112
|
disconnectedCallback() {
|
|
121
113
|
this.bootstrapPromise = null;
|
|
122
|
-
this.analyticsTracker?.dispose();
|
|
123
|
-
this.analyticsTracker = null;
|
|
124
|
-
this.analyticsTrackerKey = "";
|
|
125
114
|
this.root.unmount();
|
|
126
115
|
}
|
|
127
116
|
|
|
@@ -131,7 +120,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
131
120
|
newValue?: string | null,
|
|
132
121
|
) {
|
|
133
122
|
this.syncAttributes();
|
|
134
|
-
this.recreateAnalyticsTracker();
|
|
135
123
|
if (name === "agent-id" && oldValue !== newValue) {
|
|
136
124
|
this.clearAgentState();
|
|
137
125
|
}
|
|
@@ -202,6 +190,10 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
202
190
|
return this.sessionConfig?.widgetConfig.spotlightMessages ?? [];
|
|
203
191
|
}
|
|
204
192
|
|
|
193
|
+
private getCtaLink(): string | null {
|
|
194
|
+
return this.sessionConfig?.widgetConfig.ctaLink ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
205
197
|
private clearAgentState() {
|
|
206
198
|
this.sendVersion += 1;
|
|
207
199
|
this.confirmationVersion += 1;
|
|
@@ -217,22 +209,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
217
209
|
this.status = "idle";
|
|
218
210
|
}
|
|
219
211
|
|
|
220
|
-
private recreateAnalyticsTracker() {
|
|
221
|
-
const nextKey = `${this.mode}:${this.agentId}`;
|
|
222
|
-
if (nextKey === this.analyticsTrackerKey) {
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
this.analyticsTracker?.dispose();
|
|
227
|
-
this.analyticsTracker = createWidgetAnalyticsTracker({
|
|
228
|
-
mode: this.mode,
|
|
229
|
-
agentId: this.agentId,
|
|
230
|
-
getSession: () => this.sessionState,
|
|
231
|
-
getConversationId: () => this.sessionState?.conversationId,
|
|
232
|
-
});
|
|
233
|
-
this.analyticsTrackerKey = nextKey;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
212
|
private renderWidget() {
|
|
237
213
|
if (
|
|
238
214
|
this.bootstrapPhase !== "ready" ||
|
|
@@ -289,6 +265,7 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
289
265
|
widgetTitle: this.getWidgetTitle(),
|
|
290
266
|
welcomeMessage: this.getWelcomeMessage(),
|
|
291
267
|
spotlights: this.getSpotlights(),
|
|
268
|
+
ctaLink: this.getCtaLink(),
|
|
292
269
|
open: this.open,
|
|
293
270
|
status: this.status,
|
|
294
271
|
messages: this.messages,
|
|
@@ -305,10 +282,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
305
282
|
onSendMessage: (content: string) => void this.sendMessage(content),
|
|
306
283
|
onConfirmationDecision: (confirmationId: string, accepted: boolean) =>
|
|
307
284
|
void this.handleConfirmationDecision(confirmationId, accepted),
|
|
308
|
-
onWidgetVisible: (triggerRuleType: WidgetTriggerRuleType | null) =>
|
|
309
|
-
this.analyticsTracker?.trackView(triggerRuleType),
|
|
310
|
-
onLinkClick: (linkUrl: string) =>
|
|
311
|
-
this.analyticsTracker?.trackLinkClicked(linkUrl),
|
|
312
285
|
portalContainer: this.shadowRootRef,
|
|
313
286
|
}),
|
|
314
287
|
);
|
|
@@ -355,10 +328,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
355
328
|
this.welcomeMessage = state.session.widgetConfig.welcomeMessage;
|
|
356
329
|
this.status = "ready";
|
|
357
330
|
this.bootstrapPhase = "ready";
|
|
358
|
-
if (this.mode === "embed") {
|
|
359
|
-
this.analyticsTracker?.trackScriptLoaded();
|
|
360
|
-
this.analyticsTracker?.trackUniqueScriptLoaded();
|
|
361
|
-
}
|
|
362
331
|
this.renderWidget();
|
|
363
332
|
} catch (error) {
|
|
364
333
|
if (runVersion !== this.bootstrapVersion) {
|
|
@@ -461,12 +430,8 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
461
430
|
}
|
|
462
431
|
|
|
463
432
|
private async handleOpenChange(nextOpen: boolean) {
|
|
464
|
-
const wasOpen = this.open;
|
|
465
433
|
this.open = nextOpen;
|
|
466
434
|
this.renderWidget();
|
|
467
|
-
if (!wasOpen && nextOpen && this.mode === "embed") {
|
|
468
|
-
this.analyticsTracker?.trackClick();
|
|
469
|
-
}
|
|
470
435
|
|
|
471
436
|
if (this.open && (this.status === "idle" || !this.sessionState)) {
|
|
472
437
|
await this.ensureBootstrap();
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
} from "../shared/stop-confirmation";
|
|
28
28
|
import { widgetCss } from "../styles/widget.css";
|
|
29
29
|
import { useWidgetTriggerGate } from "../runtime/trigger-rule";
|
|
30
|
-
import type { WidgetTriggerRuleType } from "../shared/analytics";
|
|
31
30
|
|
|
32
31
|
export type WidgetRuntimeMode = "embed" | "preview";
|
|
33
32
|
|
|
@@ -49,6 +48,7 @@ export type WidgetRuntimeProps = {
|
|
|
49
48
|
widgetTitle: string;
|
|
50
49
|
welcomeMessage: string | null;
|
|
51
50
|
spotlights: string[];
|
|
51
|
+
ctaLink?: string | null;
|
|
52
52
|
open: boolean;
|
|
53
53
|
status: WidgetRuntimeStatus;
|
|
54
54
|
messages: WidgetMessage[];
|
|
@@ -64,8 +64,6 @@ export type WidgetRuntimeProps = {
|
|
|
64
64
|
accepted: boolean,
|
|
65
65
|
) => void | Promise<void>;
|
|
66
66
|
portalContainer?: HTMLElement | DocumentFragment | null;
|
|
67
|
-
onWidgetVisible?: (triggerRuleType: WidgetTriggerRuleType | null) => void;
|
|
68
|
-
onLinkClick?: (linkUrl: string) => void;
|
|
69
67
|
onLauncherClick?: () => void;
|
|
70
68
|
};
|
|
71
69
|
|
|
@@ -88,6 +86,7 @@ export function WidgetRuntime({
|
|
|
88
86
|
widgetTitle,
|
|
89
87
|
welcomeMessage,
|
|
90
88
|
spotlights,
|
|
89
|
+
ctaLink,
|
|
91
90
|
open,
|
|
92
91
|
status,
|
|
93
92
|
messages,
|
|
@@ -100,21 +99,10 @@ export function WidgetRuntime({
|
|
|
100
99
|
onStopConversation,
|
|
101
100
|
onConfirmationDecision,
|
|
102
101
|
portalContainer,
|
|
103
|
-
onWidgetVisible,
|
|
104
|
-
onLinkClick,
|
|
105
102
|
onLauncherClick,
|
|
106
103
|
}: WidgetRuntimeProps) {
|
|
107
104
|
const { ready } = useWidgetTriggerGate(triggerRule);
|
|
108
105
|
const { horizontal, vertical } = splitWidgetPlacement(placement);
|
|
109
|
-
const viewTrackedRef = React.useRef(false);
|
|
110
|
-
|
|
111
|
-
React.useEffect(() => {
|
|
112
|
-
if (mode !== "embed" || !ready || viewTrackedRef.current) {
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
viewTrackedRef.current = true;
|
|
116
|
-
onWidgetVisible?.(triggerRule?.triggerRuleType ?? null);
|
|
117
|
-
}, [mode, onWidgetVisible, ready, triggerRule]);
|
|
118
106
|
|
|
119
107
|
const { panelMessages, confirmation } = React.useMemo(
|
|
120
108
|
() => splitConfirmationFromMessages(messages, onConfirmationDecision),
|
|
@@ -141,12 +129,14 @@ export function WidgetRuntime({
|
|
|
141
129
|
appearance: toChatAppearance(appearance),
|
|
142
130
|
welcomeMessage,
|
|
143
131
|
spotlights,
|
|
132
|
+
ctaLink,
|
|
144
133
|
actionLabel: appearance.actionText || undefined,
|
|
145
134
|
showStopConversation: Boolean(onStopConversation),
|
|
146
135
|
}),
|
|
147
136
|
[
|
|
148
137
|
agentAvatarUrl,
|
|
149
138
|
appearance,
|
|
139
|
+
ctaLink,
|
|
150
140
|
onStopConversation,
|
|
151
141
|
placement,
|
|
152
142
|
preset,
|
|
@@ -219,7 +209,6 @@ export function WidgetRuntime({
|
|
|
219
209
|
onStopConversation ? handleStop : undefined
|
|
220
210
|
}
|
|
221
211
|
onLauncherClick={onLauncherClick}
|
|
222
|
-
onLinkClick={onLinkClick}
|
|
223
212
|
emptyAction={wrappedEmptyAction}
|
|
224
213
|
confirmation={confirmation}
|
|
225
214
|
portalContainer={portalEl}
|
package/src/runtime/api.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
WidgetAnalyticsBatchRequest,
|
|
3
2
|
WidgetConversation,
|
|
4
3
|
WidgetMessage,
|
|
5
4
|
WidgetPublicChatSendChatResponse,
|
|
@@ -91,33 +90,6 @@ export async function sendPublicChatConfirmation(input: {
|
|
|
91
90
|
);
|
|
92
91
|
}
|
|
93
92
|
|
|
94
|
-
export async function sendWidgetAnalyticsBatch(
|
|
95
|
-
input: WidgetAnalyticsBatchRequest,
|
|
96
|
-
): Promise<void> {
|
|
97
|
-
const response = await fetch(
|
|
98
|
-
new URL("/api/widget/events/batch", `${getWidgetApiBase()}/`).toString(),
|
|
99
|
-
{
|
|
100
|
-
method: "POST",
|
|
101
|
-
headers: input.sessionToken
|
|
102
|
-
? { Authorization: `Bearer ${input.sessionToken}` }
|
|
103
|
-
: undefined,
|
|
104
|
-
body: JSON.stringify(input),
|
|
105
|
-
credentials: "omit",
|
|
106
|
-
keepalive: true,
|
|
107
|
-
},
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
if (!response.ok) {
|
|
111
|
-
const text = await response.text();
|
|
112
|
-
const payload = text ? JSON.parse(text) : null;
|
|
113
|
-
throw new Error(
|
|
114
|
-
typeof payload === "object" && payload && "error" in payload
|
|
115
|
-
? String((payload as { error?: string }).error ?? response.statusText)
|
|
116
|
-
: response.statusText,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
93
|
export async function getCurrentConversation(sessionToken: string): Promise<{
|
|
122
94
|
conversation: WidgetConversation | null;
|
|
123
95
|
}> {
|
package/src/types.ts
CHANGED
|
@@ -25,14 +25,6 @@ export {
|
|
|
25
25
|
widgetAppearanceToAttributes,
|
|
26
26
|
type WidgetTriggerRule,
|
|
27
27
|
} from "./shared/widget-config";
|
|
28
|
-
export type {
|
|
29
|
-
WidgetAnalyticsBatchEvent,
|
|
30
|
-
WidgetAnalyticsBatchRequest,
|
|
31
|
-
WidgetAnalyticsBatchResponse,
|
|
32
|
-
WidgetAnalyticsEventType,
|
|
33
|
-
WidgetClientAnalyticsEventType,
|
|
34
|
-
WidgetTriggerRuleType,
|
|
35
|
-
} from "./shared/analytics";
|
|
36
28
|
|
|
37
29
|
export type WidgetSessionConfig = {
|
|
38
30
|
sessionToken: string;
|
|
@@ -43,6 +35,7 @@ export type WidgetSessionConfig = {
|
|
|
43
35
|
agentAvatarUrl: string | null;
|
|
44
36
|
welcomeMessage: string | null;
|
|
45
37
|
spotlightMessages: string[];
|
|
38
|
+
ctaLink: string | null;
|
|
46
39
|
widgetAppearance: WidgetAppearance;
|
|
47
40
|
widgetBehavior: WidgetBehavior;
|
|
48
41
|
widgetVariant: WidgetVariant;
|
package/src/runtime/analytics.ts
DELETED
|
@@ -1,431 +0,0 @@
|
|
|
1
|
-
import type { WidgetSessionState } from "../types";
|
|
2
|
-
import type {
|
|
3
|
-
WidgetAnalyticsBatchEvent,
|
|
4
|
-
WidgetAnalyticsBatchRequest,
|
|
5
|
-
WidgetClientAnalyticsEventType,
|
|
6
|
-
WidgetTriggerRuleType,
|
|
7
|
-
} from "../shared/analytics";
|
|
8
|
-
import { getWidgetApiBase } from "./api-origin";
|
|
9
|
-
|
|
10
|
-
const ANALYTICS_ENABLED = false;
|
|
11
|
-
|
|
12
|
-
const ANALYTICS_ENDPOINT = "/api/widget/events/batch";
|
|
13
|
-
const DEFAULT_BATCH_SIZE = 10;
|
|
14
|
-
const DEFAULT_FLUSH_INTERVAL_MS = 2500;
|
|
15
|
-
const MAX_FLUSH_INTERVAL_MS = 120_000;
|
|
16
|
-
const MAX_QUEUE_SIZE = 200;
|
|
17
|
-
const MAX_EVENT_ATTEMPTS = 5;
|
|
18
|
-
const MAX_EVENT_AGE_MS = 10 * 60 * 1000;
|
|
19
|
-
const UNIQUE_SCRIPT_LOADED_KEY_PREFIX =
|
|
20
|
-
"usereq-widget:unique-script-loaded:";
|
|
21
|
-
|
|
22
|
-
type QueuedAnalyticsEvent = {
|
|
23
|
-
event: WidgetAnalyticsBatchEvent;
|
|
24
|
-
sessionToken: string;
|
|
25
|
-
attempts: number;
|
|
26
|
-
enqueuedAt: number;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
type SendOutcome =
|
|
30
|
-
| { outcome: "ok" }
|
|
31
|
-
| { outcome: "drop" }
|
|
32
|
-
| { outcome: "retry"; retryAfterMs?: number };
|
|
33
|
-
|
|
34
|
-
function parseRetryAfter(headerValue: string | null): number | undefined {
|
|
35
|
-
if (!headerValue) return undefined;
|
|
36
|
-
const trimmed = headerValue.trim();
|
|
37
|
-
if (!trimmed) return undefined;
|
|
38
|
-
const seconds = Number(trimmed);
|
|
39
|
-
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
40
|
-
return Math.min(MAX_FLUSH_INTERVAL_MS, seconds * 1000);
|
|
41
|
-
}
|
|
42
|
-
const dateMs = Date.parse(trimmed);
|
|
43
|
-
if (!Number.isNaN(dateMs)) {
|
|
44
|
-
const delta = dateMs - Date.now();
|
|
45
|
-
if (delta > 0) return Math.min(MAX_FLUSH_INTERVAL_MS, delta);
|
|
46
|
-
}
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function classifyResponse(response: Response): SendOutcome {
|
|
51
|
-
if (response.ok) return { outcome: "ok" };
|
|
52
|
-
const status = response.status;
|
|
53
|
-
if (status === 408 || status === 425 || status === 429 || status >= 500) {
|
|
54
|
-
return {
|
|
55
|
-
outcome: "retry",
|
|
56
|
-
retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
if (status >= 400 && status < 500) return { outcome: "drop" };
|
|
60
|
-
return { outcome: "retry" };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export type WidgetAnalyticsTrackerOptions = {
|
|
64
|
-
mode: "embed" | "preview";
|
|
65
|
-
agentId: string;
|
|
66
|
-
getSession: () => WidgetSessionState | null;
|
|
67
|
-
getConversationId: () => string | undefined;
|
|
68
|
-
batchSize?: number;
|
|
69
|
-
flushIntervalMs?: number;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
export class WidgetAnalyticsTracker {
|
|
73
|
-
private readonly mode: "embed" | "preview";
|
|
74
|
-
private readonly agentId: string;
|
|
75
|
-
private readonly getSession: () => WidgetSessionState | null;
|
|
76
|
-
private readonly getConversationId: () => string | undefined;
|
|
77
|
-
private readonly batchSize: number;
|
|
78
|
-
private readonly flushIntervalMs: number;
|
|
79
|
-
private readonly uniqueScriptSeen = new Set<string>();
|
|
80
|
-
private flushTimer: number | null = null;
|
|
81
|
-
private queue: QueuedAnalyticsEvent[] = [];
|
|
82
|
-
private disposed = false;
|
|
83
|
-
private consecutiveFailures = 0;
|
|
84
|
-
private nextAllowedFlushAt = 0;
|
|
85
|
-
|
|
86
|
-
constructor(options: WidgetAnalyticsTrackerOptions) {
|
|
87
|
-
this.mode = options.mode;
|
|
88
|
-
this.agentId = options.agentId;
|
|
89
|
-
this.getSession = options.getSession;
|
|
90
|
-
this.getConversationId = options.getConversationId;
|
|
91
|
-
this.batchSize = Math.max(1, options.batchSize ?? DEFAULT_BATCH_SIZE);
|
|
92
|
-
this.flushIntervalMs = Math.max(
|
|
93
|
-
200,
|
|
94
|
-
options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
if (ANALYTICS_ENABLED && this.mode === "embed") {
|
|
98
|
-
window.addEventListener("pagehide", this.handlePageHide, {
|
|
99
|
-
capture: true,
|
|
100
|
-
});
|
|
101
|
-
document.addEventListener(
|
|
102
|
-
"visibilitychange",
|
|
103
|
-
this.handleVisibilityChange,
|
|
104
|
-
true,
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
dispose() {
|
|
110
|
-
if (this.disposed) return;
|
|
111
|
-
this.disposed = true;
|
|
112
|
-
this.clearFlushTimer();
|
|
113
|
-
if (!ANALYTICS_ENABLED) return;
|
|
114
|
-
window.removeEventListener("pagehide", this.handlePageHide, true);
|
|
115
|
-
document.removeEventListener(
|
|
116
|
-
"visibilitychange",
|
|
117
|
-
this.handleVisibilityChange,
|
|
118
|
-
true,
|
|
119
|
-
);
|
|
120
|
-
void this.flush({ transport: "beacon" });
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
trackScriptLoaded() {
|
|
124
|
-
this.track({ eventType: "SCRIPT_LOADED" });
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
trackUniqueScriptLoaded() {
|
|
128
|
-
if (!ANALYTICS_ENABLED) return;
|
|
129
|
-
const sessionToken = this.getSession()?.sessionToken?.trim();
|
|
130
|
-
if (!sessionToken) return;
|
|
131
|
-
const uniqueKey = `${this.agentId}:${sessionToken}`;
|
|
132
|
-
|
|
133
|
-
if (this.uniqueScriptSeen.has(uniqueKey)) return;
|
|
134
|
-
if (this.hasSessionUniqueScriptLoaded(uniqueKey)) {
|
|
135
|
-
this.uniqueScriptSeen.add(uniqueKey);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
this.uniqueScriptSeen.add(uniqueKey);
|
|
140
|
-
this.setSessionUniqueScriptLoaded(uniqueKey);
|
|
141
|
-
this.track({ eventType: "UNIQUE_SCRIPT_LOADED" });
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
trackView(triggerRuleType: WidgetTriggerRuleType | null) {
|
|
145
|
-
this.track({ eventType: "VIEW", triggerRuleType });
|
|
146
|
-
if (triggerRuleType === "element_displayed") {
|
|
147
|
-
this.track({
|
|
148
|
-
eventType: "DISPLAY_AFTER_ELEMENT_SHOWED",
|
|
149
|
-
triggerRuleType,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
trackClick() {
|
|
155
|
-
this.track({ eventType: "CLICK" });
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
trackLinkClicked(linkUrl: string) {
|
|
159
|
-
const raw = linkUrl.trim();
|
|
160
|
-
if (!raw) return;
|
|
161
|
-
const resolvedUrl = this.normalizeLinkUrl(raw);
|
|
162
|
-
this.track({ eventType: "LINK_CLICKED", linkUrl: resolvedUrl });
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
track(event: Omit<WidgetAnalyticsBatchEvent, "occurredAt"> & {
|
|
166
|
-
occurredAt?: string;
|
|
167
|
-
}) {
|
|
168
|
-
if (!ANALYTICS_ENABLED) return;
|
|
169
|
-
if (this.disposed || this.mode !== "embed") return;
|
|
170
|
-
|
|
171
|
-
const sessionToken = this.getSession()?.sessionToken?.trim();
|
|
172
|
-
if (!sessionToken) return;
|
|
173
|
-
|
|
174
|
-
const { occurredAt, conversationId, triggerRuleType, linkUrl, ...rest } = event;
|
|
175
|
-
const resolvedConversationId = conversationId ?? this.getConversationId();
|
|
176
|
-
const queuedEvent: WidgetAnalyticsBatchEvent = {
|
|
177
|
-
...rest,
|
|
178
|
-
occurredAt: occurredAt ?? new Date().toISOString(),
|
|
179
|
-
...(resolvedConversationId ? { conversationId: resolvedConversationId } : {}),
|
|
180
|
-
...(triggerRuleType ? { triggerRuleType } : {}),
|
|
181
|
-
...(linkUrl ? { linkUrl } : {}),
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
this.enqueue({
|
|
185
|
-
event: queuedEvent,
|
|
186
|
-
sessionToken,
|
|
187
|
-
attempts: 0,
|
|
188
|
-
enqueuedAt: Date.now(),
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
if (this.queue.length >= this.batchSize) {
|
|
192
|
-
void this.flush();
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
this.scheduleFlush();
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
private enqueue(entry: QueuedAnalyticsEvent) {
|
|
200
|
-
this.queue.push(entry);
|
|
201
|
-
if (this.queue.length > MAX_QUEUE_SIZE) {
|
|
202
|
-
this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
private pruneStale(now: number) {
|
|
207
|
-
if (this.queue.length === 0) return;
|
|
208
|
-
this.queue = this.queue.filter(
|
|
209
|
-
(entry) => now - entry.enqueuedAt < MAX_EVENT_AGE_MS,
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async flush(options?: {
|
|
214
|
-
transport?: "default" | "beacon";
|
|
215
|
-
}): Promise<void> {
|
|
216
|
-
if (!ANALYTICS_ENABLED) return;
|
|
217
|
-
if (this.disposed) return;
|
|
218
|
-
|
|
219
|
-
const now = Date.now();
|
|
220
|
-
this.pruneStale(now);
|
|
221
|
-
if (this.queue.length === 0) {
|
|
222
|
-
this.clearFlushTimer();
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
this.clearFlushTimer();
|
|
227
|
-
const transport = options?.transport ?? "default";
|
|
228
|
-
const snapshot = this.queue;
|
|
229
|
-
this.queue = [];
|
|
230
|
-
|
|
231
|
-
const grouped = new Map<string, QueuedAnalyticsEvent[]>();
|
|
232
|
-
for (const entry of snapshot) {
|
|
233
|
-
const group = grouped.get(entry.sessionToken);
|
|
234
|
-
if (group) {
|
|
235
|
-
group.push(entry);
|
|
236
|
-
} else {
|
|
237
|
-
grouped.set(entry.sessionToken, [entry]);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const failed: QueuedAnalyticsEvent[] = [];
|
|
242
|
-
let maxRetryAfterMs = 0;
|
|
243
|
-
let anyRetry = false;
|
|
244
|
-
let anySuccess = false;
|
|
245
|
-
|
|
246
|
-
for (const [sessionToken, entries] of grouped.entries()) {
|
|
247
|
-
const result = await this.sendBatch({
|
|
248
|
-
events: entries.map((entry) => entry.event),
|
|
249
|
-
sessionToken,
|
|
250
|
-
transport,
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
if (result.outcome === "ok") {
|
|
254
|
-
anySuccess = true;
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
if (result.outcome === "drop") {
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
anyRetry = true;
|
|
261
|
-
if (result.retryAfterMs && result.retryAfterMs > maxRetryAfterMs) {
|
|
262
|
-
maxRetryAfterMs = result.retryAfterMs;
|
|
263
|
-
}
|
|
264
|
-
for (const entry of entries) {
|
|
265
|
-
const attempts = entry.attempts + 1;
|
|
266
|
-
if (attempts >= MAX_EVENT_ATTEMPTS) continue;
|
|
267
|
-
failed.push({ ...entry, attempts });
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (anySuccess && !anyRetry) {
|
|
272
|
-
this.consecutiveFailures = 0;
|
|
273
|
-
this.nextAllowedFlushAt = 0;
|
|
274
|
-
} else if (anyRetry) {
|
|
275
|
-
this.consecutiveFailures += 1;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (failed.length > 0) {
|
|
279
|
-
for (const entry of failed) {
|
|
280
|
-
this.enqueue(entry);
|
|
281
|
-
}
|
|
282
|
-
const backoff = this.computeBackoffDelay();
|
|
283
|
-
const delay = Math.max(backoff, maxRetryAfterMs);
|
|
284
|
-
this.nextAllowedFlushAt = Date.now() + delay;
|
|
285
|
-
this.scheduleFlush(delay);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
private computeBackoffDelay(): number {
|
|
290
|
-
const exponent = Math.max(0, this.consecutiveFailures - 1);
|
|
291
|
-
const base = this.flushIntervalMs * Math.pow(2, exponent);
|
|
292
|
-
const jitter = base * 0.2 * Math.random();
|
|
293
|
-
return Math.min(MAX_FLUSH_INTERVAL_MS, base + jitter);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
private async sendBatch(input: {
|
|
297
|
-
events: WidgetAnalyticsBatchEvent[];
|
|
298
|
-
sessionToken: string;
|
|
299
|
-
transport: "default" | "beacon";
|
|
300
|
-
}): Promise<SendOutcome> {
|
|
301
|
-
if (input.events.length === 0) return { outcome: "ok" };
|
|
302
|
-
|
|
303
|
-
const url = new URL(ANALYTICS_ENDPOINT, `${getWidgetApiBase()}/`).toString();
|
|
304
|
-
const sanitizedEvents = input.events.map((event) => {
|
|
305
|
-
const { conversationId, triggerRuleType, linkUrl, ...rest } = event;
|
|
306
|
-
return {
|
|
307
|
-
...rest,
|
|
308
|
-
...(conversationId ? { conversationId } : {}),
|
|
309
|
-
...(triggerRuleType ? { triggerRuleType } : {}),
|
|
310
|
-
...(linkUrl ? { linkUrl } : {}),
|
|
311
|
-
};
|
|
312
|
-
});
|
|
313
|
-
const payload: WidgetAnalyticsBatchRequest = {
|
|
314
|
-
events: sanitizedEvents,
|
|
315
|
-
pageUrl: window.location.href,
|
|
316
|
-
referrer: document.referrer || undefined,
|
|
317
|
-
// sendBeacon cannot set Authorization header, so include token in body as fallback.
|
|
318
|
-
sessionToken: input.sessionToken,
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
if (input.transport === "beacon" && typeof navigator.sendBeacon === "function") {
|
|
322
|
-
const body = new Blob([JSON.stringify(payload)], {
|
|
323
|
-
type: "application/json",
|
|
324
|
-
});
|
|
325
|
-
if (navigator.sendBeacon(url, body)) {
|
|
326
|
-
return { outcome: "ok" };
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
try {
|
|
331
|
-
const response = await fetch(url, {
|
|
332
|
-
method: "POST",
|
|
333
|
-
headers: {
|
|
334
|
-
Authorization: `Bearer ${input.sessionToken}`,
|
|
335
|
-
"Content-Type": "application/json",
|
|
336
|
-
},
|
|
337
|
-
body: JSON.stringify(payload),
|
|
338
|
-
credentials: "omit",
|
|
339
|
-
keepalive: input.transport === "beacon",
|
|
340
|
-
});
|
|
341
|
-
return classifyResponse(response);
|
|
342
|
-
} catch {
|
|
343
|
-
return { outcome: "retry" };
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
private scheduleFlush(delayMs?: number) {
|
|
348
|
-
if (this.flushTimer != null) return;
|
|
349
|
-
const delay = Math.max(0, delayMs ?? this.flushIntervalMs);
|
|
350
|
-
this.flushTimer = window.setTimeout(() => {
|
|
351
|
-
this.flushTimer = null;
|
|
352
|
-
void this.flush();
|
|
353
|
-
}, delay);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
private clearFlushTimer() {
|
|
357
|
-
if (this.flushTimer == null) return;
|
|
358
|
-
window.clearTimeout(this.flushTimer);
|
|
359
|
-
this.flushTimer = null;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
private hasSessionUniqueScriptLoaded(uniqueKey: string): boolean {
|
|
363
|
-
try {
|
|
364
|
-
return (
|
|
365
|
-
sessionStorage.getItem(`${UNIQUE_SCRIPT_LOADED_KEY_PREFIX}${uniqueKey}`) ===
|
|
366
|
-
"1"
|
|
367
|
-
);
|
|
368
|
-
} catch {
|
|
369
|
-
return false;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
private setSessionUniqueScriptLoaded(uniqueKey: string) {
|
|
374
|
-
try {
|
|
375
|
-
sessionStorage.setItem(
|
|
376
|
-
`${UNIQUE_SCRIPT_LOADED_KEY_PREFIX}${uniqueKey}`,
|
|
377
|
-
"1",
|
|
378
|
-
);
|
|
379
|
-
} catch {
|
|
380
|
-
// no-op
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
private readonly handlePageHide = () => {
|
|
385
|
-
void this.flush({ transport: "beacon" });
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
private readonly handleVisibilityChange = () => {
|
|
389
|
-
if (document.visibilityState === "hidden") {
|
|
390
|
-
void this.flush({ transport: "beacon" });
|
|
391
|
-
}
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
private normalizeLinkUrl(value: string): string {
|
|
395
|
-
try {
|
|
396
|
-
return new URL(value, window.location.href).toString();
|
|
397
|
-
} catch {
|
|
398
|
-
return value;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
export function createWidgetAnalyticsTracker(
|
|
404
|
-
options: WidgetAnalyticsTrackerOptions,
|
|
405
|
-
): WidgetAnalyticsTracker {
|
|
406
|
-
return new WidgetAnalyticsTracker(options);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
export function isWidgetTriggerRuleType(
|
|
410
|
-
value: string | null | undefined,
|
|
411
|
-
): value is WidgetTriggerRuleType {
|
|
412
|
-
return (
|
|
413
|
-
value === "time_delay" ||
|
|
414
|
-
value === "scroll_to_bottom" ||
|
|
415
|
-
value === "element_clicked" ||
|
|
416
|
-
value === "element_displayed"
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
export function isWidgetClientAnalyticsEventType(
|
|
421
|
-
value: string | null | undefined,
|
|
422
|
-
): value is WidgetClientAnalyticsEventType {
|
|
423
|
-
return (
|
|
424
|
-
value === "SCRIPT_LOADED" ||
|
|
425
|
-
value === "UNIQUE_SCRIPT_LOADED" ||
|
|
426
|
-
value === "VIEW" ||
|
|
427
|
-
value === "CLICK" ||
|
|
428
|
-
value === "DISPLAY_AFTER_ELEMENT_SHOWED" ||
|
|
429
|
-
value === "LINK_CLICKED"
|
|
430
|
-
);
|
|
431
|
-
}
|