@usereq/widget 0.1.3 → 0.1.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 +339 -339
- package/package.json +1 -1
- package/src/chat-widget/styles/chat-widget-messenger.tsx +225 -49
- package/src/runtime/analytics.ts +119 -21
package/package.json
CHANGED
|
@@ -10,25 +10,46 @@ import {
|
|
|
10
10
|
} from "@usereq/ui/components/avatar";
|
|
11
11
|
import { Button } from "@usereq/ui/components/button";
|
|
12
12
|
import { Textarea } from "@usereq/ui/components/textarea";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ChevronDown,
|
|
15
|
+
SendHorizonal,
|
|
16
|
+
Phone,
|
|
17
|
+
Video,
|
|
18
|
+
Info,
|
|
19
|
+
} from "lucide-react";
|
|
14
20
|
import {
|
|
15
21
|
DEFAULT_AGENT_NAME_BUBBLE,
|
|
16
|
-
DEFAULT_COMPOSER_PLACEHOLDER,
|
|
17
22
|
DEFAULT_POWERED_BY,
|
|
18
23
|
DEFAULT_SPOTLIGHT_MESSAGES,
|
|
19
24
|
} from "../chat-widget-defaults";
|
|
20
25
|
import {
|
|
21
|
-
ChatWidgetMessages,
|
|
22
26
|
ChatWidgetPanel,
|
|
23
27
|
ChatWidgetPoweredBy,
|
|
28
|
+
ChatWidgetTyping,
|
|
24
29
|
useIsChatWidgetFullscreenViewport,
|
|
25
30
|
useSpotlightFade,
|
|
26
31
|
type ChatWidgetDataProps,
|
|
32
|
+
type ChatWidgetPreviewMessage,
|
|
27
33
|
} from "../components/chat-widget-primitives";
|
|
34
|
+
import {
|
|
35
|
+
Conversation,
|
|
36
|
+
ConversationContent,
|
|
37
|
+
ConversationEmptyState,
|
|
38
|
+
ConversationScrollButton,
|
|
39
|
+
} from "../components/conversation";
|
|
40
|
+
import { foldStopConfirmationMessages } from "../stop-confirmation";
|
|
41
|
+
import { getChatWidgetMessagesLayoutClasses } from "../components/chat-widget-layout";
|
|
42
|
+
import {
|
|
43
|
+
Message,
|
|
44
|
+
MessageContent,
|
|
45
|
+
MessageResponse,
|
|
46
|
+
} from "../components/message";
|
|
28
47
|
|
|
29
48
|
const MESSENGER_GRADIENT =
|
|
30
49
|
"linear-gradient(135deg, #00B2FF 0%, #006AFF 50%, #8E5BFF 100%)";
|
|
31
50
|
|
|
51
|
+
const MESSENGER_BRAND_COLOR = "#0084FF";
|
|
52
|
+
|
|
32
53
|
const PANEL_WIDTH = 372;
|
|
33
54
|
const PANEL_HEIGHT = 580;
|
|
34
55
|
const LAUNCHER_SIZE = 60;
|
|
@@ -40,7 +61,6 @@ const fadeTransition = { duration: 0.35 };
|
|
|
40
61
|
export function ChatWidgetMessenger({
|
|
41
62
|
agentName = DEFAULT_AGENT_NAME_BUBBLE,
|
|
42
63
|
agentAvatarUrl,
|
|
43
|
-
appearance,
|
|
44
64
|
spotlightMessages = [],
|
|
45
65
|
messages = [],
|
|
46
66
|
poweredBy = DEFAULT_POWERED_BY,
|
|
@@ -50,7 +70,6 @@ export function ChatWidgetMessenger({
|
|
|
50
70
|
sendDisabled = false,
|
|
51
71
|
onSendMessage,
|
|
52
72
|
isTyping,
|
|
53
|
-
onSuggestion,
|
|
54
73
|
onLauncherClick,
|
|
55
74
|
className,
|
|
56
75
|
open,
|
|
@@ -120,7 +139,7 @@ export function ChatWidgetMessenger({
|
|
|
120
139
|
transition={fadeTransition}
|
|
121
140
|
className="absolute right-0 bottom-[calc(60px+10px)] flex w-full max-w-70 flex-col items-end"
|
|
122
141
|
>
|
|
123
|
-
<div className="rounded-3xl bg-
|
|
142
|
+
<div className="rounded-3xl bg-background px-4 py-2.5 text-right shadow-[0_4px_16px_rgba(0,0,0,0.12)]">
|
|
124
143
|
<p className="text-[14px] leading-snug text-foreground">
|
|
125
144
|
{spotlightText}
|
|
126
145
|
</p>
|
|
@@ -134,7 +153,7 @@ export function ChatWidgetMessenger({
|
|
|
134
153
|
open={effectiveOpen}
|
|
135
154
|
onOpenChange={handleOpenChange}
|
|
136
155
|
portalContainer={portalContainer}
|
|
137
|
-
desktopClassName="absolute right-0 overflow-hidden rounded-3xl bg-background shadow-[0_8px_32px_rgba(0,0,0,0.16)] border border-
|
|
156
|
+
desktopClassName="absolute right-0 overflow-hidden rounded-3xl bg-background shadow-[0_8px_32px_rgba(0,0,0,0.16)] border border-border"
|
|
138
157
|
desktopStyle={{
|
|
139
158
|
width: PANEL_WIDTH,
|
|
140
159
|
height: PANEL_HEIGHT,
|
|
@@ -149,20 +168,16 @@ export function ChatWidgetMessenger({
|
|
|
149
168
|
onStopConversation={onStopConversation ?? undefined}
|
|
150
169
|
stopDisabled={stopDisabled}
|
|
151
170
|
/>
|
|
152
|
-
<div className="flex min-h-0 flex-1 flex-col
|
|
153
|
-
<
|
|
171
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
|
|
172
|
+
<MessengerMessages
|
|
154
173
|
messages={messages}
|
|
155
174
|
isTyping={isTyping ?? status === "sending"}
|
|
156
175
|
status={status}
|
|
157
|
-
suggestions={spotlightForFade}
|
|
158
176
|
errorMessage={errorMessage}
|
|
159
|
-
|
|
177
|
+
agentName={agentName}
|
|
178
|
+
agentAvatarUrl={agentAvatarUrl}
|
|
160
179
|
onConfirmationDecision={onConfirmationDecision}
|
|
161
180
|
onLinkClick={onLinkClick}
|
|
162
|
-
onSuggestion={(value: string) => {
|
|
163
|
-
setDraftValue(value);
|
|
164
|
-
onSuggestion?.(value);
|
|
165
|
-
}}
|
|
166
181
|
emptyAction={emptyAction}
|
|
167
182
|
/>
|
|
168
183
|
</div>
|
|
@@ -199,7 +214,7 @@ export function ChatWidgetMessenger({
|
|
|
199
214
|
style={{
|
|
200
215
|
width: LAUNCHER_SIZE,
|
|
201
216
|
height: LAUNCHER_SIZE,
|
|
202
|
-
|
|
217
|
+
backgroundColor: MESSENGER_BRAND_COLOR,
|
|
203
218
|
boxShadow: "0 6px 20px rgba(0, 132, 255, 0.45)",
|
|
204
219
|
}}
|
|
205
220
|
aria-label={effectiveOpen ? "Close chat" : "Open chat"}
|
|
@@ -209,7 +224,7 @@ export function ChatWidgetMessenger({
|
|
|
209
224
|
whileTap={previewOnly ? undefined : { scale: 0.96 }}
|
|
210
225
|
>
|
|
211
226
|
{effectiveOpen ? (
|
|
212
|
-
<
|
|
227
|
+
<ChevronDown className="size-7" strokeWidth={2.4} />
|
|
213
228
|
) : (
|
|
214
229
|
<MessengerLogo className="size-8" />
|
|
215
230
|
)}
|
|
@@ -219,6 +234,169 @@ export function ChatWidgetMessenger({
|
|
|
219
234
|
);
|
|
220
235
|
}
|
|
221
236
|
|
|
237
|
+
function MessengerMessages({
|
|
238
|
+
messages,
|
|
239
|
+
isTyping,
|
|
240
|
+
status,
|
|
241
|
+
errorMessage,
|
|
242
|
+
agentName,
|
|
243
|
+
agentAvatarUrl,
|
|
244
|
+
onConfirmationDecision,
|
|
245
|
+
onLinkClick,
|
|
246
|
+
emptyAction,
|
|
247
|
+
}: {
|
|
248
|
+
messages: ChatWidgetPreviewMessage[];
|
|
249
|
+
isTyping: boolean;
|
|
250
|
+
status: ChatWidgetDataProps["status"];
|
|
251
|
+
errorMessage?: string | null;
|
|
252
|
+
agentName: string;
|
|
253
|
+
agentAvatarUrl?: string | null;
|
|
254
|
+
onConfirmationDecision?: ChatWidgetDataProps["onConfirmationDecision"];
|
|
255
|
+
onLinkClick?: (linkUrl: string) => void;
|
|
256
|
+
emptyAction?: { label: string; onClick: () => void; disabled?: boolean };
|
|
257
|
+
}) {
|
|
258
|
+
const { scrollClassName, contentClassName } =
|
|
259
|
+
getChatWidgetMessagesLayoutClasses();
|
|
260
|
+
const renderedMessages = React.useMemo(
|
|
261
|
+
() => foldStopConfirmationMessages(messages),
|
|
262
|
+
[messages],
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
if (messages.length === 0 && !isTyping) {
|
|
266
|
+
if (status === "error") {
|
|
267
|
+
return (
|
|
268
|
+
<div className="flex h-full items-center justify-center px-6 py-8 text-center text-sm text-destructive">
|
|
269
|
+
{errorMessage ?? "Something went wrong."}
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div className="flex min-h-full flex-1 items-center justify-center px-6 py-8">
|
|
276
|
+
<ConversationEmptyState className="w-full max-w-[280px] gap-4">
|
|
277
|
+
<Avatar
|
|
278
|
+
className="size-16 ring-4 ring-background"
|
|
279
|
+
style={{
|
|
280
|
+
boxShadow: "0 6px 18px rgba(0, 132, 255, 0.25)",
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
{agentAvatarUrl ? (
|
|
284
|
+
<AvatarImage src={agentAvatarUrl} alt={agentName} />
|
|
285
|
+
) : null}
|
|
286
|
+
<AvatarFallback
|
|
287
|
+
className="text-white text-base font-semibold"
|
|
288
|
+
style={{ backgroundColor: MESSENGER_BRAND_COLOR }}
|
|
289
|
+
>
|
|
290
|
+
{agentName.slice(0, 2).toUpperCase()}
|
|
291
|
+
</AvatarFallback>
|
|
292
|
+
</Avatar>
|
|
293
|
+
<div>
|
|
294
|
+
<p className="text-[15px] font-semibold leading-tight text-foreground">
|
|
295
|
+
{agentName}
|
|
296
|
+
</p>
|
|
297
|
+
<p className="mt-1.5 text-[13px] leading-snug text-muted-foreground">
|
|
298
|
+
Typically replies in a few minutes.
|
|
299
|
+
</p>
|
|
300
|
+
</div>
|
|
301
|
+
{emptyAction && (
|
|
302
|
+
<Button
|
|
303
|
+
size="lg"
|
|
304
|
+
disabled={emptyAction.disabled}
|
|
305
|
+
onClick={emptyAction.onClick}
|
|
306
|
+
className="rounded-full px-6 text-white shadow-[0_4px_12px_rgba(0,132,255,0.35)] hover:opacity-95 disabled:opacity-60"
|
|
307
|
+
style={{ backgroundColor: MESSENGER_BRAND_COLOR }}
|
|
308
|
+
>
|
|
309
|
+
{emptyAction.label}
|
|
310
|
+
</Button>
|
|
311
|
+
)}
|
|
312
|
+
</ConversationEmptyState>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<Conversation className="relative size-full">
|
|
319
|
+
<ConversationContent
|
|
320
|
+
scrollClassName={scrollClassName}
|
|
321
|
+
className={cn(contentClassName, "gap-1.5 px-3 py-3")}
|
|
322
|
+
>
|
|
323
|
+
{renderedMessages.map((item) => {
|
|
324
|
+
if (item.type === "confirmation") {
|
|
325
|
+
return (
|
|
326
|
+
<Message key={item.message.id} from="assistant">
|
|
327
|
+
<div className="flex w-fit max-w-[80%] shrink-0 flex-col gap-2">
|
|
328
|
+
<MessageResponse
|
|
329
|
+
confirmation={item.latestEnvelope}
|
|
330
|
+
confirmationPrompt={item.envelope.prompt}
|
|
331
|
+
confirmationActionsDisabled={status === "sending"}
|
|
332
|
+
onConfirmationDecision={onConfirmationDecision}
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
</Message>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const m = item.message;
|
|
340
|
+
const isUser = m.role === "user";
|
|
341
|
+
return (
|
|
342
|
+
<Message key={m.id} from={m.role}>
|
|
343
|
+
<div className="flex w-fit max-w-[80%] shrink-0 flex-col gap-1.5">
|
|
344
|
+
<MessageContent
|
|
345
|
+
className={cn(
|
|
346
|
+
"whitespace-pre-wrap rounded-[18px] px-3.5 py-2 text-[14px] leading-snug",
|
|
347
|
+
isUser
|
|
348
|
+
? "text-white"
|
|
349
|
+
: "bg-secondary text-secondary-foreground",
|
|
350
|
+
)}
|
|
351
|
+
style={
|
|
352
|
+
isUser ? { background: MESSENGER_GRADIENT } : undefined
|
|
353
|
+
}
|
|
354
|
+
>
|
|
355
|
+
{isUser ? (
|
|
356
|
+
m.content
|
|
357
|
+
) : (
|
|
358
|
+
<MessageResponse onLinkClick={onLinkClick}>
|
|
359
|
+
{m.content}
|
|
360
|
+
</MessageResponse>
|
|
361
|
+
)}
|
|
362
|
+
</MessageContent>
|
|
363
|
+
{m.actions && m.actions.length > 0 && (
|
|
364
|
+
<div className="flex flex-wrap gap-2">
|
|
365
|
+
{m.actions.map((action, i) => (
|
|
366
|
+
<Button
|
|
367
|
+
key={i}
|
|
368
|
+
size="sm"
|
|
369
|
+
variant={action.variant ?? "outline"}
|
|
370
|
+
onClick={action.onClick}
|
|
371
|
+
className="rounded-full"
|
|
372
|
+
>
|
|
373
|
+
{action.label}
|
|
374
|
+
</Button>
|
|
375
|
+
))}
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
</Message>
|
|
380
|
+
);
|
|
381
|
+
})}
|
|
382
|
+
{isTyping && (
|
|
383
|
+
<motion.div
|
|
384
|
+
initial={{ opacity: 0, y: 6 }}
|
|
385
|
+
animate={{ opacity: 1, y: 0 }}
|
|
386
|
+
transition={{ duration: 0.2, ease: "easeOut" }}
|
|
387
|
+
className="flex w-full justify-start"
|
|
388
|
+
>
|
|
389
|
+
<div className="flex max-w-[80%] items-center rounded-[18px] bg-secondary px-3.5 py-2 text-secondary-foreground">
|
|
390
|
+
<ChatWidgetTyping className="bg-transparent! p-0!" />
|
|
391
|
+
</div>
|
|
392
|
+
</motion.div>
|
|
393
|
+
)}
|
|
394
|
+
</ConversationContent>
|
|
395
|
+
<ConversationScrollButton />
|
|
396
|
+
</Conversation>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
222
400
|
function MessengerHeader({
|
|
223
401
|
agentName,
|
|
224
402
|
agentAvatarUrl,
|
|
@@ -237,7 +415,7 @@ function MessengerHeader({
|
|
|
237
415
|
return (
|
|
238
416
|
<div
|
|
239
417
|
className="flex shrink-0 items-center gap-3 px-4 py-3 text-white"
|
|
240
|
-
style={{
|
|
418
|
+
style={{ backgroundColor: MESSENGER_BRAND_COLOR }}
|
|
241
419
|
>
|
|
242
420
|
<div className="relative">
|
|
243
421
|
<Avatar size="lg" className="ring-2 ring-white/30">
|
|
@@ -287,11 +465,11 @@ function MessengerHeader({
|
|
|
287
465
|
)}
|
|
288
466
|
<button
|
|
289
467
|
type="button"
|
|
290
|
-
aria-label="
|
|
468
|
+
aria-label="Minimize"
|
|
291
469
|
onClick={onClose}
|
|
292
470
|
className="ml-0.5 flex size-8 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white"
|
|
293
471
|
>
|
|
294
|
-
<
|
|
472
|
+
<ChevronDown className="size-5" strokeWidth={2.4} />
|
|
295
473
|
</button>
|
|
296
474
|
</div>
|
|
297
475
|
</div>
|
|
@@ -326,49 +504,47 @@ function MessengerComposer({
|
|
|
326
504
|
submit();
|
|
327
505
|
};
|
|
328
506
|
|
|
329
|
-
const
|
|
330
|
-
const
|
|
507
|
+
const hasText = value.trim().length > 0;
|
|
508
|
+
const sendDisabled = disabled || !onSubmit || !hasText;
|
|
331
509
|
|
|
332
510
|
return (
|
|
333
|
-
<div className="shrink-0 border-t border-
|
|
334
|
-
<div className="flex items-end gap-
|
|
335
|
-
|
|
511
|
+
<div className="shrink-0 border-t border-border bg-background px-2 py-2">
|
|
512
|
+
<div className="flex items-end gap-0.5">
|
|
513
|
+
{/* Tool icons (Plus / Camera / Image / Mic) temporarily disabled. */}
|
|
514
|
+
<div className="flex flex-1 items-end rounded-3xl bg-secondary px-3.5">
|
|
336
515
|
<Textarea
|
|
337
516
|
ref={textareaRef}
|
|
338
|
-
placeholder=
|
|
517
|
+
placeholder="Aa"
|
|
339
518
|
value={value}
|
|
340
519
|
onChange={(e) => onChange(e.target.value)}
|
|
341
520
|
onKeyDown={handleKeyDown}
|
|
342
521
|
rows={1}
|
|
343
522
|
disabled={disabled}
|
|
344
523
|
className={cn(
|
|
345
|
-
"min-h-9 max-h-32 flex-1 resize-none border-0 bg-transparent py-2 text-[14px] shadow-none",
|
|
524
|
+
"min-h-9 max-h-32 flex-1 resize-none border-0 bg-transparent py-2 text-[14px] leading-snug shadow-none",
|
|
346
525
|
"focus-visible:ring-0 focus-visible:border-0",
|
|
347
526
|
)}
|
|
348
527
|
/>
|
|
349
528
|
</div>
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
<ThumbsUp className="size-5" strokeWidth={2.2} />
|
|
370
|
-
</button>
|
|
371
|
-
)}
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
aria-label="Send"
|
|
532
|
+
onClick={submit}
|
|
533
|
+
disabled={sendDisabled}
|
|
534
|
+
className={cn(
|
|
535
|
+
"ml-1 flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
|
|
536
|
+
"hover:bg-secondary disabled:hover:bg-transparent",
|
|
537
|
+
)}
|
|
538
|
+
style={{ color: MESSENGER_BRAND_COLOR }}
|
|
539
|
+
>
|
|
540
|
+
<SendHorizonal
|
|
541
|
+
className={cn(
|
|
542
|
+
"size-5 transition-opacity",
|
|
543
|
+
sendDisabled && "opacity-40",
|
|
544
|
+
)}
|
|
545
|
+
strokeWidth={2.2}
|
|
546
|
+
/>
|
|
547
|
+
</button>
|
|
372
548
|
</div>
|
|
373
549
|
</div>
|
|
374
550
|
);
|
package/src/runtime/analytics.ts
CHANGED
|
@@ -10,14 +10,54 @@ import { getWidgetApiBase } from "./api-origin";
|
|
|
10
10
|
const ANALYTICS_ENDPOINT = "/api/widget/events/batch";
|
|
11
11
|
const DEFAULT_BATCH_SIZE = 10;
|
|
12
12
|
const DEFAULT_FLUSH_INTERVAL_MS = 2500;
|
|
13
|
+
const MAX_FLUSH_INTERVAL_MS = 120_000;
|
|
14
|
+
const MAX_QUEUE_SIZE = 200;
|
|
15
|
+
const MAX_EVENT_ATTEMPTS = 5;
|
|
16
|
+
const MAX_EVENT_AGE_MS = 10 * 60 * 1000;
|
|
13
17
|
const UNIQUE_SCRIPT_LOADED_KEY_PREFIX =
|
|
14
18
|
"usereq-widget:unique-script-loaded:";
|
|
15
19
|
|
|
16
20
|
type QueuedAnalyticsEvent = {
|
|
17
21
|
event: WidgetAnalyticsBatchEvent;
|
|
18
22
|
sessionToken: string;
|
|
23
|
+
attempts: number;
|
|
24
|
+
enqueuedAt: number;
|
|
19
25
|
};
|
|
20
26
|
|
|
27
|
+
type SendOutcome =
|
|
28
|
+
| { outcome: "ok" }
|
|
29
|
+
| { outcome: "drop" }
|
|
30
|
+
| { outcome: "retry"; retryAfterMs?: number };
|
|
31
|
+
|
|
32
|
+
function parseRetryAfter(headerValue: string | null): number | undefined {
|
|
33
|
+
if (!headerValue) return undefined;
|
|
34
|
+
const trimmed = headerValue.trim();
|
|
35
|
+
if (!trimmed) return undefined;
|
|
36
|
+
const seconds = Number(trimmed);
|
|
37
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
38
|
+
return Math.min(MAX_FLUSH_INTERVAL_MS, seconds * 1000);
|
|
39
|
+
}
|
|
40
|
+
const dateMs = Date.parse(trimmed);
|
|
41
|
+
if (!Number.isNaN(dateMs)) {
|
|
42
|
+
const delta = dateMs - Date.now();
|
|
43
|
+
if (delta > 0) return Math.min(MAX_FLUSH_INTERVAL_MS, delta);
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function classifyResponse(response: Response): SendOutcome {
|
|
49
|
+
if (response.ok) return { outcome: "ok" };
|
|
50
|
+
const status = response.status;
|
|
51
|
+
if (status === 408 || status === 425 || status === 429 || status >= 500) {
|
|
52
|
+
return {
|
|
53
|
+
outcome: "retry",
|
|
54
|
+
retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (status >= 400 && status < 500) return { outcome: "drop" };
|
|
58
|
+
return { outcome: "retry" };
|
|
59
|
+
}
|
|
60
|
+
|
|
21
61
|
export type WidgetAnalyticsTrackerOptions = {
|
|
22
62
|
mode: "embed" | "preview";
|
|
23
63
|
agentId: string;
|
|
@@ -38,6 +78,8 @@ export class WidgetAnalyticsTracker {
|
|
|
38
78
|
private flushTimer: number | null = null;
|
|
39
79
|
private queue: QueuedAnalyticsEvent[] = [];
|
|
40
80
|
private disposed = false;
|
|
81
|
+
private consecutiveFailures = 0;
|
|
82
|
+
private nextAllowedFlushAt = 0;
|
|
41
83
|
|
|
42
84
|
constructor(options: WidgetAnalyticsTrackerOptions) {
|
|
43
85
|
this.mode = options.mode;
|
|
@@ -134,9 +176,11 @@ export class WidgetAnalyticsTracker {
|
|
|
134
176
|
...(linkUrl ? { linkUrl } : {}),
|
|
135
177
|
};
|
|
136
178
|
|
|
137
|
-
this.
|
|
179
|
+
this.enqueue({
|
|
138
180
|
event: queuedEvent,
|
|
139
181
|
sessionToken,
|
|
182
|
+
attempts: 0,
|
|
183
|
+
enqueuedAt: Date.now(),
|
|
140
184
|
});
|
|
141
185
|
|
|
142
186
|
if (this.queue.length >= this.batchSize) {
|
|
@@ -147,55 +191,108 @@ export class WidgetAnalyticsTracker {
|
|
|
147
191
|
this.scheduleFlush();
|
|
148
192
|
}
|
|
149
193
|
|
|
194
|
+
private enqueue(entry: QueuedAnalyticsEvent) {
|
|
195
|
+
this.queue.push(entry);
|
|
196
|
+
if (this.queue.length > MAX_QUEUE_SIZE) {
|
|
197
|
+
this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private pruneStale(now: number) {
|
|
202
|
+
if (this.queue.length === 0) return;
|
|
203
|
+
this.queue = this.queue.filter(
|
|
204
|
+
(entry) => now - entry.enqueuedAt < MAX_EVENT_AGE_MS,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
150
208
|
async flush(options?: {
|
|
151
209
|
transport?: "default" | "beacon";
|
|
152
210
|
}): Promise<void> {
|
|
153
211
|
if (this.disposed) return;
|
|
154
|
-
|
|
212
|
+
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
this.pruneStale(now);
|
|
215
|
+
if (this.queue.length === 0) {
|
|
216
|
+
this.clearFlushTimer();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
155
219
|
|
|
156
220
|
this.clearFlushTimer();
|
|
157
221
|
const transport = options?.transport ?? "default";
|
|
158
222
|
const snapshot = this.queue;
|
|
159
223
|
this.queue = [];
|
|
160
224
|
|
|
161
|
-
const grouped = new Map<string,
|
|
225
|
+
const grouped = new Map<string, QueuedAnalyticsEvent[]>();
|
|
162
226
|
for (const entry of snapshot) {
|
|
163
227
|
const group = grouped.get(entry.sessionToken);
|
|
164
228
|
if (group) {
|
|
165
|
-
group.push(entry
|
|
229
|
+
group.push(entry);
|
|
166
230
|
} else {
|
|
167
|
-
grouped.set(entry.sessionToken, [entry
|
|
231
|
+
grouped.set(entry.sessionToken, [entry]);
|
|
168
232
|
}
|
|
169
233
|
}
|
|
170
234
|
|
|
171
235
|
const failed: QueuedAnalyticsEvent[] = [];
|
|
236
|
+
let maxRetryAfterMs = 0;
|
|
237
|
+
let anyRetry = false;
|
|
238
|
+
let anySuccess = false;
|
|
172
239
|
|
|
173
|
-
for (const [sessionToken,
|
|
174
|
-
const
|
|
175
|
-
events,
|
|
240
|
+
for (const [sessionToken, entries] of grouped.entries()) {
|
|
241
|
+
const result = await this.sendBatch({
|
|
242
|
+
events: entries.map((entry) => entry.event),
|
|
176
243
|
sessionToken,
|
|
177
244
|
transport,
|
|
178
245
|
});
|
|
179
246
|
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
247
|
+
if (result.outcome === "ok") {
|
|
248
|
+
anySuccess = true;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (result.outcome === "drop") {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
anyRetry = true;
|
|
255
|
+
if (result.retryAfterMs && result.retryAfterMs > maxRetryAfterMs) {
|
|
256
|
+
maxRetryAfterMs = result.retryAfterMs;
|
|
184
257
|
}
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
const attempts = entry.attempts + 1;
|
|
260
|
+
if (attempts >= MAX_EVENT_ATTEMPTS) continue;
|
|
261
|
+
failed.push({ ...entry, attempts });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (anySuccess && !anyRetry) {
|
|
266
|
+
this.consecutiveFailures = 0;
|
|
267
|
+
this.nextAllowedFlushAt = 0;
|
|
268
|
+
} else if (anyRetry) {
|
|
269
|
+
this.consecutiveFailures += 1;
|
|
185
270
|
}
|
|
186
271
|
|
|
187
272
|
if (failed.length > 0) {
|
|
188
|
-
|
|
189
|
-
|
|
273
|
+
for (const entry of failed) {
|
|
274
|
+
this.enqueue(entry);
|
|
275
|
+
}
|
|
276
|
+
const backoff = this.computeBackoffDelay();
|
|
277
|
+
const delay = Math.max(backoff, maxRetryAfterMs);
|
|
278
|
+
this.nextAllowedFlushAt = Date.now() + delay;
|
|
279
|
+
this.scheduleFlush(delay);
|
|
190
280
|
}
|
|
191
281
|
}
|
|
192
282
|
|
|
283
|
+
private computeBackoffDelay(): number {
|
|
284
|
+
const exponent = Math.max(0, this.consecutiveFailures - 1);
|
|
285
|
+
const base = this.flushIntervalMs * Math.pow(2, exponent);
|
|
286
|
+
const jitter = base * 0.2 * Math.random();
|
|
287
|
+
return Math.min(MAX_FLUSH_INTERVAL_MS, base + jitter);
|
|
288
|
+
}
|
|
289
|
+
|
|
193
290
|
private async sendBatch(input: {
|
|
194
291
|
events: WidgetAnalyticsBatchEvent[];
|
|
195
292
|
sessionToken: string;
|
|
196
293
|
transport: "default" | "beacon";
|
|
197
|
-
}): Promise<
|
|
198
|
-
if (input.events.length === 0) return
|
|
294
|
+
}): Promise<SendOutcome> {
|
|
295
|
+
if (input.events.length === 0) return { outcome: "ok" };
|
|
199
296
|
|
|
200
297
|
const url = new URL(ANALYTICS_ENDPOINT, `${getWidgetApiBase()}/`).toString();
|
|
201
298
|
const sanitizedEvents = input.events.map((event) => {
|
|
@@ -220,7 +317,7 @@ export class WidgetAnalyticsTracker {
|
|
|
220
317
|
type: "application/json",
|
|
221
318
|
});
|
|
222
319
|
if (navigator.sendBeacon(url, body)) {
|
|
223
|
-
return
|
|
320
|
+
return { outcome: "ok" };
|
|
224
321
|
}
|
|
225
322
|
}
|
|
226
323
|
|
|
@@ -235,18 +332,19 @@ export class WidgetAnalyticsTracker {
|
|
|
235
332
|
credentials: "omit",
|
|
236
333
|
keepalive: input.transport === "beacon",
|
|
237
334
|
});
|
|
238
|
-
return response
|
|
335
|
+
return classifyResponse(response);
|
|
239
336
|
} catch {
|
|
240
|
-
return
|
|
337
|
+
return { outcome: "retry" };
|
|
241
338
|
}
|
|
242
339
|
}
|
|
243
340
|
|
|
244
|
-
private scheduleFlush() {
|
|
341
|
+
private scheduleFlush(delayMs?: number) {
|
|
245
342
|
if (this.flushTimer != null) return;
|
|
343
|
+
const delay = Math.max(0, delayMs ?? this.flushIntervalMs);
|
|
246
344
|
this.flushTimer = window.setTimeout(() => {
|
|
247
345
|
this.flushTimer = null;
|
|
248
346
|
void this.flush();
|
|
249
|
-
},
|
|
347
|
+
}, delay);
|
|
250
348
|
}
|
|
251
349
|
|
|
252
350
|
private clearFlushTimer() {
|