@usereq/widget 0.2.3 → 0.2.4
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 +300 -300
- package/package.json +1 -1
- package/src/custom-element/agent-widget-element.tsx +0 -40
- package/src/index.ts +0 -1
- package/src/renderer/widget-runtime.tsx +0 -15
- package/src/runtime/api.ts +0 -28
- package/src/types.ts +0 -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
|
}
|
|
@@ -217,22 +205,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
217
205
|
this.status = "idle";
|
|
218
206
|
}
|
|
219
207
|
|
|
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
208
|
private renderWidget() {
|
|
237
209
|
if (
|
|
238
210
|
this.bootstrapPhase !== "ready" ||
|
|
@@ -305,10 +277,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
305
277
|
onSendMessage: (content: string) => void this.sendMessage(content),
|
|
306
278
|
onConfirmationDecision: (confirmationId: string, accepted: boolean) =>
|
|
307
279
|
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
280
|
portalContainer: this.shadowRootRef,
|
|
313
281
|
}),
|
|
314
282
|
);
|
|
@@ -355,10 +323,6 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
355
323
|
this.welcomeMessage = state.session.widgetConfig.welcomeMessage;
|
|
356
324
|
this.status = "ready";
|
|
357
325
|
this.bootstrapPhase = "ready";
|
|
358
|
-
if (this.mode === "embed") {
|
|
359
|
-
this.analyticsTracker?.trackScriptLoaded();
|
|
360
|
-
this.analyticsTracker?.trackUniqueScriptLoaded();
|
|
361
|
-
}
|
|
362
326
|
this.renderWidget();
|
|
363
327
|
} catch (error) {
|
|
364
328
|
if (runVersion !== this.bootstrapVersion) {
|
|
@@ -461,12 +425,8 @@ export class AgentWidgetElement extends HTMLElement {
|
|
|
461
425
|
}
|
|
462
426
|
|
|
463
427
|
private async handleOpenChange(nextOpen: boolean) {
|
|
464
|
-
const wasOpen = this.open;
|
|
465
428
|
this.open = nextOpen;
|
|
466
429
|
this.renderWidget();
|
|
467
|
-
if (!wasOpen && nextOpen && this.mode === "embed") {
|
|
468
|
-
this.analyticsTracker?.trackClick();
|
|
469
|
-
}
|
|
470
430
|
|
|
471
431
|
if (this.open && (this.status === "idle" || !this.sessionState)) {
|
|
472
432
|
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
|
|
|
@@ -64,8 +63,6 @@ export type WidgetRuntimeProps = {
|
|
|
64
63
|
accepted: boolean,
|
|
65
64
|
) => void | Promise<void>;
|
|
66
65
|
portalContainer?: HTMLElement | DocumentFragment | null;
|
|
67
|
-
onWidgetVisible?: (triggerRuleType: WidgetTriggerRuleType | null) => void;
|
|
68
|
-
onLinkClick?: (linkUrl: string) => void;
|
|
69
66
|
onLauncherClick?: () => void;
|
|
70
67
|
};
|
|
71
68
|
|
|
@@ -100,21 +97,10 @@ export function WidgetRuntime({
|
|
|
100
97
|
onStopConversation,
|
|
101
98
|
onConfirmationDecision,
|
|
102
99
|
portalContainer,
|
|
103
|
-
onWidgetVisible,
|
|
104
|
-
onLinkClick,
|
|
105
100
|
onLauncherClick,
|
|
106
101
|
}: WidgetRuntimeProps) {
|
|
107
102
|
const { ready } = useWidgetTriggerGate(triggerRule);
|
|
108
103
|
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
104
|
|
|
119
105
|
const { panelMessages, confirmation } = React.useMemo(
|
|
120
106
|
() => splitConfirmationFromMessages(messages, onConfirmationDecision),
|
|
@@ -219,7 +205,6 @@ export function WidgetRuntime({
|
|
|
219
205
|
onStopConversation ? handleStop : undefined
|
|
220
206
|
}
|
|
221
207
|
onLauncherClick={onLauncherClick}
|
|
222
|
-
onLinkClick={onLinkClick}
|
|
223
208
|
emptyAction={wrappedEmptyAction}
|
|
224
209
|
confirmation={confirmation}
|
|
225
210
|
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;
|
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
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export function scheduleRenewal(
|
|
2
|
-
expiresAt: string,
|
|
3
|
-
onRenew: () => Promise<void> | void
|
|
4
|
-
): () => void {
|
|
5
|
-
const expiryMs = new Date(expiresAt).getTime();
|
|
6
|
-
const delay = Math.max(5_000, expiryMs - Date.now() - 60_000);
|
|
7
|
-
const timer = window.setTimeout(() => {
|
|
8
|
-
void onRenew();
|
|
9
|
-
}, delay);
|
|
10
|
-
|
|
11
|
-
return () => window.clearTimeout(timer);
|
|
12
|
-
}
|
|
13
|
-
|