@wireai/activation 0.12.2 → 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 +259 -1
- package/README.md +87 -3
- package/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/analytics/index.js +174 -36
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +174 -37
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-ClkLjcJ0.d.mts} +456 -19
- package/dist/{currentSession-BxEB37xt.d.ts → currentSession-DOVZEWJl.d.ts} +456 -19
- package/dist/index.d.mts +197 -16
- package/dist/index.d.ts +197 -16
- package/dist/index.js +1056 -390
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +836 -193
- 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 +20 -7
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +20 -7
- package/dist/reviews/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +141 -3
- package/src/WireOnboarding.tsx +178 -34
- package/src/activation/wireActivation.ts +13 -7
- package/src/analytics/analyticsEvent.ts +16 -1
- package/src/analytics/analyticsFacade.ts +11 -10
- package/src/analytics/currentSession.ts +6 -20
- package/src/analytics/eventQueue.ts +71 -1
- package/src/analytics/index.ts +1 -1
- package/src/analytics/reportClientEvent.ts +157 -38
- package/src/cards/PermissionCard.tsx +438 -0
- package/src/cards/index.ts +7 -0
- package/src/config/wireConfigFromEnv.ts +1 -10
- package/src/context/deviceId.ts +77 -16
- package/src/context/userContext.ts +4 -15
- package/src/identity/identityRecord.ts +123 -0
- package/src/identity/userIdentity.ts +45 -9
- package/src/illustrations/defaultIllustrations.tsx +44 -3
- package/src/index.ts +44 -4
- 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/session-analytics/useLifecycleEvents.ts +10 -1
- package/src/types.ts +77 -1
- package/src/utils/deriveAnswers.ts +6 -2
- package/src/utils/readProgress.ts +4 -0
- package/src/utils/warnInDev.ts +33 -0
- package/src/components/DoneBlock.tsx +0 -37
|
@@ -38,17 +38,9 @@ import {
|
|
|
38
38
|
} from "../context/userContext";
|
|
39
39
|
import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
40
40
|
import { detectAppVersion } from "../device/appVersion";
|
|
41
|
+
import { resolveIdentity } from "../identity/identityRecord";
|
|
41
42
|
import { looksLikeEmail, sanitizeUserId } from "../identity/userIdentity";
|
|
42
|
-
|
|
43
|
-
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
44
|
-
declare const __DEV__: boolean | undefined;
|
|
45
|
-
|
|
46
|
-
/** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
|
|
47
|
-
const warnInDev = (message: string): void => {
|
|
48
|
-
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
49
|
-
console.warn(message);
|
|
50
|
-
}
|
|
51
|
-
};
|
|
43
|
+
import { warnInDev } from "../utils/warnInDev";
|
|
52
44
|
|
|
53
45
|
/** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
|
|
54
46
|
export type AnalyticsProps = Record<string, unknown>;
|
|
@@ -204,6 +196,15 @@ export const createAnalytics = (
|
|
|
204
196
|
typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
|
|
205
197
|
? config.userContext.deviceKey.trim()
|
|
206
198
|
: undefined;
|
|
199
|
+
// Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
|
|
200
|
+
// was NOT given one can tell "this app owns no device id" (fine, inject) from "this app owns one
|
|
201
|
+
// and forgot it here" (the silent third id space — K9). Recording only; nothing reads it here.
|
|
202
|
+
resolveIdentity({
|
|
203
|
+
value: hostDeviceKeyAtInit,
|
|
204
|
+
space: "device",
|
|
205
|
+
source: "host",
|
|
206
|
+
scope: config.appId,
|
|
207
|
+
});
|
|
207
208
|
// The auto id comes from the ONE process-wide registry (`resolveAutoDeviceKey`), NOT a mint local to
|
|
208
209
|
// this instance. A host that also builds a `createWireActivation` instance used to get a SECOND,
|
|
209
210
|
// different auto id for the same install, splitting `device_key` across two id spaces — see the
|
|
@@ -48,6 +48,12 @@
|
|
|
48
48
|
* app-open: it warns once in dev, naming the fix.
|
|
49
49
|
*/
|
|
50
50
|
import { makeSessionId } from "./reportClientEvent";
|
|
51
|
+
// The shared primitive returns whether it ACTUALLY warned, which the once-flag below depends on:
|
|
52
|
+
// marking "already warned" after a prod no-op would burn the single warning and leave the one dev
|
|
53
|
+
// build that needed it silent. (The old local copy justified itself as keeping this module
|
|
54
|
+
// import-free for the tree-shaken analytics bundle; that had already lapsed — it imports
|
|
55
|
+
// `makeSessionId` right here — and `utils/warnInDev` has no imports of its own.)
|
|
56
|
+
import { warnInDev } from "../utils/warnInDev";
|
|
51
57
|
|
|
52
58
|
/**
|
|
53
59
|
* Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
|
|
@@ -83,26 +89,6 @@ export const resetCurrentSessionId = (): void => {
|
|
|
83
89
|
globalSlot[CURRENT_SESSION_ID_SLOT] = undefined;
|
|
84
90
|
};
|
|
85
91
|
|
|
86
|
-
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
87
|
-
declare const __DEV__: boolean | undefined;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests.
|
|
91
|
-
* Same idiom as `analyticsFacade.warnInDev` — deliberately duplicated rather than imported, so this
|
|
92
|
-
* module keeps its zero-import-weight for the tree-shaken analytics bundle.
|
|
93
|
-
*
|
|
94
|
-
* Returns whether it ACTUALLY warned, so the caller's once-flag is spent on a warning a developer
|
|
95
|
-
* saw. Marking "already warned" after a no-op would burn the single warning in prod, and the one
|
|
96
|
-
* dev build that needed it would then run silent.
|
|
97
|
-
*/
|
|
98
|
-
const warnInDev = (message: string): boolean => {
|
|
99
|
-
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
100
|
-
console.warn(message);
|
|
101
|
-
return true;
|
|
102
|
-
}
|
|
103
|
-
return false;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
92
|
/** The one-time message. Hoisted so a prod mint does not rebuild a string nobody will read. */
|
|
107
93
|
const MINT_WARNING =
|
|
108
94
|
"[wireai] No app-open session was registered, so a session id was minted for this event " +
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type ClientEventTarget,
|
|
32
32
|
} from "./reportClientEvent";
|
|
33
33
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
34
|
+
import { warnInDev } from "../utils/warnInDev";
|
|
34
35
|
|
|
35
36
|
/** Envelope source: a fixed envelope or a provider evaluated at enqueue time (fresh network type). */
|
|
36
37
|
export type EnvelopeSource = ContextEnvelope | (() => ContextEnvelope | undefined);
|
|
@@ -85,6 +86,66 @@ const DEFAULTS = {
|
|
|
85
86
|
/** Ceiling on the persisted-backlog read — a hung adapter degrades to an empty start, never a stall. */
|
|
86
87
|
const READ_TIMEOUT_MS = 1500;
|
|
87
88
|
|
|
89
|
+
// ── One storage slot per QUEUE, not per appId (K4) ───────────────────────────────────────────────
|
|
90
|
+
//
|
|
91
|
+
// The default key was derived from `appId` alone, so two `createAnalytics` instances for one tenant —
|
|
92
|
+
// the documented double-wiring, a façade for `track`/`screen` plus an activation instance — shared ONE
|
|
93
|
+
// persisted backlog while keeping SEPARATE in-memory buffers. Every symptom of that is silent:
|
|
94
|
+
// each `persist()` overwrites the other's blob with its own view of "pending"; a queue that drains to
|
|
95
|
+
// empty calls `removeItem` and DELETES a sibling's still-pending events; and on relaunch whatever
|
|
96
|
+
// survived is loaded by both instances and sent twice. `useLifecycleEvents` already avoided all of it
|
|
97
|
+
// by handing its queue a dedicated explicit key — this generalizes that.
|
|
98
|
+
//
|
|
99
|
+
// A `Symbol.for` slot for the same reason as every other registry here: tsup inlines a copy of this
|
|
100
|
+
// module into each bundle, and a plain module `let` would let the `.` and `./analytics` copies each
|
|
101
|
+
// think they were the first claimant of the same key.
|
|
102
|
+
const QUEUE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:eventQueueKeys");
|
|
103
|
+
|
|
104
|
+
type GlobalWithQueueKeys = typeof globalThis & { [QUEUE_KEY_SLOT]?: Set<string> };
|
|
105
|
+
|
|
106
|
+
const queueKeyGlobal = globalThis as GlobalWithQueueKeys;
|
|
107
|
+
|
|
108
|
+
const claimedQueueKeys = (): Set<string> => {
|
|
109
|
+
const existing = queueKeyGlobal[QUEUE_KEY_SLOT];
|
|
110
|
+
if (existing) return existing;
|
|
111
|
+
const created = new Set<string>();
|
|
112
|
+
queueKeyGlobal[QUEUE_KEY_SLOT] = created;
|
|
113
|
+
return created;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Claim `preferred` for this queue, or the next free `preferred#N` when a live queue already holds it.
|
|
118
|
+
*
|
|
119
|
+
* ONLY the appId-derived DEFAULT is ever rotated. An EXPLICIT `storageKey` is the host (or
|
|
120
|
+
* `useLifecycleEvents`) declaring which slot a queue owns, and that hook re-creates its queue on every
|
|
121
|
+
* remount — rotating there would silently walk it off its own backlog once per remount, which is a
|
|
122
|
+
* worse bug than the one being fixed.
|
|
123
|
+
*/
|
|
124
|
+
const claimQueueKey = (preferred: string, explicit: boolean): string => {
|
|
125
|
+
const claimed = claimedQueueKeys();
|
|
126
|
+
if (explicit || !claimed.has(preferred)) {
|
|
127
|
+
claimed.add(preferred);
|
|
128
|
+
return preferred;
|
|
129
|
+
}
|
|
130
|
+
let ordinal = 2;
|
|
131
|
+
while (claimed.has(`${preferred}#${ordinal}`)) ordinal++;
|
|
132
|
+
const key = `${preferred}#${ordinal}`;
|
|
133
|
+
claimed.add(key);
|
|
134
|
+
warnInDev(
|
|
135
|
+
`[wireai] a second event queue was created for the same appId, and "${preferred}" is already ` +
|
|
136
|
+
"claimed by a live one. Two queues sharing one storage slot overwrite each other's backlog, " +
|
|
137
|
+
"delete each other's pending events when one drains to empty, and double-send on relaunch, so " +
|
|
138
|
+
`this queue was given "${key}" instead. Prefer ONE analytics instance per app; if you really ` +
|
|
139
|
+
"need two, pass an explicit `storageKey` to each so the slots are yours to reason about.",
|
|
140
|
+
);
|
|
141
|
+
return key;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Test-only: forget every claimed queue key. A real RELAUNCH is a new process, so a test that
|
|
145
|
+
* simulates one in-process must call this or its second queue reads as a concurrent sibling.
|
|
146
|
+
* Exported from `@wireai/activation/analytics`, matching `resetAutoDeviceKeys` / `resetCurrentSessionId`. */
|
|
147
|
+
export const resetEventQueueKeys = (): void => claimedQueueKeys().clear();
|
|
148
|
+
|
|
88
149
|
/** Internal buffered item. `id` is a local monotonic handle for deterministic dequeue-after-ack;
|
|
89
150
|
* it is NEVER sent to the server. `sig` is the de-dup signature (serialized stamped event). */
|
|
90
151
|
type QueuedItem = { id: number; event: ClientEvent; sig: string };
|
|
@@ -137,7 +198,14 @@ const parsePersisted = (raw: string | null | undefined): PersistedItem[] => {
|
|
|
137
198
|
export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
138
199
|
const target = options.target;
|
|
139
200
|
const storage = options.storage;
|
|
140
|
-
|
|
201
|
+
// One slot per QUEUE (K4): an explicit key is taken verbatim; the appId-derived default is rotated
|
|
202
|
+
// to `…#2` when a live queue already holds it, so two instances can never share one backlog.
|
|
203
|
+
//
|
|
204
|
+
// Only claimed when there IS storage. The defect is entirely about the persisted slot, and a
|
|
205
|
+
// storage-less queue (the documented degraded in-memory mode) never reads or writes the key — so
|
|
206
|
+
// claiming there would warn about a collision that cannot happen.
|
|
207
|
+
const defaultKey = options.storageKey ?? `wireai:evtq:${options.appId ?? "default"}`;
|
|
208
|
+
const key = storage ? claimQueueKey(defaultKey, options.storageKey !== undefined) : defaultKey;
|
|
141
209
|
const maxSize = options.maxSize ?? DEFAULTS.maxSize;
|
|
142
210
|
const batchSize = options.batchSize ?? DEFAULTS.batchSize;
|
|
143
211
|
const baseBackoffMs = options.baseBackoffMs ?? DEFAULTS.baseBackoffMs;
|
|
@@ -170,6 +238,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
170
238
|
// from a REDUNDANT re-enqueue of the same instant. The de-dup signature below includes it, so
|
|
171
239
|
// two identical events enqueued in the same millisecond still collapse (a re-render), while the
|
|
172
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.
|
|
173
243
|
if (stamped.ts === undefined) stamped.ts = Date.now();
|
|
174
244
|
if (!env) return stamped;
|
|
175
245
|
if (!stamped.device && env.device) stamped.device = env.device;
|
package/src/analytics/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ export { buildContextEnvelope } from "./contextEnvelope";
|
|
|
49
49
|
export type { ContextEnvelope, ContextEnvelopeInput } from "./contextEnvelope";
|
|
50
50
|
|
|
51
51
|
// ─── Offline-first, persistent, batched + retried event queue (dependency-free) ─
|
|
52
|
-
export { createEventQueue } from "./eventQueue";
|
|
52
|
+
export { createEventQueue, resetEventQueueKeys } from "./eventQueue";
|
|
53
53
|
export type { EventQueue, EventQueueOptions, EnvelopeSource } from "./eventQueue";
|
|
54
54
|
|
|
55
55
|
// ─── Developer-facing analytics façade (track / screen / identify) over the queue ─
|
|
@@ -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,23 +164,91 @@ 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
|
|
|
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. */
|
|
179
|
+
type EventsAck = { written?: number; skipped: number; reasons: string[] };
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Read the `/v1/events` ACK body. Resolves `undefined` when there is nothing readable — no `.json`
|
|
183
|
+
* (an old server, a bare test mock), an already-consumed body, or a hostile response object. NEVER
|
|
184
|
+
* throws, and NEVER dev-gated: the `skipped` count is a RETURN VALUE for the awaitable path, not only
|
|
185
|
+
* a warning, so it has to be read in production too.
|
|
186
|
+
*
|
|
187
|
+
* ⚠️ The body can be read exactly once, so a caller that both decides on `skipped` AND warns must do
|
|
188
|
+
* both from ONE call to this.
|
|
189
|
+
*/
|
|
190
|
+
const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
|
|
191
|
+
try {
|
|
192
|
+
const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
|
|
193
|
+
if (typeof json !== "function") return undefined;
|
|
194
|
+
const body = (await Promise.resolve(json.call(res))) as
|
|
195
|
+
| { written?: unknown; skipped?: unknown; errors?: unknown }
|
|
196
|
+
| null
|
|
197
|
+
| undefined;
|
|
198
|
+
const skipped = body?.skipped;
|
|
199
|
+
if (typeof skipped !== "number" || !Number.isFinite(skipped)) return undefined;
|
|
200
|
+
const reasons = Array.isArray(body?.errors)
|
|
201
|
+
? body.errors
|
|
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
|
+
})
|
|
213
|
+
.filter((r): r is string => typeof r === "string")
|
|
214
|
+
: [];
|
|
215
|
+
return { written: typeof body?.written === "number" ? body.written : undefined, skipped, reasons };
|
|
216
|
+
} catch {
|
|
217
|
+
// Unreadable / already-consumed body / hostile object — best-effort, treat as "no ack".
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The DISCARDED warning text, built from what the server ACTUALLY said.
|
|
224
|
+
*
|
|
225
|
+
* It used to staple a cause onto a bare integer — "the usual cause is a missing or empty session_id".
|
|
226
|
+
* Since `ensureCurrentSessionId` shipped, no kit path can emit an event without a `session_id`, so
|
|
227
|
+
* that is now the LEAST likely explanation, and naming it sent every reader looking in the one place
|
|
228
|
+
* the problem is not. The endpoint folds four real failures and two idempotent no-ops into one
|
|
229
|
+
* integer, so unless the server volunteers reasons, the honest thing to report is the count and where
|
|
230
|
+
* the reason lives.
|
|
231
|
+
*/
|
|
232
|
+
const describeDiscarded = (ack: EventsAck): string =>
|
|
233
|
+
`[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${ack.skipped} event(s) ` +
|
|
234
|
+
"(skipped in the response body) — they are gone, not retried. The server reported: " +
|
|
235
|
+
`skipped=${ack.skipped}${ack.written !== undefined ? `, written=${ack.written}` : ""}` +
|
|
236
|
+
(ack.reasons.length > 0
|
|
237
|
+
? `, reasons: ${ack.reasons.join(", ")}.`
|
|
238
|
+
: ". It gave no reason (the endpoint folds every rejection into one count), so check the " +
|
|
239
|
+
"server's ingest log for this request rather than guessing.");
|
|
240
|
+
|
|
131
241
|
/** RN sets this global; absent under node/SSR. Read defensively inside {@link warnOnSkippedEvents}. */
|
|
132
242
|
declare const __DEV__: boolean | undefined;
|
|
133
243
|
|
|
134
244
|
/**
|
|
135
245
|
* Read the `/v1/events` ACK body and warn (dev builds only) when the server DISCARDED events.
|
|
136
246
|
*
|
|
137
|
-
* The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
247
|
+
* The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses is counted
|
|
248
|
+
* in `skipped`, never surfaced in the status code. Every send path here reads `res.ok` alone, so a
|
|
249
|
+
* whole batch can evaporate behind a green response. As of 0.13.0 ALL FOUR send paths consume the ack:
|
|
250
|
+
* the offline queue, session-start, the fire-and-forget POST, and the awaitable one (which also acts
|
|
251
|
+
* on it — see {@link reportClientEventsAwait}).
|
|
141
252
|
*
|
|
142
253
|
* LOG ONLY: returns immediately, never throws, and never influences retry / dequeue / return values.
|
|
143
254
|
* A response with no usable `.json` (an old server, a test mock) is silently ignored.
|
|
@@ -149,25 +260,10 @@ declare const __DEV__: boolean | undefined;
|
|
|
149
260
|
export const warnOnSkippedEvents = (res: unknown): void => {
|
|
150
261
|
if (typeof __DEV__ === "undefined" || !__DEV__) return;
|
|
151
262
|
if (typeof console === "undefined" || !console.warn) return;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.then((body) => {
|
|
157
|
-
const skipped = (body as { skipped?: unknown } | null | undefined)?.skipped;
|
|
158
|
-
if (typeof skipped !== "number" || skipped <= 0) return;
|
|
159
|
-
console.warn(
|
|
160
|
-
`[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) ` +
|
|
161
|
-
"(skipped in the response body) — they are gone, not retried. The usual cause is an " +
|
|
162
|
-
"event with a missing or empty session_id.",
|
|
163
|
-
);
|
|
164
|
-
})
|
|
165
|
-
.catch(() => {
|
|
166
|
-
// Unreadable / already-consumed body — best-effort logging, swallow.
|
|
167
|
-
});
|
|
168
|
-
} catch {
|
|
169
|
-
// A hostile response object — swallow.
|
|
170
|
-
}
|
|
263
|
+
void readEventsAck(res).then((ack) => {
|
|
264
|
+
if (!ack || ack.skipped <= 0) return;
|
|
265
|
+
console.warn(describeDiscarded(ack));
|
|
266
|
+
});
|
|
171
267
|
};
|
|
172
268
|
|
|
173
269
|
/**
|
|
@@ -182,9 +278,16 @@ export const reportClientEvents = (
|
|
|
182
278
|
try {
|
|
183
279
|
const req = buildEventsRequest(target, events);
|
|
184
280
|
if (!req) return;
|
|
185
|
-
void fetch(req.url, req.init)
|
|
186
|
-
|
|
187
|
-
|
|
281
|
+
void fetch(req.url, req.init)
|
|
282
|
+
.then((res) => {
|
|
283
|
+
// Still fire-and-forget — nothing is retried, nothing is returned. But a batch that
|
|
284
|
+
// evaporated behind a 200 is exactly what this path used to make invisible, so in dev it
|
|
285
|
+
// now says so, like the queue and session-start paths already did.
|
|
286
|
+
warnOnSkippedEvents(res);
|
|
287
|
+
})
|
|
288
|
+
.catch(() => {
|
|
289
|
+
// Network/transport error — analytics is best-effort, swallow.
|
|
290
|
+
});
|
|
188
291
|
} catch {
|
|
189
292
|
// A missing `fetch` — swallow.
|
|
190
293
|
}
|
|
@@ -201,8 +304,17 @@ export const reportClientEvent = (
|
|
|
201
304
|
* `/v1/events` path, but resolve only once the server has RESPONDED — so a decision re-fetch fired
|
|
202
305
|
* immediately after is guaranteed to see the event in the session stream (this is the guarantee
|
|
203
306
|
* `wire.track` needs before it triggers decision revalidation). Never throws: a missing/invalid
|
|
204
|
-
* target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`.
|
|
205
|
-
*
|
|
307
|
+
* target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`.
|
|
308
|
+
*
|
|
309
|
+
* ⚠️ 0.13.0: a 2xx is NO LONGER SUFFICIENT. The endpoint answers HTTP 200 with `{ ok, written,
|
|
310
|
+
* skipped }` and counts an event it refuses in `skipped`, so this used to resolve `true` for an event
|
|
311
|
+
* the server had thrown away — and `wire.track` then bumped decision revalidation, making every
|
|
312
|
+
* subscribed gate re-fetch against a stream the action never entered. It now reads the ack and
|
|
313
|
+
* resolves `false` when the server reports a positive `skipped`.
|
|
314
|
+
*
|
|
315
|
+
* BACKWARD COMPATIBLE BY CONSTRUCTION: only an explicit positive `skipped` demotes a 200. An old
|
|
316
|
+
* server that sends no such field, a body that cannot be parsed, or a response with no `.json` at all
|
|
317
|
+
* resolves `true` exactly as before — the change can produce no false negatives.
|
|
206
318
|
*/
|
|
207
319
|
export const reportClientEventsAwait = async (
|
|
208
320
|
target: ClientEventTarget | undefined,
|
|
@@ -212,7 +324,14 @@ export const reportClientEventsAwait = async (
|
|
|
212
324
|
const req = buildEventsRequest(target, events);
|
|
213
325
|
if (!req) return false;
|
|
214
326
|
const res = await fetch(req.url, req.init);
|
|
215
|
-
|
|
327
|
+
if (!res || !res.ok) return false;
|
|
328
|
+
// ONE body read, used for both the verdict and the dev warning — it can only be read once.
|
|
329
|
+
const ack = await readEventsAck(res);
|
|
330
|
+
if (!ack || ack.skipped <= 0) return true;
|
|
331
|
+
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
332
|
+
console.warn(describeDiscarded(ack));
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
216
335
|
} catch {
|
|
217
336
|
// Unreachable / missing-fetch / network — best-effort, report failure.
|
|
218
337
|
return false;
|