@wireai/activation 0.13.0 → 0.13.2
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/AGENTS.md +3 -1
- package/CHANGELOG.md +116 -3
- package/README.md +82 -0
- package/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/analytics/index.js +61 -2
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +61 -2
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-_GynvhzT.d.mts → currentSession-ClkLjcJ0.d.mts} +298 -13
- package/dist/{currentSession-D7zabMXK.d.ts → currentSession-DOVZEWJl.d.ts} +298 -13
- package/dist/index.d.mts +195 -4
- package/dist/index.d.ts +195 -4
- package/dist/index.js +909 -340
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +693 -145
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +12 -1
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +12 -1
- package/dist/reviews/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +131 -0
- package/src/WireOnboarding.tsx +63 -2
- package/src/analytics/analyticsEvent.ts +16 -1
- package/src/analytics/eventQueue.ts +2 -0
- package/src/analytics/reportClientEvent.ts +68 -12
- package/src/cards/PermissionCard.tsx +438 -0
- package/src/cards/index.ts +7 -0
- package/src/illustrations/defaultIllustrations.tsx +44 -3
- package/src/index.ts +38 -0
- package/src/permissions/index.ts +64 -0
- package/src/permissions/permissionCopy.ts +87 -0
- package/src/permissions/permissionEvents.ts +76 -0
- package/src/permissions/permissionMemory.ts +88 -0
- package/src/permissions/placement.ts +88 -0
- package/src/permissions/types.ts +131 -0
- package/src/session/persistedSession.ts +10 -3
- package/src/types.ts +66 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
|
|
6
6
|
"author": "Malik Chohra <malik@getwireai.com>",
|
package/src/OnboardingFlow.tsx
CHANGED
|
@@ -36,6 +36,17 @@ import { deriveAnswers } from "./utils/deriveAnswers";
|
|
|
36
36
|
import { readProgress } from "./utils/readProgress";
|
|
37
37
|
import { reportClientEvent, type ClientEventTarget } from "./analytics/reportClientEvent";
|
|
38
38
|
import { sendPreview } from "./analytics/sendPreview";
|
|
39
|
+
import { PermissionCardView, PERMISSION_CARD_NAME } from "./cards/PermissionCard";
|
|
40
|
+
import { permissionEventName, permissionEventProps } from "./permissions/permissionEvents";
|
|
41
|
+
import { selectDuePermissionScreen } from "./permissions/placement";
|
|
42
|
+
import { resolvePermissionCopy } from "./permissions/permissionCopy";
|
|
43
|
+
import type {
|
|
44
|
+
PermissionScreenConfig,
|
|
45
|
+
PermissionStage,
|
|
46
|
+
WirePermissionKind,
|
|
47
|
+
WirePermissionOutcome,
|
|
48
|
+
WirePermissionStatus,
|
|
49
|
+
} from "./permissions/types";
|
|
39
50
|
import type { DeviceContext } from "./device/deviceContext";
|
|
40
51
|
import type { OnboardingCopy, OnboardingEvent, OnboardingResult, StepValidator } from "./types";
|
|
41
52
|
|
|
@@ -75,6 +86,25 @@ type OnboardingFlowProps = {
|
|
|
75
86
|
* `started` so host funnels don't double-count the session.
|
|
76
87
|
*/
|
|
77
88
|
resumed?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Host-declared permission screens injected into this server-driven stream. See
|
|
91
|
+
* `permissions/types.ts`: the OS dialog is only ever reached from the primary tap.
|
|
92
|
+
*/
|
|
93
|
+
permissionScreens?: PermissionScreenConfig[];
|
|
94
|
+
/**
|
|
95
|
+
* Screen ids already settled in THIS session, restored from `storage` by WireOnboarding so an
|
|
96
|
+
* app kill mid-flow does not re-ask. Empty (the default) means "nothing settled yet".
|
|
97
|
+
*/
|
|
98
|
+
settledPermissions?: readonly string[];
|
|
99
|
+
/**
|
|
100
|
+
* True while that restore is still in flight. Permission screens are suppressed until it lands,
|
|
101
|
+
* because showing one before the record is read is exactly the re-ask the record prevents. The
|
|
102
|
+
* read is short (one storage get, timeout-capped) and cannot outlive the first card's round trip
|
|
103
|
+
* in practice, so this normally never shows up as a delay.
|
|
104
|
+
*/
|
|
105
|
+
permissionsPending?: boolean;
|
|
106
|
+
/** Persist one settled screen id (fire-and-forget; WireOnboarding owns the storage). */
|
|
107
|
+
onPermissionSettled?: (id: string, outcome: WirePermissionOutcome) => void;
|
|
78
108
|
};
|
|
79
109
|
|
|
80
110
|
// The kit's per-question Skip sends this sentinel; the backend skips ONE question
|
|
@@ -112,6 +142,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
112
142
|
sessionId,
|
|
113
143
|
resumed = false,
|
|
114
144
|
clientContext,
|
|
145
|
+
permissionScreens,
|
|
146
|
+
settledPermissions,
|
|
147
|
+
permissionsPending = false,
|
|
148
|
+
onPermissionSettled,
|
|
115
149
|
}) => {
|
|
116
150
|
const { messages, error, isLoading, sendMessage, reset } = useWireAIThread();
|
|
117
151
|
const makeActions = useWireAIAction(sendMessage);
|
|
@@ -156,6 +190,11 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
156
190
|
const [timedOut, setTimedOut] = useState(false);
|
|
157
191
|
const [validationError, setValidationError] = useState<string | undefined>();
|
|
158
192
|
const [validating, setValidating] = useState(false);
|
|
193
|
+
// Permission screens settled during THIS mount, on top of whatever was restored from storage.
|
|
194
|
+
// A settled screen never comes back, which is the "shown exactly once" half of the contract.
|
|
195
|
+
const [locallySettledPermissions, setLocallySettledPermissions] = useState<string[]>([]);
|
|
196
|
+
const onPermissionSettledRef = useRef(onPermissionSettled);
|
|
197
|
+
onPermissionSettledRef.current = onPermissionSettled;
|
|
159
198
|
|
|
160
199
|
const lastCard = useMemo<Message | undefined>(
|
|
161
200
|
() => [...messages].reverse().find((m) => m.role === "assistant" && m.response?.action === "render"),
|
|
@@ -324,6 +363,51 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
324
363
|
sendMessage(SKIP_ONE_SENTINEL);
|
|
325
364
|
}, [isLoading, sendMessage]);
|
|
326
365
|
|
|
366
|
+
// ── Permission screens (host-injected, never server-driven in this release) ─────────────────
|
|
367
|
+
//
|
|
368
|
+
// Analytics for one moment of a permission screen. It rides the SAME plumbing as the rest of the
|
|
369
|
+
// funnel: an `app_event` on `/v1/events` whose `question_key` is the canonical `wire_permission_*`
|
|
370
|
+
// name (exactly how `WIRE_PURCHASE_EVENTS` reach the wire), carrying the same device snapshot and
|
|
371
|
+
// `user_context`, so the `device_key` join that makes every other number real covers these too.
|
|
372
|
+
// It is ALSO surfaced on `onEvent`, so a host keeps its own analytics without a second wiring.
|
|
373
|
+
//
|
|
374
|
+
// `screen_index` is the last card index shown, NOT a new one: a permission screen is not a
|
|
375
|
+
// question, so it must never introduce a screen index the server never emitted a `screen_shown`
|
|
376
|
+
// for. Nothing here can throw into the UI (reportClientEvent is fire-and-forget by contract).
|
|
377
|
+
const emitPermissionStage = useCallback(
|
|
378
|
+
(permission: WirePermissionKind, stage: PermissionStage, status?: WirePermissionStatus) => {
|
|
379
|
+
onEventRef.current?.({ type: "permission", permission, stage, status });
|
|
380
|
+
reportClientEvent(reportTargetRef.current, {
|
|
381
|
+
event_type: "app_event",
|
|
382
|
+
session_id: sessionIdRef.current,
|
|
383
|
+
question_key: permissionEventName(stage),
|
|
384
|
+
component: PERMISSION_CARD_NAME,
|
|
385
|
+
screen_index: lastScreenIndexRef.current >= 0 ? lastScreenIndexRef.current : undefined,
|
|
386
|
+
meta: JSON.stringify(permissionEventProps(permission, status)),
|
|
387
|
+
device: clientContextRef.current?.device,
|
|
388
|
+
user_context: clientContextRef.current?.userContext,
|
|
389
|
+
user_id: clientContextRef.current?.userId,
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
[],
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// A screen is DONE: remember it (so it never returns), let the host persist that across a kill,
|
|
396
|
+
// and hand the outcome to `onResult`, the seam a host schedules its first local notification
|
|
397
|
+
// from. A throwing host callback is swallowed: the flow continues on every path, always.
|
|
398
|
+
const settlePermission = useCallback(
|
|
399
|
+
(id: string, screen: PermissionScreenConfig, outcome: WirePermissionOutcome) => {
|
|
400
|
+
setLocallySettledPermissions((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
|
401
|
+
onPermissionSettledRef.current?.(id, outcome);
|
|
402
|
+
try {
|
|
403
|
+
screen.onResult?.(screen.permission, outcome);
|
|
404
|
+
} catch {
|
|
405
|
+
// A host's post-grant work (scheduling a reminder) must never break onboarding.
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
[],
|
|
409
|
+
);
|
|
410
|
+
|
|
327
411
|
// Prefetch on SELECT — the fix for "the first AI question is always the slowest". Q1 is a
|
|
328
412
|
// deterministic base card (instant); Q2 is the first LLM turn, and neither cache protects it
|
|
329
413
|
// (flow-cache misses a novel answer combo, the provider prompt-cache is cold on the first
|
|
@@ -438,6 +522,53 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
438
522
|
const total = progress.total ?? approxScreens;
|
|
439
523
|
const isTerminal = response.action === "render" && response.component === "StatusCard";
|
|
440
524
|
|
|
525
|
+
// A permission screen sits IN FRONT of the card the flow was about to render (including the
|
|
526
|
+
// terminal recap, which is what `"beforeEnd"` means), and hands that card straight back once it
|
|
527
|
+
// settles. Nothing is sent to the backend, nothing is appended to the thread, and `step` is
|
|
528
|
+
// untouched, so the progress bar, the funnel, and `deriveAnswers` cannot tell it happened.
|
|
529
|
+
// Placement is resolved against the card ABOUT to render, because the stream length is
|
|
530
|
+
// server-driven; an `afterCard` the stream never reaches clamps to the terminal position.
|
|
531
|
+
const duePermission = permissionsPending
|
|
532
|
+
? undefined
|
|
533
|
+
: selectDuePermissionScreen(
|
|
534
|
+
permissionScreens,
|
|
535
|
+
settledPermissions
|
|
536
|
+
? [...settledPermissions, ...locallySettledPermissions]
|
|
537
|
+
: locallySettledPermissions,
|
|
538
|
+
{ cardIndex: renderedCount - 1, isTerminal },
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
if (duePermission) {
|
|
542
|
+
const { screen, id } = duePermission;
|
|
543
|
+
const copy = resolvePermissionCopy(screen.permission, screen.copy);
|
|
544
|
+
return (
|
|
545
|
+
<OnboardingScaffold step={step} approxScreens={total}>
|
|
546
|
+
{/* The same shell + handoff every card uses; "spring" is the value-beat register the
|
|
547
|
+
InterstitialCard already wears, which is what this screen is. */}
|
|
548
|
+
<CardHandoff transitionKey={`permission:${id}`} variant="spring">
|
|
549
|
+
<PermissionCardView
|
|
550
|
+
key={id}
|
|
551
|
+
permission={screen.permission}
|
|
552
|
+
title={copy.title}
|
|
553
|
+
message={copy.message}
|
|
554
|
+
primaryLabel={copy.primaryLabel}
|
|
555
|
+
secondaryLabel={copy.secondaryLabel}
|
|
556
|
+
blockedTitle={copy.blockedTitle}
|
|
557
|
+
blockedMessage={copy.blockedMessage}
|
|
558
|
+
settingsLabel={copy.settingsLabel}
|
|
559
|
+
continueLabel={copy.continueLabel}
|
|
560
|
+
illustration={screen.illustration}
|
|
561
|
+
request={screen.request}
|
|
562
|
+
getStatus={screen.getStatus}
|
|
563
|
+
openSettings={screen.openSettings}
|
|
564
|
+
onStage={(stage, status) => emitPermissionStage(screen.permission, stage, status)}
|
|
565
|
+
onSettled={(outcome) => settlePermission(id, screen, outcome)}
|
|
566
|
+
/>
|
|
567
|
+
</CardHandoff>
|
|
568
|
+
</OnboardingScaffold>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
441
572
|
// Terminal recap: show what the app will now do for the user (backend-authored),
|
|
442
573
|
// and finish only when they tap the CTA.
|
|
443
574
|
if (isTerminal) {
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -34,6 +34,12 @@ import {
|
|
|
34
34
|
DEFAULT_SESSION_TTL_MS,
|
|
35
35
|
type LoadedSession,
|
|
36
36
|
} from "./session/persistedSession";
|
|
37
|
+
import {
|
|
38
|
+
clearSettledPermissions,
|
|
39
|
+
loadSettledPermissions,
|
|
40
|
+
permissionStorageKey,
|
|
41
|
+
saveSettledPermissions,
|
|
42
|
+
} from "./permissions/permissionMemory";
|
|
37
43
|
import type { OnboardingResult, WireOnboardingProps } from "./types";
|
|
38
44
|
import { warnInDev } from "./utils/warnInDev";
|
|
39
45
|
|
|
@@ -70,6 +76,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
70
76
|
userContext,
|
|
71
77
|
userId,
|
|
72
78
|
autoJoinKey = true,
|
|
79
|
+
permissionScreens,
|
|
73
80
|
}) => {
|
|
74
81
|
// The host's own opaque user id (trimmed + capped, NO PII) so onboarding sessions can be
|
|
75
82
|
// reconciled to real users later. `sanitizeUserId` is a pure string transform → the memoized
|
|
@@ -321,6 +328,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
321
328
|
storage ? null : { id: makeSessionId(), resumed: false },
|
|
322
329
|
);
|
|
323
330
|
const storageKey = persistKey ?? sessionStorageKey(config.appId);
|
|
331
|
+
const permissionsKey = permissionStorageKey(config.appId);
|
|
324
332
|
|
|
325
333
|
useEffect(() => {
|
|
326
334
|
if (!storage || session) return;
|
|
@@ -351,14 +359,63 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
351
359
|
// host that does not opt in lands on the byte-for-byte legacy clear-on-complete path.
|
|
352
360
|
const handleComplete = useCallback(
|
|
353
361
|
(result: OnboardingResult) => {
|
|
354
|
-
if (storage && shouldClearOnComplete(retainSessionOnComplete))
|
|
362
|
+
if (storage && shouldClearOnComplete(retainSessionOnComplete)) {
|
|
363
|
+
clearPersistedSession(storage, storageKey);
|
|
364
|
+
// The permission record is keyed by session id, so a stale one already reads as empty,
|
|
365
|
+
// this only keeps the adapter from carrying a dead entry forever.
|
|
366
|
+
clearSettledPermissions(storage, permissionsKey);
|
|
367
|
+
}
|
|
355
368
|
onComplete(result);
|
|
356
369
|
},
|
|
357
|
-
[storage, storageKey, retainSessionOnComplete, onComplete],
|
|
370
|
+
[storage, storageKey, permissionsKey, retainSessionOnComplete, onComplete],
|
|
358
371
|
);
|
|
359
372
|
|
|
360
373
|
const sessionId = session?.id ?? "";
|
|
361
374
|
|
|
375
|
+
// ── Permission-screen memory: "shown once per session", kept true across an app KILL ─────────
|
|
376
|
+
//
|
|
377
|
+
// The in-memory guard covers one mount. A resumed session (the whole point of `storage`) is a
|
|
378
|
+
// NEW mount of the SAME session, so without a persisted record it would re-show a screen the user
|
|
379
|
+
// already answered, and on the ask path that is a second attempt at a prompt iOS grants once.
|
|
380
|
+
//
|
|
381
|
+
// Unlike the auto-join key, this does NOT hold the loader gate: it feeds no `llm` dependency, so
|
|
382
|
+
// a late arrival cannot recreate the A2A adapter. The flow simply suppresses permission screens
|
|
383
|
+
// while `permissionsPending` is true, and that window closes long before the first card arrives
|
|
384
|
+
// (one timeout-capped storage read against a backend round trip).
|
|
385
|
+
const wantsPermissionMemory = Boolean(storage) && (permissionScreens?.length ?? 0) > 0;
|
|
386
|
+
const [settledPermissions, setSettledPermissions] = useState<string[] | undefined>(undefined);
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (!wantsPermissionMemory || !storage || !sessionId || settledPermissions !== undefined) return;
|
|
389
|
+
let cancelled = false;
|
|
390
|
+
void loadSettledPermissions(storage, permissionsKey, sessionId).then((ids) => {
|
|
391
|
+
if (!cancelled) setSettledPermissions(ids);
|
|
392
|
+
});
|
|
393
|
+
return () => {
|
|
394
|
+
cancelled = true;
|
|
395
|
+
};
|
|
396
|
+
}, [wantsPermissionMemory, storage, permissionsKey, sessionId, settledPermissions]);
|
|
397
|
+
|
|
398
|
+
// Persist the settled set as it grows. Fire-and-forget: a failed write only costs a re-ask on a
|
|
399
|
+
// resume, never a broken flow. Without `storage` there is nothing to write to and the
|
|
400
|
+
// in-memory guard alone carries the once-only contract for this mount.
|
|
401
|
+
// Mirrored in a ref so the handler can compute the next set WITHOUT doing the storage write
|
|
402
|
+
// inside a state updater (React invokes updaters twice under StrictMode; a write belongs outside).
|
|
403
|
+
const settledPermissionsRef = useRef<string[] | undefined>(undefined);
|
|
404
|
+
settledPermissionsRef.current = settledPermissions;
|
|
405
|
+
const handlePermissionSettled = useCallback(
|
|
406
|
+
(id: string) => {
|
|
407
|
+
const current = settledPermissionsRef.current ?? [];
|
|
408
|
+
if (current.includes(id)) return;
|
|
409
|
+
const next = [...current, id];
|
|
410
|
+
settledPermissionsRef.current = next;
|
|
411
|
+
setSettledPermissions(next);
|
|
412
|
+
if (storage && wantsPermissionMemory && sessionId) {
|
|
413
|
+
saveSettledPermissions(storage, permissionsKey, sessionId, next);
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
[storage, wantsPermissionMemory, permissionsKey, sessionId],
|
|
417
|
+
);
|
|
418
|
+
|
|
362
419
|
// Session-start snapshot of the bound user id. It seeds the A2A `metadata.userId` so the
|
|
363
420
|
// adapter is built ONCE with whatever id was known at session start. A mid-session userId
|
|
364
421
|
// change must NOT flow through this memo — recreating `llm` recreates the A2A adapter,
|
|
@@ -478,6 +535,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
478
535
|
sessionId={sessionId}
|
|
479
536
|
resumed={session.resumed}
|
|
480
537
|
clientContext={clientContext}
|
|
538
|
+
permissionScreens={permissionScreens}
|
|
539
|
+
settledPermissions={settledPermissions}
|
|
540
|
+
permissionsPending={wantsPermissionMemory && settledPermissions === undefined}
|
|
541
|
+
onPermissionSettled={handlePermissionSettled}
|
|
481
542
|
/>
|
|
482
543
|
</WireAIProvider>
|
|
483
544
|
</IconRegistryProvider>
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* `onEvent`) — the app logs it explicitly on `onComplete` using the constant below, so the
|
|
16
16
|
* funnel name stays canonical.
|
|
17
17
|
*/
|
|
18
|
+
import {
|
|
19
|
+
permissionEventName,
|
|
20
|
+
permissionEventProps,
|
|
21
|
+
type WirePermissionEventName,
|
|
22
|
+
} from "../permissions/permissionEvents";
|
|
18
23
|
import type { OnboardingEvent } from "../types";
|
|
19
24
|
|
|
20
25
|
export const WIRE_ONBOARDING_EVENTS = {
|
|
@@ -33,7 +38,12 @@ export type WireOnboardingEventName =
|
|
|
33
38
|
(typeof WIRE_ONBOARDING_EVENTS)[keyof typeof WIRE_ONBOARDING_EVENTS];
|
|
34
39
|
|
|
35
40
|
export type AnalyticsEvent = {
|
|
36
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A permission screen maps to its own canonical `wire_permission_*` name rather than to an
|
|
43
|
+
* onboarding one: it is a distinct funnel (see `permissions/permissionEvents.ts`), and folding it
|
|
44
|
+
* into `wire_onboarding_turn` would make every permission rate unreadable.
|
|
45
|
+
*/
|
|
46
|
+
name: WireOnboardingEventName | WirePermissionEventName;
|
|
37
47
|
params?: Record<string, unknown>;
|
|
38
48
|
};
|
|
39
49
|
|
|
@@ -61,6 +71,11 @@ export const toAnalyticsEvent = (event: OnboardingEvent): AnalyticsEvent => {
|
|
|
61
71
|
};
|
|
62
72
|
case "fallback":
|
|
63
73
|
return { name: WIRE_ONBOARDING_EVENTS.fallback, params: { reason: event.reason } };
|
|
74
|
+
case "permission":
|
|
75
|
+
return {
|
|
76
|
+
name: permissionEventName(event.stage),
|
|
77
|
+
params: permissionEventProps(event.permission, event.status),
|
|
78
|
+
};
|
|
64
79
|
default: {
|
|
65
80
|
const _exhaustive: never = event;
|
|
66
81
|
return _exhaustive;
|
|
@@ -238,6 +238,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
238
238
|
// from a REDUNDANT re-enqueue of the same instant. The de-dup signature below includes it, so
|
|
239
239
|
// two identical events enqueued in the same millisecond still collapse (a re-render), while the
|
|
240
240
|
// same action repeated later carries a fresh `ts` and survives. A caller-set `ts` is preserved.
|
|
241
|
+
// This stays a NUMBER in the queue (the de-dup signature depends on it); `buildEventsRequest`
|
|
242
|
+
// serializes it to the ISO8601 string the server requires at send time.
|
|
241
243
|
if (stamped.ts === undefined) stamped.ts = Date.now();
|
|
242
244
|
if (!env) return stamped;
|
|
243
245
|
if (!stamped.device && env.device) stamped.device = env.device;
|
|
@@ -77,15 +77,24 @@ export type ClientEvent = {
|
|
|
77
77
|
*/
|
|
78
78
|
user_id?: string;
|
|
79
79
|
/**
|
|
80
|
-
* Client-stamped
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
80
|
+
* Client-stamped timestamp of when the event was ENQUEUED on the device. Optional and ADDITIVE.
|
|
81
|
+
*
|
|
82
|
+
* TWO REPRESENTATIONS, on purpose:
|
|
83
|
+
* - INTERNAL (epoch-ms `number`): what the offline queue stamps at enqueue time (see
|
|
84
|
+
* `createEventQueue`), so two otherwise byte-identical events fired seconds apart (a genuine
|
|
85
|
+
* repeat, e.g. the user taps "share" twice) are NOT collapsed by the queue's identical-JSON
|
|
86
|
+
* de-dup — while two truly simultaneous re-enqueues of the same instant (a redundant
|
|
87
|
+
* re-render) still share a `ts` and collapse. The de-dup signature depends on this number.
|
|
88
|
+
* - WIRE (ISO8601 UTC `string`): what actually leaves the device. {@link buildEventsRequest}
|
|
89
|
+
* converts the number on its way out, because the server declares `ts: str | None` and
|
|
90
|
+
* pydantic v2 does NOT coerce a number into it — a numeric `ts` made the server answer HTTP
|
|
91
|
+
* 200 with `{written: 0, skipped: N, errors: [{field: "ts", reason: "validation_error"}]}`,
|
|
92
|
+
* silently discarding EVERY `app_event` through 0.13.0.
|
|
93
|
+
*
|
|
94
|
+
* A caller-set ISO string is passed through as-is. Never a wall-clock the server trusts (it
|
|
95
|
+
* derives its own receive time); an old/strict server that does not model it ignores the field.
|
|
87
96
|
*/
|
|
88
|
-
ts?: number;
|
|
97
|
+
ts?: number | string;
|
|
89
98
|
};
|
|
90
99
|
|
|
91
100
|
/** Where to POST. Derived from `WireOnboardingConfig` (`serverUrl` + `apiKey`). */
|
|
@@ -105,12 +114,46 @@ export type ClientEventTarget = {
|
|
|
105
114
|
export const makeSessionId = (): string =>
|
|
106
115
|
`wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
107
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Serialize the internal epoch-ms `ts` to the ISO8601 UTC string the WIRE requires.
|
|
119
|
+
*
|
|
120
|
+
* The server's event model declares `ts: str | None` and pydantic v2 does NOT coerce int → str, so
|
|
121
|
+
* a numeric `ts` fails per-event validation: the endpoint still answers HTTP 200, but with
|
|
122
|
+
* `{written: 0, skipped: N, errors: [{index, reason: "validation_error", field: "ts"}]}` — every
|
|
123
|
+
* `app_event` from `track()`/`screen()` silently discarded behind a green response.
|
|
124
|
+
*
|
|
125
|
+
* WHY HERE and not at the queue's `stamp()`: this is the single choke point all FOUR send paths go
|
|
126
|
+
* through (offline queue, fire-and-forget, awaitable, session-start). Converting here leaves the
|
|
127
|
+
* queue's numeric `ts` — and therefore its identical-JSON de-dup signature — exactly as it was, and
|
|
128
|
+
* it also converts the persisted 0.13.0 backlogs (which hold a numeric `ts`) on their way out.
|
|
129
|
+
*
|
|
130
|
+
* NEVER mutates the caller's event: an event that needs a change is copied. A string `ts` (a
|
|
131
|
+
* caller-set ISO stamp) and an absent `ts` pass through untouched. A non-finite (`NaN`/`Infinity`)
|
|
132
|
+
* or out-of-range number — the latter makes `toISOString` throw — drops the `ts` field from the
|
|
133
|
+
* copy rather than killing the whole batch.
|
|
134
|
+
*/
|
|
135
|
+
const toWireEvents = (events: ClientEvent[]): ClientEvent[] =>
|
|
136
|
+
events.map((event) => {
|
|
137
|
+
if (typeof event.ts !== "number") return event;
|
|
138
|
+
const { ts, ...rest } = event;
|
|
139
|
+
if (!Number.isFinite(ts)) return rest;
|
|
140
|
+
try {
|
|
141
|
+
return { ...rest, ts: new Date(ts).toISOString() };
|
|
142
|
+
} catch {
|
|
143
|
+
// Out-of-range epoch-ms — send the event WITHOUT a ts rather than lose the batch.
|
|
144
|
+
return rest;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
108
148
|
/**
|
|
109
149
|
* The ONE place the `/v1/events` POST is described (url + method + headers + body). Both the
|
|
110
150
|
* fire-and-forget {@link reportClientEvents} and the awaitable {@link reportClientEventsAwait}
|
|
111
151
|
* build their request here so there is a SINGLE definition of the events transport — no second
|
|
112
152
|
* copy of the endpoint path, headers, or envelope shape to drift. Returns `null` when there is
|
|
113
153
|
* nothing to send (no target / no events) or serialization throws, so callers just bail.
|
|
154
|
+
*
|
|
155
|
+
* It is also where the internal epoch-ms `ts` becomes the wire's ISO8601 string — see
|
|
156
|
+
* {@link toWireEvents} for why the conversion belongs at this choke point.
|
|
114
157
|
*/
|
|
115
158
|
export const buildEventsRequest = (
|
|
116
159
|
target: { serverUrl: string; apiKey?: string } | undefined,
|
|
@@ -121,15 +164,18 @@ export const buildEventsRequest = (
|
|
|
121
164
|
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
122
165
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
123
166
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
124
|
-
|
|
167
|
+
const body = JSON.stringify({ events: toWireEvents(events) });
|
|
168
|
+
return { url, init: { method: "POST", headers, body } };
|
|
125
169
|
} catch {
|
|
126
170
|
// URL construction or JSON serialization failed — nothing to send.
|
|
127
171
|
return null;
|
|
128
172
|
}
|
|
129
173
|
};
|
|
130
174
|
|
|
131
|
-
/** What the `/v1/events` endpoint says in its 200 body.
|
|
132
|
-
*
|
|
175
|
+
/** What the `/v1/events` endpoint says in its 200 body. The deployed server DOES send `errors[]`,
|
|
176
|
+
* each entry carrying `{index, reason, field}` — `field` names the property that failed validation
|
|
177
|
+
* (it is what identified the `ts` rejection), so it is folded into the reason string here. Still
|
|
178
|
+
* read defensively: an older server sends no `errors` at all. */
|
|
133
179
|
type EventsAck = { written?: number; skipped: number; reasons: string[] };
|
|
134
180
|
|
|
135
181
|
/**
|
|
@@ -153,7 +199,17 @@ const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
|
|
|
153
199
|
if (typeof skipped !== "number" || !Number.isFinite(skipped)) return undefined;
|
|
154
200
|
const reasons = Array.isArray(body?.errors)
|
|
155
201
|
? body.errors
|
|
156
|
-
.map((e) =>
|
|
202
|
+
.map((e) => {
|
|
203
|
+
const entry = e as { reason?: unknown; field?: unknown } | null;
|
|
204
|
+
const reason = entry?.reason;
|
|
205
|
+
if (typeof reason !== "string") return undefined;
|
|
206
|
+
// `field` is the whole point of a validation error — a bare "validation_error" sends the
|
|
207
|
+
// reader hunting; "validation_error (field: ts)" names the property the server refused.
|
|
208
|
+
const field = entry?.field;
|
|
209
|
+
return typeof field === "string" && field.length > 0
|
|
210
|
+
? `${reason} (field: ${field})`
|
|
211
|
+
: reason;
|
|
212
|
+
})
|
|
157
213
|
.filter((r): r is string => typeof r === "string")
|
|
158
214
|
: [];
|
|
159
215
|
return { written: typeof body?.written === "number" ? body.written : undefined, skipped, reasons };
|