@wireai/activation 0.13.3 → 0.13.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/AGENTS.md +3 -1
- package/CHANGELOG.md +63 -0
- package/INTEGRATION_PROMPT.md +7 -1
- package/README.md +52 -0
- package/dist/analytics/index.d.mts +86 -6
- package/dist/analytics/index.d.ts +86 -6
- package/dist/analytics/index.js +167 -2
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +167 -3
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +1 -1
- package/dist/coachmarks/index.d.ts +1 -1
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-BoWtr3Jp.d.mts → currentSession-DgJf0fRz.d.mts} +2 -2
- package/dist/{currentSession-CCOMlaQ2.d.ts → currentSession-EOHU64QD.d.ts} +2 -2
- package/dist/{decision-Cjw0jbEj.d.mts → decision-Bgo17oH7.d.mts} +1 -1
- package/dist/{decision-GfpXAsk_.d.ts → decision-Bkh_LigV.d.ts} +1 -1
- package/dist/index.d.mts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4 -2
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +3 -3
- package/dist/questionnaire/index.d.ts +3 -3
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +5 -5
- package/dist/reviews/index.d.ts +5 -5
- package/dist/reviews/index.js +4 -2
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +4 -2
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +1 -1
- package/dist/showcase/index.d.ts +1 -1
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-DFuPyCoJ.d.ts → transport-B_0SgCBe.d.ts} +1 -1
- package/dist/{transport-CefdERPs.d.mts → transport-j5gFfJhK.d.mts} +1 -1
- package/dist/{types-CKFhyrMu.d.mts → types-BpwiRpA8.d.mts} +1 -1
- package/dist/{types-CKFhyrMu.d.ts → types-BpwiRpA8.d.ts} +1 -1
- package/dist/{types-l2tfg23P.d.mts → types-Cju-1_jT.d.mts} +1 -1
- package/dist/{types-UVYt9BJe.d.ts → types-h2BZvl1t.d.ts} +1 -1
- package/llms.txt +1 -1
- package/package.json +3 -1
- package/src/activation/revalidation.ts +9 -1
- package/src/analytics/currentSession.ts +10 -0
- package/src/analytics/eventQueue.ts +5 -0
- package/src/analytics/index.ts +6 -0
- package/src/analytics/reportClientEvent.ts +29 -7
- package/src/analytics/wireDoctor.ts +330 -0
- package/src/coachmarks/runtime.ts +10 -1
- package/src/context/deviceId.ts +10 -1
- package/src/features/WireFeaturesProvider.tsx +8 -0
- package/src/identity/identityRecord.ts +9 -1
- package/src/reviews/runtime.ts +11 -1
- package/src/theme/ThemeContext.tsx +7 -0
|
@@ -122,10 +122,15 @@ export const makeSessionId = (): string =>
|
|
|
122
122
|
* `{written: 0, skipped: N, errors: [{index, reason: "validation_error", field: "ts"}]}` — every
|
|
123
123
|
* `app_event` from `track()`/`screen()` silently discarded behind a green response.
|
|
124
124
|
*
|
|
125
|
-
* WHY HERE and not at the queue's `stamp()`: this is the single choke point all
|
|
126
|
-
* through (offline queue, fire-and-forget, awaitable, session-start). Converting
|
|
127
|
-
* queue's numeric `ts` — and therefore its identical-JSON de-dup signature — exactly
|
|
128
|
-
* it also converts the persisted 0.13.0 backlogs (which hold a numeric `ts`) on their
|
|
125
|
+
* WHY HERE and not at the queue's `stamp()`: this is the single choke point all FIVE send paths go
|
|
126
|
+
* through (offline queue, fire-and-forget, awaitable, review transport, session-start). Converting
|
|
127
|
+
* here leaves the queue's numeric `ts` — and therefore its identical-JSON de-dup signature — exactly
|
|
128
|
+
* as it was, and it also converts the persisted 0.13.0 backlogs (which hold a numeric `ts`) on their
|
|
129
|
+
* way out.
|
|
130
|
+
*
|
|
131
|
+
* ⚠️ NOT the same list as the ack-consumer list in {@link warnOnSkippedEvents}, which is FOUR. The
|
|
132
|
+
* review transport builds its request here but fires and forgets without reading the response, so
|
|
133
|
+
* it rides this conversion and is absent from that one. Count the call sites before editing either.
|
|
129
134
|
*
|
|
130
135
|
* NEVER mutates the caller's event: an event that needs a change is copied. A string `ts` (a
|
|
131
136
|
* caller-set ISO stamp) and an absent `ts` pass through untouched. A non-finite (`NaN`/`Infinity`)
|
|
@@ -154,17 +159,30 @@ const toWireEvents = (events: ClientEvent[]): ClientEvent[] =>
|
|
|
154
159
|
*
|
|
155
160
|
* It is also where the internal epoch-ms `ts` becomes the wire's ISO8601 string — see
|
|
156
161
|
* {@link toWireEvents} for why the conversion belongs at this choke point.
|
|
162
|
+
*
|
|
163
|
+
* `options.dryRun` asks the server to VALIDATE the batch and write nothing, which is what the wire
|
|
164
|
+
* doctor's round-trip check needs: a real POST, through this one builder, that cannot pollute a
|
|
165
|
+
* tenant's funnel. The `dry_run` key is added ONLY on an explicit `true`, so every production
|
|
166
|
+
* caller (which passes no options at all) still serializes the exact same bytes it did before the
|
|
167
|
+
* parameter existed. That byte-identity is asserted in `buildEventsRequest.test.ts`; every shipped
|
|
168
|
+
* send path rides this envelope, so a diagnostic is not allowed to change it for them.
|
|
157
169
|
*/
|
|
158
170
|
export const buildEventsRequest = (
|
|
159
171
|
target: { serverUrl: string; apiKey?: string } | undefined,
|
|
160
172
|
events: ClientEvent[],
|
|
173
|
+
options?: { dryRun?: boolean },
|
|
161
174
|
): { url: string; init: RequestInit } | null => {
|
|
162
175
|
if (!target?.serverUrl || events.length === 0) return null;
|
|
163
176
|
try {
|
|
164
177
|
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
165
178
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
166
179
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
167
|
-
|
|
180
|
+
// The key is CONDITIONALLY assigned rather than set to a falsy default: an absent property and
|
|
181
|
+
// a `dry_run: false` property do not serialize the same, and only absence keeps the body byte
|
|
182
|
+
// identical for the production call sites.
|
|
183
|
+
const envelope: { events: ClientEvent[]; dry_run?: true } = { events: toWireEvents(events) };
|
|
184
|
+
if (options?.dryRun === true) envelope.dry_run = true;
|
|
185
|
+
const body = JSON.stringify(envelope);
|
|
168
186
|
return { url, init: { method: "POST", headers, body } };
|
|
169
187
|
} catch {
|
|
170
188
|
// URL construction or JSON serialization failed — nothing to send.
|
|
@@ -176,7 +194,7 @@ export const buildEventsRequest = (
|
|
|
176
194
|
* each entry carrying `{index, reason, field}` — `field` names the property that failed validation
|
|
177
195
|
* (it is what identified the `ts` rejection), so it is folded into the reason string here. Still
|
|
178
196
|
* read defensively: an older server sends no `errors` at all. */
|
|
179
|
-
type EventsAck = { written?: number; skipped: number; reasons: string[] };
|
|
197
|
+
export type EventsAck = { written?: number; skipped: number; reasons: string[] };
|
|
180
198
|
|
|
181
199
|
/**
|
|
182
200
|
* Read the `/v1/events` ACK body. Resolves `undefined` when there is nothing readable — no `.json`
|
|
@@ -186,8 +204,12 @@ type EventsAck = { written?: number; skipped: number; reasons: string[] };
|
|
|
186
204
|
*
|
|
187
205
|
* ⚠️ The body can be read exactly once, so a caller that both decides on `skipped` AND warns must do
|
|
188
206
|
* both from ONE call to this.
|
|
207
|
+
*
|
|
208
|
+
* Exported from this MODULE (not from the `./analytics` barrel) so the wire doctor can read an ack
|
|
209
|
+
* with the same reader the send paths use. A second ack reader would be a second thing to drift,
|
|
210
|
+
* and the drift it exists to catch is exactly the kind that hides behind a 200.
|
|
189
211
|
*/
|
|
190
|
-
const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
|
|
212
|
+
export const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
|
|
191
213
|
try {
|
|
192
214
|
const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
|
|
193
215
|
if (typeof json !== "function") return undefined;
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireDoctor: a DEV-ONLY, opt-in self-check that proves a fresh integration end to end before the
|
|
3
|
+
* first real user ever runs it.
|
|
4
|
+
*
|
|
5
|
+
* WHY IT EXISTS: the `/v1/events` endpoint answers HTTP **200** for a batch it throws away. It
|
|
6
|
+
* reports the refusal in the response body (`{written, skipped, errors:[{reason, field}]}`), so a
|
|
7
|
+
* mis-wired integration looks perfectly healthy from the outside while every event evaporates. A
|
|
8
|
+
* numeric `ts` did exactly that through 0.13.0. This turns that class of loss from something you
|
|
9
|
+
* discover in a funnel report weeks later into something the first run tells you.
|
|
10
|
+
*
|
|
11
|
+
* SHAPE: four independent checks, each a `{name, ok, detail}` unit that can be read (and tested)
|
|
12
|
+
* without the others. The report is data, never a thrown error and never a side effect on the host:
|
|
13
|
+
*
|
|
14
|
+
* 1. `target` : is there a server URL and a key, and do they look like a key and a URL?
|
|
15
|
+
* 2. `reachability`: is the server actually there? (`GET /v1/events/contract`, public and cheap)
|
|
16
|
+
* 3. `storage` : can the offline queue persist? (a write / read / delete probe)
|
|
17
|
+
* 4. `round_trip` : does a REAL event survive REAL server validation? (a `dry_run` POST)
|
|
18
|
+
*
|
|
19
|
+
* NEVER THROWS, under any input, any network condition, or any hostile response object. A doctor
|
|
20
|
+
* that can crash the screen it is diagnosing is worse than no doctor.
|
|
21
|
+
*
|
|
22
|
+
* ⛔ NEVER PRINTS A SECRET. The `target` check reports the key's SHAPE (present / absent, length,
|
|
23
|
+
* whether the prefix is the expected one) and never any character of the key itself, in any
|
|
24
|
+
* `detail`, any log line, or any error. `wireDoctor.test.ts` asserts that with a sentinel key.
|
|
25
|
+
*
|
|
26
|
+
* COSTS A NON-CALLER NOTHING: it is a plain function behind the `./analytics` subpath, the package
|
|
27
|
+
* is `sideEffects: false`, and this module has no top-level side effects, so a host that never
|
|
28
|
+
* imports it never bundles it. It also mints no `globalThis` slot and retains no timer or listener.
|
|
29
|
+
*
|
|
30
|
+
* USAGE (dev builds only):
|
|
31
|
+
*
|
|
32
|
+
* import { wireDoctor } from "@wireai/activation/analytics";
|
|
33
|
+
*
|
|
34
|
+
* const report = await wireDoctor({
|
|
35
|
+
* target: { serverUrl: "https://api.example.com", apiKey: DRIVELINE_KEY },
|
|
36
|
+
* storage: AsyncStorage,
|
|
37
|
+
* });
|
|
38
|
+
* console.log(report.ok, report.checks);
|
|
39
|
+
*/
|
|
40
|
+
// Imported DIRECTLY from the transport module, never through `./index`: the barrel would pull the
|
|
41
|
+
// whole analytics surface into anything that touches the doctor. `readEventsAck` is module-exported
|
|
42
|
+
// for exactly this, so the doctor reads an ack with the SAME reader the send paths use rather than
|
|
43
|
+
// growing a second one to drift.
|
|
44
|
+
import {
|
|
45
|
+
buildEventsRequest,
|
|
46
|
+
makeSessionId,
|
|
47
|
+
readEventsAck,
|
|
48
|
+
type ClientEvent,
|
|
49
|
+
type ClientEventTarget,
|
|
50
|
+
} from "./reportClientEvent";
|
|
51
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
52
|
+
|
|
53
|
+
/** RN sets this global; absent under node/SSR. Read defensively, exactly as `warnOnSkippedEvents` does. */
|
|
54
|
+
declare const __DEV__: boolean | undefined;
|
|
55
|
+
|
|
56
|
+
/** One diagnosis. `name` is stable and machine-readable; `detail` is for a human reading a console. */
|
|
57
|
+
export type WireDoctorCheck = {
|
|
58
|
+
/**
|
|
59
|
+
* Stable id: `target` | `reachability` | `storage` | `round_trip` | `dev_only` | `internal_error`.
|
|
60
|
+
*
|
|
61
|
+
* `dev_only` means ONE thing and only that thing: `__DEV__` is unset or false, so nothing ran.
|
|
62
|
+
* `internal_error` is the separate catch-all for a failure that got past every check's own
|
|
63
|
+
* swallow. They are distinct names because a consumer branching on `dev_only` would otherwise
|
|
64
|
+
* read an internal fault as "this is a release build" and report a healthy skip.
|
|
65
|
+
*/
|
|
66
|
+
name: string;
|
|
67
|
+
ok: boolean;
|
|
68
|
+
/** Human-readable result. ⛔ Never contains any part of an API key. */
|
|
69
|
+
detail: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** What {@link wireDoctor} resolves to. `ok` is true only when EVERY check passed. */
|
|
73
|
+
export type WireDoctorReport = {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
checks: WireDoctorCheck[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Input for {@link wireDoctor}. `storage` is optional: without it the queue runs in-memory only. */
|
|
79
|
+
export type WireDoctorOptions = {
|
|
80
|
+
/** The same `{serverUrl, apiKey}` the kit is configured with. */
|
|
81
|
+
target: ClientEventTarget | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* The host storage the offline queue would use (AsyncStorage-compatible). Omit it and the storage
|
|
84
|
+
* check reports the DEGRADED in-memory mode rather than failing.
|
|
85
|
+
*/
|
|
86
|
+
storage?: WireOnboardingStorage;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The probe's own storage key. Deliberately NOT under the queue's `wireai:evtq:` namespace, so a
|
|
91
|
+
* doctor run can never read, overwrite or delete a pending backlog. A diagnostic that eats a user's
|
|
92
|
+
* unsent events is a worse bug than the one it was written to find.
|
|
93
|
+
*/
|
|
94
|
+
const PROBE_STORAGE_KEY = "wireai:doctor:probe";
|
|
95
|
+
|
|
96
|
+
/** The `question_key` the synthetic round-trip event carries. `dry_run` means it is never written. */
|
|
97
|
+
const PROBE_QUESTION_KEY = "wire_doctor_probe";
|
|
98
|
+
|
|
99
|
+
/** Tenant keys start with this. Used ONLY for a boolean comparison; ⛔ never interpolated into a detail. */
|
|
100
|
+
const EXPECTED_KEY_PREFIX = "wai_";
|
|
101
|
+
|
|
102
|
+
/** Ceiling on each network check, so a hung server degrades to a failed check, never a hung caller. */
|
|
103
|
+
const NETWORK_TIMEOUT_MS = 10_000;
|
|
104
|
+
|
|
105
|
+
const check = (name: string, ok: boolean, detail: string): WireDoctorCheck => ({ name, ok, detail });
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `fetch` with a timeout that is ALWAYS cleared, including on rejection. Resolves `undefined`
|
|
109
|
+
* instead of throwing, so every caller stays on the happy path. Returns no timer to the caller and
|
|
110
|
+
* leaves none pending: a diagnostic must not keep the JS thread alive after it has answered.
|
|
111
|
+
*/
|
|
112
|
+
const fetchWithTimeout = async (url: string, init?: RequestInit): Promise<Response | undefined> => {
|
|
113
|
+
if (typeof fetch === "undefined") return undefined;
|
|
114
|
+
const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
115
|
+
const timer = setTimeout(() => controller?.abort(), NETWORK_TIMEOUT_MS);
|
|
116
|
+
try {
|
|
117
|
+
return await fetch(url, controller ? { ...init, signal: controller.signal } : init);
|
|
118
|
+
} catch {
|
|
119
|
+
// Unreachable host, DNS failure, abort, or a missing fetch implementation.
|
|
120
|
+
return undefined;
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* CHECK 1: is the target usable at all?
|
|
128
|
+
*
|
|
129
|
+
* Reports the key's shape and NEVER its content: present/absent, character length, and whether the
|
|
130
|
+
* prefix matches what a tenant key starts with. Length and a yes/no are enough to tell "you pasted
|
|
131
|
+
* the wrong string" from "you pasted nothing", which is the entire diagnostic value here.
|
|
132
|
+
*/
|
|
133
|
+
const checkTarget = (target: ClientEventTarget | undefined): WireDoctorCheck => {
|
|
134
|
+
const serverUrl = target?.serverUrl;
|
|
135
|
+
if (typeof serverUrl !== "string" || serverUrl.trim().length === 0) {
|
|
136
|
+
return check("target", false, "no serverUrl configured: set `serverUrl` on the kit config.");
|
|
137
|
+
}
|
|
138
|
+
let parsedHost = false;
|
|
139
|
+
try {
|
|
140
|
+
const parsed = new URL(serverUrl);
|
|
141
|
+
parsedHost = parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
142
|
+
} catch {
|
|
143
|
+
parsedHost = false;
|
|
144
|
+
}
|
|
145
|
+
if (!parsedHost) {
|
|
146
|
+
return check("target", false, "serverUrl is not a valid http(s) URL.");
|
|
147
|
+
}
|
|
148
|
+
const apiKey = target?.apiKey;
|
|
149
|
+
if (typeof apiKey !== "string" || apiKey.length === 0) {
|
|
150
|
+
return check("target", false, "serverUrl looks valid, but no apiKey is configured.");
|
|
151
|
+
}
|
|
152
|
+
// ⛔ `EXPECTED_KEY_PREFIX` is compared, never printed: it is itself a substring of a real key.
|
|
153
|
+
const prefixOk = apiKey.startsWith(EXPECTED_KEY_PREFIX);
|
|
154
|
+
return check(
|
|
155
|
+
"target",
|
|
156
|
+
true,
|
|
157
|
+
`serverUrl is a valid http(s) URL; apiKey present (${apiKey.length} chars, expected prefix: ${
|
|
158
|
+
prefixOk ? "yes" : "no"
|
|
159
|
+
}).`,
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* CHECK 2: is the server there, and is it a Wire server?
|
|
165
|
+
*
|
|
166
|
+
* `GET /v1/events/contract` is public, cheap, and it answers the question the round-trip check
|
|
167
|
+
* cannot answer on its own: a failure here means "wrong URL / server down", not "bad payload".
|
|
168
|
+
*/
|
|
169
|
+
const checkReachability = async (serverUrl: string): Promise<WireDoctorCheck> => {
|
|
170
|
+
const url = `${serverUrl.replace(/\/$/, "")}/v1/events/contract`;
|
|
171
|
+
const res = await fetchWithTimeout(url, { method: "GET" });
|
|
172
|
+
if (!res) {
|
|
173
|
+
return check("reachability", false, "could not reach the server (network error or timeout).");
|
|
174
|
+
}
|
|
175
|
+
const status = (res as { status?: unknown }).status;
|
|
176
|
+
const ok = !!(res as { ok?: boolean }).ok;
|
|
177
|
+
if (!ok) {
|
|
178
|
+
return check(
|
|
179
|
+
"reachability",
|
|
180
|
+
false,
|
|
181
|
+
`the server answered ${typeof status === "number" ? status : "an error"} for the events contract; ` +
|
|
182
|
+
"check the serverUrl points at a Wire server.",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return check("reachability", true, "the server answered the events contract.");
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* CHECK 3: can the offline queue actually persist?
|
|
190
|
+
*
|
|
191
|
+
* Write, read back, compare. Without working storage the queue silently degrades to in-memory only,
|
|
192
|
+
* so a backgrounded app loses whatever it had not flushed, and nothing anywhere says so.
|
|
193
|
+
*
|
|
194
|
+
* The probe key is REMOVED in a `finally`, so a failing read or a throwing adapter still cleans up.
|
|
195
|
+
*/
|
|
196
|
+
const checkStorage = async (storage: WireOnboardingStorage | undefined): Promise<WireDoctorCheck> => {
|
|
197
|
+
if (!storage) {
|
|
198
|
+
return check(
|
|
199
|
+
"storage",
|
|
200
|
+
true,
|
|
201
|
+
"no storage injected: the queue runs in DEGRADED in-memory mode and loses pending events on an app kill. " +
|
|
202
|
+
"Pass AsyncStorage (or an MMKV wrapper) to make it durable.",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const token = `probe_${Date.now().toString(36)}`;
|
|
206
|
+
try {
|
|
207
|
+
await storage.setItem(PROBE_STORAGE_KEY, token);
|
|
208
|
+
const read = await storage.getItem(PROBE_STORAGE_KEY);
|
|
209
|
+
if (read !== token) {
|
|
210
|
+
return check(
|
|
211
|
+
"storage",
|
|
212
|
+
false,
|
|
213
|
+
"the storage adapter accepted a write but did not read the same value back, so the queue cannot persist.",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
// ⚠️ "write / read" only: this returns BEFORE the `finally` runs its delete, and a throwing
|
|
217
|
+
// `removeItem` is deliberately swallowed there, so the delete is attempted, never asserted.
|
|
218
|
+
return check("storage", true, "storage write / read round trip succeeded.");
|
|
219
|
+
} catch {
|
|
220
|
+
return check("storage", false, "the storage adapter threw, so the queue cannot persist events.");
|
|
221
|
+
} finally {
|
|
222
|
+
// Always, including after a failed write or read: never leave the probe key behind.
|
|
223
|
+
try {
|
|
224
|
+
await storage.removeItem(PROBE_STORAGE_KEY);
|
|
225
|
+
} catch {
|
|
226
|
+
// A remove that throws is not worth failing the run over; nothing else depends on it.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* CHECK 4: does a real event survive real server validation?
|
|
233
|
+
*
|
|
234
|
+
* POSTs ONE synthetic event through the REAL {@link buildEventsRequest} with `dry_run: true`, so it
|
|
235
|
+
* travels the exact bytes a production event travels and the server validates it exactly the same
|
|
236
|
+
* way, but writes nothing. The probe is inert: it pollutes no funnel and no metric.
|
|
237
|
+
*
|
|
238
|
+
* ⚠️ The verdict is `written === 1 && skipped === 0`, read from the ACK BODY. A 200 is not a
|
|
239
|
+
* receipt here: the endpoint returns 200 for a batch it discarded. A `ts`-shaped drift shows up as
|
|
240
|
+
* `skipped: 1` with the server's own `field: "ts"`, which is the whole point of this check.
|
|
241
|
+
*/
|
|
242
|
+
const checkRoundTrip = async (target: ClientEventTarget): Promise<WireDoctorCheck> => {
|
|
243
|
+
const event: ClientEvent = {
|
|
244
|
+
event_type: "app_event",
|
|
245
|
+
session_id: makeSessionId(),
|
|
246
|
+
question_key: PROBE_QUESTION_KEY,
|
|
247
|
+
};
|
|
248
|
+
const req = buildEventsRequest(target, [event], { dryRun: true });
|
|
249
|
+
if (!req) {
|
|
250
|
+
return check("round_trip", false, "could not build the events request from this target.");
|
|
251
|
+
}
|
|
252
|
+
const res = await fetchWithTimeout(req.url, req.init);
|
|
253
|
+
if (!res) {
|
|
254
|
+
return check("round_trip", false, "the events POST failed (network error or timeout).");
|
|
255
|
+
}
|
|
256
|
+
if (!(res as { ok?: boolean }).ok) {
|
|
257
|
+
const status = (res as { status?: unknown }).status;
|
|
258
|
+
return check(
|
|
259
|
+
"round_trip",
|
|
260
|
+
false,
|
|
261
|
+
`the events endpoint answered ${typeof status === "number" ? status : "an error"}; ` +
|
|
262
|
+
"a 401 here means the apiKey is not accepted.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const ack = await readEventsAck(res);
|
|
266
|
+
if (!ack) {
|
|
267
|
+
return check(
|
|
268
|
+
"round_trip",
|
|
269
|
+
false,
|
|
270
|
+
"the server answered 2xx but sent no readable ack body, so it is unknown whether the event validated.",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (ack.written === 1 && ack.skipped === 0) {
|
|
274
|
+
return check("round_trip", true, "the server validated the probe event: written=1, skipped=0.");
|
|
275
|
+
}
|
|
276
|
+
// The server's own reasons, verbatim, each already carrying the field it refused.
|
|
277
|
+
return check(
|
|
278
|
+
"round_trip",
|
|
279
|
+
false,
|
|
280
|
+
`the server DISCARDED the probe event: written=${ack.written ?? "unknown"}, skipped=${ack.skipped}` +
|
|
281
|
+
(ack.reasons.length > 0
|
|
282
|
+
? `, reasons: ${ack.reasons.join(", ")}.`
|
|
283
|
+
: ". It gave no reason; check the server's ingest log for this request."),
|
|
284
|
+
);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Run the full diagnosis. Resolves a report; NEVER throws and NEVER rejects.
|
|
289
|
+
*
|
|
290
|
+
* DEV-ONLY BY CONTRACT: the `__DEV__` guard is the FIRST statement, so a release build performs no
|
|
291
|
+
* network call, no storage write, and no work at all. The report says it was skipped rather than
|
|
292
|
+
* pretending everything passed, because a green report that never ran is the exact failure mode
|
|
293
|
+
* this whole feature exists to remove.
|
|
294
|
+
*/
|
|
295
|
+
export const wireDoctor = async (options: WireDoctorOptions): Promise<WireDoctorReport> => {
|
|
296
|
+
if (typeof __DEV__ === "undefined" || !__DEV__) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
checks: [
|
|
300
|
+
check(
|
|
301
|
+
"dev_only",
|
|
302
|
+
false,
|
|
303
|
+
"wireDoctor is dev-only and did NOT run: __DEV__ is unset or false. Nothing was checked.",
|
|
304
|
+
),
|
|
305
|
+
],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const target = options?.target;
|
|
310
|
+
const checks: WireDoctorCheck[] = [checkTarget(target)];
|
|
311
|
+
// Reachability and the round trip both need a usable target; running them against a broken one
|
|
312
|
+
// would report a network failure and bury the real cause, which check 1 already named.
|
|
313
|
+
if (checks[0]!.ok && target) {
|
|
314
|
+
checks.push(await checkReachability(target.serverUrl));
|
|
315
|
+
checks.push(await checkStorage(options?.storage));
|
|
316
|
+
checks.push(await checkRoundTrip(target));
|
|
317
|
+
} else {
|
|
318
|
+
checks.push(await checkStorage(options?.storage));
|
|
319
|
+
}
|
|
320
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
321
|
+
} catch {
|
|
322
|
+
// Belt and braces: every check above already swallows its own failures, so reaching here means
|
|
323
|
+
// something hostile got past them. Still a report, still not a throw. The name is
|
|
324
|
+
// `internal_error`, NOT `dev_only`: this ran and broke, which is the opposite of "never ran".
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
checks: [check("internal_error", false, "wireDoctor could not complete: an unexpected error was swallowed.")],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
};
|
|
@@ -27,7 +27,16 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import type { CoachmarkStorage } from "./types";
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles.
|
|
32
|
+
*
|
|
33
|
+
* @globalSlot LATCH — the RUNTIME RECORD is created once and its identity is then stable. A second
|
|
34
|
+
* write replaces the record `subscribeCoachmarksEnabled` registered its listeners on, so a
|
|
35
|
+
* kill-switch flip notifies an orphaned set while a tour that believes it is running paints nothing
|
|
36
|
+
* — the exact limbo `coachmarkKillSwitch` canaries. Its FIELDS (`storage`, `testing`,
|
|
37
|
+
* `coachmarksEnabled`, `listeners`) are live, which is why every accessor calls `coachmarkRuntime()`
|
|
38
|
+
* instead of holding the record, and why `coachmarksEnabled` is subscribable rather than sampled.
|
|
39
|
+
*/
|
|
31
40
|
const COACHMARK_RUNTIME_SLOT: unique symbol = Symbol.for("@wireai/activation:coachmarkRuntime");
|
|
32
41
|
|
|
33
42
|
/** The shared runtime: the provider-injected storage, the QA replay flag, and the kill switch. */
|
package/src/context/deviceId.ts
CHANGED
|
@@ -68,7 +68,16 @@ export const mintDeviceId = (): string => {
|
|
|
68
68
|
// events are the ONLY ones the server counts `min_sessions` from, so a per-launch id there is not a
|
|
69
69
|
// millisecond of noise, it is a counter that can never exceed 1.
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle.
|
|
73
|
+
*
|
|
74
|
+
* @globalSlot LATCH — the REGISTRY OBJECT is created once and its identity is then stable. A second
|
|
75
|
+
* write empties `keys`/`hydrating`/`pending`, so the next surface mints a SECOND auto id for one
|
|
76
|
+
* install and the `device_key` the server joins sessions on splits in two — the halved-counter
|
|
77
|
+
* defect this registry exists to close. Its CONTENTS are live (the id per `appId` is replaced when
|
|
78
|
+
* an async hydration adopts a persisted value), so callers must re-read through
|
|
79
|
+
* `resolveAutoDeviceKey()` rather than hold the string a mount happened to see first.
|
|
80
|
+
*/
|
|
72
81
|
const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
|
|
73
82
|
|
|
74
83
|
/** What a hydration settled on: the id, and whether persistence actually CONFIRMED it.
|
|
@@ -18,6 +18,14 @@ import { defaultWireFeatures } from "./defaults";
|
|
|
18
18
|
import { useWireFeatures } from "./useWireFeatures";
|
|
19
19
|
import type { WireFeatures, WireFeaturesConfig } from "./types";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* @globalSlot LATCH — the React context object is minted by the first copy of this module to load
|
|
23
|
+
* and every later copy ADOPTS it (the `if (!…)` guard below). A second write would re-split the
|
|
24
|
+
* identity the guard exists to keep whole: the provider would publish into one context while a
|
|
25
|
+
* subpath bundle's `useContext` read another, and `useResolvedFeatures`' null fallback would serve
|
|
26
|
+
* the all-on defaults with no crash and no warning. Because it is a latch, and only because of
|
|
27
|
+
* that, the module-local `const WireFeaturesContext` below may cache it.
|
|
28
|
+
*/
|
|
21
29
|
const CONTEXT_SYMBOL = Symbol.for("wireai.features.context");
|
|
22
30
|
// `globalThis`, not `global`: the other nine `Symbol.for` slots in this kit all use it, and `global`
|
|
23
31
|
// simply does not exist in a plain browser (RN-web / SSR-in-the-browser), where this line would
|
|
@@ -65,7 +65,15 @@ export type ResolveIdentityInput = {
|
|
|
65
65
|
scope?: string;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Well-known key into the runtime-global symbol registry — one provenance registry per process.
|
|
70
|
+
*
|
|
71
|
+
* @globalSlot LATCH — the REGISTRY OBJECT is created once and its identity is then stable. A second
|
|
72
|
+
* write drops every host id recorded so far, so `hostIdentity()` answers "no host key in this
|
|
73
|
+
* process" for a process that demonstrably has one, and the kit injects its own `wdev_*` beside it
|
|
74
|
+
* without warning. Its CONTENTS are live (`host` gains an entry on every host-sourced
|
|
75
|
+
* `resolveIdentity`), so both accessors go through `provenanceRegistry()` on every call.
|
|
76
|
+
*/
|
|
69
77
|
const IDENTITY_PROVENANCE_SLOT: unique symbol = Symbol.for("@wireai/activation:identityProvenance");
|
|
70
78
|
|
|
71
79
|
/** `"<space>:<scope>"` → the HOST-supplied value seen for it. Auto values are never recorded. */
|
package/src/reviews/runtime.ts
CHANGED
|
@@ -31,7 +31,11 @@ export const resolveStorage = (
|
|
|
31
31
|
declare const __DEV__: boolean | undefined;
|
|
32
32
|
|
|
33
33
|
/** Once-per-process latch on its own `Symbol.for` slot: a plain `let` would warn once per inlined
|
|
34
|
-
* bundle copy, and the gates live in three of them.
|
|
34
|
+
* bundle copy, and the gates live in three of them.
|
|
35
|
+
*
|
|
36
|
+
* @globalSlot LATCH — written `true` by the first gate that found no sync storage, and never again.
|
|
37
|
+
* A second differing write re-arms a warning the process already spent, so a host that mounts three
|
|
38
|
+
* gates gets the same paragraph three times and stops reading it. */
|
|
35
39
|
const NO_STORAGE_WARNED_SLOT: unique symbol = Symbol.for(
|
|
36
40
|
"@wireai/activation:gateStorageWarned",
|
|
37
41
|
);
|
|
@@ -138,6 +142,12 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
|
|
|
138
142
|
// regardless of what the session registry does afterwards. The client counter only has to be
|
|
139
143
|
// monotone and per-launch; the server's own `min_sessions` still counts real `app.session_started`
|
|
140
144
|
// events, so nothing downstream needs the two ids to be identical.
|
|
145
|
+
//
|
|
146
|
+
// @globalSlot LATCH — pinned by the FIRST read and stable for the whole launch. This is the one
|
|
147
|
+
// slot whose latch is the fix: it samples the LIVE `currentSessionId` once, on purpose, so that
|
|
148
|
+
// registry changing underneath it (render before effect, on every cold start) cannot change the
|
|
149
|
+
// unit mid-launch and hand `minSessions: 2` a second "session" inside one app open. A second
|
|
150
|
+
// differing write is therefore not a stale-cache bug, it IS the defect — see the block above.
|
|
141
151
|
const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
|
|
142
152
|
|
|
143
153
|
type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: string };
|
|
@@ -35,6 +35,13 @@ import type { OnboardingTheme } from "./types";
|
|
|
35
35
|
* Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
|
|
36
36
|
* what makes this cross-bundle: it returns the SAME symbol for the same string across every copy of
|
|
37
37
|
* this module, so duplicated inlined copies all address one slot.
|
|
38
|
+
*
|
|
39
|
+
* @globalSlot LATCH — the React context object is minted by the first copy of this module to load
|
|
40
|
+
* and every later copy ADOPTS it (the `if (!…)` guard below says so in one line). A second write
|
|
41
|
+
* re-splits the identity: `<OnboardingThemeProvider>` (root barrel only) publishes into one context
|
|
42
|
+
* while `<ReviewGate>` from a subpath reads another, and `useOnboardingTheme`'s `ctx ?? mergeTheme()`
|
|
43
|
+
* fallback quietly serves the NEUTRAL theme. Because it is a latch, and only because of that, the
|
|
44
|
+
* module-local `const OnboardingThemeContext` below may cache it.
|
|
38
45
|
*/
|
|
39
46
|
const THEME_CONTEXT_SLOT: unique symbol = Symbol.for("@wireai/activation:themeContext");
|
|
40
47
|
|