@hot-updater/plugin-insights 1.0.0-rc.20
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/LICENSE +33 -0
- package/README.md +24 -0
- package/client/package.json +7 -0
- package/dist/client/eventId.cjs +181 -0
- package/dist/client/eventId.mjs +179 -0
- package/dist/client/index.cjs +234 -0
- package/dist/client/index.d.cts +42 -0
- package/dist/client/index.d.mts +42 -0
- package/dist/client/index.mjs +233 -0
- package/dist/client/sender.cjs +103 -0
- package/dist/client/sender.mjs +103 -0
- package/dist/client/state.cjs +121 -0
- package/dist/client/state.mjs +119 -0
- package/dist/internal.cjs +9 -0
- package/dist/internal.d.cts +5 -0
- package/dist/internal.d.mts +5 -0
- package/dist/internal.mjs +5 -0
- package/dist/server/contract.cjs +193 -0
- package/dist/server/contract.mjs +189 -0
- package/dist/server/domain.d.cts +149 -0
- package/dist/server/domain.d.mts +149 -0
- package/dist/server/errors.cjs +15 -0
- package/dist/server/errors.d.cts +6 -0
- package/dist/server/errors.d.mts +6 -0
- package/dist/server/errors.mjs +14 -0
- package/dist/server/eventInput.cjs +284 -0
- package/dist/server/eventInput.d.cts +6 -0
- package/dist/server/eventInput.d.mts +6 -0
- package/dist/server/eventInput.mjs +282 -0
- package/dist/server/eventRow.cjs +49 -0
- package/dist/server/eventRow.d.cts +64 -0
- package/dist/server/eventRow.d.mts +64 -0
- package/dist/server/eventRow.mjs +47 -0
- package/dist/server/index.cjs +128 -0
- package/dist/server/index.d.cts +394 -0
- package/dist/server/index.d.mts +394 -0
- package/dist/server/index.mjs +121 -0
- package/dist/server/model.cjs +17 -0
- package/dist/server/model.d.cts +10 -0
- package/dist/server/model.d.mts +10 -0
- package/dist/server/model.mjs +17 -0
- package/dist/server/modelTypes.d.cts +176 -0
- package/dist/server/modelTypes.d.mts +176 -0
- package/dist/server/overview.cjs +109 -0
- package/dist/server/overview.d.cts +15 -0
- package/dist/server/overview.d.mts +15 -0
- package/dist/server/overview.mjs +107 -0
- package/dist/server/provider.cjs +375 -0
- package/dist/server/provider.d.cts +6 -0
- package/dist/server/provider.d.mts +6 -0
- package/dist/server/provider.mjs +374 -0
- package/dist/server/queryInput.cjs +93 -0
- package/dist/server/queryInput.mjs +91 -0
- package/dist/server/reads.cjs +681 -0
- package/dist/server/reads.d.cts +60 -0
- package/dist/server/reads.d.mts +60 -0
- package/dist/server/reads.mjs +674 -0
- package/dist/server/recordEvent.cjs +319 -0
- package/dist/server/recordEvent.d.cts +17 -0
- package/dist/server/recordEvent.d.mts +17 -0
- package/dist/server/recordEvent.mjs +316 -0
- package/dist/server/routes.cjs +96 -0
- package/dist/server/routes.d.cts +36 -0
- package/dist/server/routes.d.mts +36 -0
- package/dist/server/routes.mjs +95 -0
- package/dist/server/schema.cjs +489 -0
- package/dist/server/schema.d.cts +706 -0
- package/dist/server/schema.d.mts +706 -0
- package/dist/server/schema.mjs +483 -0
- package/dist/server/testing/eventRow.d.mts +64 -0
- package/dist/server/testing/expectInsightsIndex.mjs +18 -0
- package/dist/server/testing/fixtures.d.mts +8 -0
- package/dist/server/testing/fixtures.mjs +25 -0
- package/dist/server/testing/index.d.mts +24 -0
- package/dist/server/testing/index.mjs +25 -0
- package/dist/server/testing/insightsModelTests.mjs +714 -0
- package/dist/server/testing/modelTypes.d.mts +176 -0
- package/dist/server/testing/setupInsightsHttpTestSuite.d.mts +11 -0
- package/dist/server/testing/setupInsightsHttpTestSuite.mjs +107 -0
- package/dist/server/testing/setupInsightsModelTestSuite.d.mts +11 -0
- package/dist/server/testing/setupInsightsModelTestSuite.mjs +31 -0
- package/dist/server/testing/types.d.mts +26 -0
- package/dist/server/types.d.cts +33 -0
- package/dist/server/types.d.mts +33 -0
- package/package.json +68 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { HotUpdaterClientPlugin } from "@hot-updater/core";
|
|
2
|
+
//#region src/client/index.d.ts
|
|
3
|
+
interface InsightsOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Also report from debug builds (`__DEV__`). Defaults to `false`, so
|
|
6
|
+
* development sessions stay out of production Insights.
|
|
7
|
+
*/
|
|
8
|
+
readonly debug?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** The app's own id for the signed-in user, attached to later reports. */
|
|
11
|
+
interface InsightsUser {
|
|
12
|
+
readonly userId: string | number;
|
|
13
|
+
}
|
|
14
|
+
interface InsightsPlugin extends HotUpdaterClientPlugin {
|
|
15
|
+
readonly id: "insights";
|
|
16
|
+
/**
|
|
17
|
+
* Attaches the signed-in user's id to later reports, or clears it with
|
|
18
|
+
* `null`. It persists on the device, sends nothing by itself, and can be
|
|
19
|
+
* called before `HotUpdater.init`.
|
|
20
|
+
*/
|
|
21
|
+
setUser(user: InsightsUser | null): void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Reports update adoption to a server that runs the `insights()` server
|
|
25
|
+
* plugin: launches (at most once a UTC day while nothing changes), downloads,
|
|
26
|
+
* applies, recoveries, and update failures. After the server answers that
|
|
27
|
+
* Insights is off, the plugin sends nothing for 24 hours, and for the rest
|
|
28
|
+
* of that runtime.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { HotUpdater } from "@hot-updater/react-native";
|
|
33
|
+
* import { insights } from "@hot-updater/react-native/plugins/insights";
|
|
34
|
+
*
|
|
35
|
+
* const analytics = insights();
|
|
36
|
+
* HotUpdater.init({ baseURL, plugins: [analytics] });
|
|
37
|
+
* analytics.setUser({ userId: "user-123" });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
declare const insights: (options?: InsightsOptions) => InsightsPlugin;
|
|
41
|
+
//#endregion
|
|
42
|
+
export { InsightsOptions, InsightsPlugin, InsightsUser, insights };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createKeyedUUIDv7, createUUIDv7 } from "./eventId.mjs";
|
|
2
|
+
import { createInsightsEventSender } from "./sender.mjs";
|
|
3
|
+
import { createInsightsState, utcDay, utcDayStart } from "./state.mjs";
|
|
4
|
+
import { defineClientPlugin } from "@hot-updater/core";
|
|
5
|
+
//#region src/client/index.ts
|
|
6
|
+
const normalizeUserId = (user) => {
|
|
7
|
+
if (user === null) return null;
|
|
8
|
+
const userId = String(user.userId).trim();
|
|
9
|
+
return userId.length === 0 ? null : userId;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Reports update adoption to a server that runs the `insights()` server
|
|
13
|
+
* plugin: launches (at most once a UTC day while nothing changes), downloads,
|
|
14
|
+
* applies, recoveries, and update failures. After the server answers that
|
|
15
|
+
* Insights is off, the plugin sends nothing for 24 hours, and for the rest
|
|
16
|
+
* of that runtime.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { HotUpdater } from "@hot-updater/react-native";
|
|
21
|
+
* import { insights } from "@hot-updater/react-native/plugins/insights";
|
|
22
|
+
*
|
|
23
|
+
* const analytics = insights();
|
|
24
|
+
* HotUpdater.init({ baseURL, plugins: [analytics] });
|
|
25
|
+
* analytics.setUser({ userId: "user-123" });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
const insights = (options = {}) => {
|
|
29
|
+
let context = null;
|
|
30
|
+
let state = null;
|
|
31
|
+
/** A user set before setup, applied once the plugin has storage. */
|
|
32
|
+
let pendingUser = null;
|
|
33
|
+
/** A download in this runtime: a later no-change report would hide it. */
|
|
34
|
+
let didDownload = false;
|
|
35
|
+
let lastDownloadedSelection = null;
|
|
36
|
+
const pending = /* @__PURE__ */ new Map();
|
|
37
|
+
let send = null;
|
|
38
|
+
const createEvent = (movement, eventId) => {
|
|
39
|
+
if (context === null || state === null) return null;
|
|
40
|
+
const appVersion = context.appVersion;
|
|
41
|
+
if (appVersion === null) {
|
|
42
|
+
console.warn(`[HotUpdater] Insights needs the native app version; the ${movement.type} event was not sent.`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const now = context.now();
|
|
46
|
+
const installId = context.installId;
|
|
47
|
+
const userId = state.readUserId();
|
|
48
|
+
const body = {
|
|
49
|
+
...movement,
|
|
50
|
+
eventId: eventId?.(now, installId) ?? createUUIDv7(),
|
|
51
|
+
installId,
|
|
52
|
+
...userId === null ? {} : { userId },
|
|
53
|
+
platform: context.platform,
|
|
54
|
+
appVersion,
|
|
55
|
+
cohort: context.getCohort(),
|
|
56
|
+
fingerprintHash: context.getFingerprintHash(),
|
|
57
|
+
sdkVersion: context.sdkVersion
|
|
58
|
+
};
|
|
59
|
+
const day = utcDay(now);
|
|
60
|
+
return {
|
|
61
|
+
body,
|
|
62
|
+
day,
|
|
63
|
+
report: movement.type === "UNCHANGED" || movement.type === "UPDATE_APPLIED" || movement.type === "RECOVERED" ? {
|
|
64
|
+
day,
|
|
65
|
+
channel: movement.channel,
|
|
66
|
+
appVersion,
|
|
67
|
+
bundleId: movement.toBundleId,
|
|
68
|
+
releaseId: movement.toReleaseId,
|
|
69
|
+
userId
|
|
70
|
+
} : null,
|
|
71
|
+
failureKey: null
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
const enqueue = (event) => {
|
|
75
|
+
if (event === null || send === null) return;
|
|
76
|
+
const { type, toBundleId } = event.body;
|
|
77
|
+
if (type === "UPDATE_DOWNLOADED" || type === "UPDATE_APPLIED") {
|
|
78
|
+
for (const queued of pending.values()) if (queued.body.type === "UPDATE_FAILED" && queued.body.toBundleId === toBundleId) queued.superseded = true;
|
|
79
|
+
}
|
|
80
|
+
pending.set(event.body, event);
|
|
81
|
+
send(event.body).catch((error) => {
|
|
82
|
+
console.warn(`[HotUpdater] Insights ${event.body.type} event was not delivered:`, error);
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const reportLaunch = (channel, bundleId, releaseId, fromReleaseId) => {
|
|
86
|
+
if (didDownload) return;
|
|
87
|
+
enqueue(createEvent({
|
|
88
|
+
type: "UNCHANGED",
|
|
89
|
+
channel,
|
|
90
|
+
fromBundleId: null,
|
|
91
|
+
fromReleaseId,
|
|
92
|
+
toBundleId: bundleId,
|
|
93
|
+
toReleaseId: releaseId,
|
|
94
|
+
updateStrategy: null
|
|
95
|
+
}));
|
|
96
|
+
};
|
|
97
|
+
const onAppReady = (result) => {
|
|
98
|
+
if (result.status === "UNCHANGED") {
|
|
99
|
+
reportLaunch(result.channel, result.bundleId, result.releaseId, null);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
enqueue(createEvent({
|
|
103
|
+
type: result.status,
|
|
104
|
+
channel: result.channel,
|
|
105
|
+
fromBundleId: result.fromBundleId,
|
|
106
|
+
fromReleaseId: result.fromReleaseId,
|
|
107
|
+
toBundleId: result.toBundleId,
|
|
108
|
+
toReleaseId: result.toReleaseId,
|
|
109
|
+
updateStrategy: result.updateStrategy,
|
|
110
|
+
...result.status === "RECOVERED" && result.previousProcessExit !== null ? { metadata: { previousProcessExit: result.previousProcessExit } } : {}
|
|
111
|
+
}));
|
|
112
|
+
};
|
|
113
|
+
const onUpdateCheck = (result) => {
|
|
114
|
+
if (result.status !== "UNCHANGED") return;
|
|
115
|
+
reportLaunch(result.channel, result.bundleId, result.releaseId, result.previousReleaseId === result.releaseId ? null : result.previousReleaseId);
|
|
116
|
+
};
|
|
117
|
+
const onBundleDownloaded = (info) => {
|
|
118
|
+
const selection = JSON.stringify([
|
|
119
|
+
info.channel,
|
|
120
|
+
info.toReleaseId,
|
|
121
|
+
info.toBundleId
|
|
122
|
+
]);
|
|
123
|
+
if (lastDownloadedSelection === selection) return;
|
|
124
|
+
lastDownloadedSelection = selection;
|
|
125
|
+
didDownload = true;
|
|
126
|
+
enqueue(createEvent({
|
|
127
|
+
type: "UPDATE_DOWNLOADED",
|
|
128
|
+
channel: info.channel,
|
|
129
|
+
fromBundleId: info.fromBundleId,
|
|
130
|
+
fromReleaseId: info.fromReleaseId,
|
|
131
|
+
toBundleId: info.toBundleId,
|
|
132
|
+
toReleaseId: info.toReleaseId,
|
|
133
|
+
updateStrategy: info.updateStrategy,
|
|
134
|
+
metadata: {
|
|
135
|
+
delivery: info.delivery,
|
|
136
|
+
...info.patchFallback ? { patchFallback: true } : {}
|
|
137
|
+
}
|
|
138
|
+
}));
|
|
139
|
+
};
|
|
140
|
+
const onUpdateError = (error) => {
|
|
141
|
+
if (error.stage === "check" && error.reason === "network") return;
|
|
142
|
+
const targetBundleId = error.targetBundleId ?? error.bundleId;
|
|
143
|
+
const failureKey = [
|
|
144
|
+
error.stage,
|
|
145
|
+
error.reason,
|
|
146
|
+
targetBundleId
|
|
147
|
+
].join(" ");
|
|
148
|
+
const event = createEvent({
|
|
149
|
+
type: "UPDATE_FAILED",
|
|
150
|
+
channel: error.channel,
|
|
151
|
+
fromBundleId: error.bundleId,
|
|
152
|
+
fromReleaseId: error.releaseId,
|
|
153
|
+
toBundleId: targetBundleId,
|
|
154
|
+
toReleaseId: error.targetReleaseId ?? null,
|
|
155
|
+
updateStrategy: error.updateStrategy,
|
|
156
|
+
metadata: { failure: {
|
|
157
|
+
stage: error.stage,
|
|
158
|
+
reason: error.reason,
|
|
159
|
+
...error.resource === void 0 ? {} : { resource: error.resource },
|
|
160
|
+
...error.httpStatus === void 0 ? {} : { httpStatus: error.httpStatus },
|
|
161
|
+
...error.transport === void 0 ? {} : { transport: error.transport },
|
|
162
|
+
...error.originCode === void 0 ? {} : { originCode: error.originCode },
|
|
163
|
+
...error.previousProcessExit === void 0 ? {} : { previousProcessExit: error.previousProcessExit }
|
|
164
|
+
} }
|
|
165
|
+
}, (now, installId) => createKeyedUUIDv7([
|
|
166
|
+
installId,
|
|
167
|
+
utcDay(now),
|
|
168
|
+
failureKey
|
|
169
|
+
].join("\n"), utcDayStart(now)));
|
|
170
|
+
enqueue(event === null ? null : {
|
|
171
|
+
...event,
|
|
172
|
+
failureKey
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
const admit = (body) => {
|
|
176
|
+
const event = pending.get(body);
|
|
177
|
+
if (event === void 0 || state === null || context === null) return false;
|
|
178
|
+
if (event.superseded === true || state.isPaused(context.now())) {
|
|
179
|
+
pending.delete(body);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const admitted = body.type === "UNCHANGED" ? event.report === null || !state.repeatsReport(event.report) : body.type === "UPDATE_FAILED" ? event.failureKey === null || !state.hasFailure(event.day, event.failureKey) : true;
|
|
183
|
+
if (!admitted) pending.delete(body);
|
|
184
|
+
return admitted;
|
|
185
|
+
};
|
|
186
|
+
const settle = (body, delivery) => {
|
|
187
|
+
const event = pending.get(body);
|
|
188
|
+
pending.delete(body);
|
|
189
|
+
if (event === void 0 || state === null || context === null) return;
|
|
190
|
+
if (delivery === "failed") return;
|
|
191
|
+
if (delivery === "disabled") {
|
|
192
|
+
state.pause(context.now());
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (event.failureKey !== null) state.recordFailure(event.day, event.failureKey);
|
|
196
|
+
if (delivery === "refused") return;
|
|
197
|
+
state.resume();
|
|
198
|
+
if (event.report !== null) state.recordReport(event.report);
|
|
199
|
+
else state.forgetReport();
|
|
200
|
+
};
|
|
201
|
+
return defineClientPlugin({
|
|
202
|
+
id: "insights",
|
|
203
|
+
setup(pluginContext) {
|
|
204
|
+
context = pluginContext;
|
|
205
|
+
state = createInsightsState(pluginContext.storage);
|
|
206
|
+
if (pendingUser !== null) {
|
|
207
|
+
state.writeUserId(pendingUser.userId);
|
|
208
|
+
pendingUser = null;
|
|
209
|
+
}
|
|
210
|
+
if (pluginContext.isDebugBuild && options.debug !== true) return;
|
|
211
|
+
send = createInsightsEventSender(pluginContext.fetch, {
|
|
212
|
+
admit,
|
|
213
|
+
settle
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
onAppReady,
|
|
217
|
+
onUpdateCheck,
|
|
218
|
+
onBundleDownloaded,
|
|
219
|
+
onUpdateError
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
setUser(user) {
|
|
223
|
+
const userId = normalizeUserId(user);
|
|
224
|
+
if (state === null) {
|
|
225
|
+
pendingUser = { userId };
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
state.writeUserId(userId);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
//#endregion
|
|
233
|
+
export { insights };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
//#region src/client/sender.ts
|
|
2
|
+
/** Attempts per Insights event, the first one included. */
|
|
3
|
+
const INSIGHTS_MAX_ATTEMPTS = 3;
|
|
4
|
+
/** Wait before the second attempt; it doubles for each later attempt. */
|
|
5
|
+
const INSIGHTS_RETRY_BASE_DELAY_MS = 1e3;
|
|
6
|
+
/** Longest wait between attempts, a server's `Retry-After` included. */
|
|
7
|
+
const INSIGHTS_RETRY_MAX_DELAY_MS = 3e4;
|
|
8
|
+
/**
|
|
9
|
+
* Reads `Retry-After` as delay-seconds, the form a server under load sends;
|
|
10
|
+
* an HTTP-date falls back to the backoff.
|
|
11
|
+
*/
|
|
12
|
+
const parseRetryAfterMs = (value) => {
|
|
13
|
+
const seconds = value?.trim();
|
|
14
|
+
return seconds && /^\d+$/.test(seconds) ? Number(seconds) * 1e3 : null;
|
|
15
|
+
};
|
|
16
|
+
const getRetryDelayMs = (failedAttempt, retryAfterMs) => Math.min(retryAfterMs ?? INSIGHTS_RETRY_BASE_DELAY_MS * 2 ** (failedAttempt - 1) * (.5 + Math.random()), INSIGHTS_RETRY_MAX_DELAY_MS);
|
|
17
|
+
const wait = (ms) => new Promise((resolve) => {
|
|
18
|
+
setTimeout(resolve, ms);
|
|
19
|
+
});
|
|
20
|
+
/** Makes one POST, with the context's timeout, and settles with its outcome. */
|
|
21
|
+
const postInsightsEvent = async (fetch, event) => {
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch("events", {
|
|
24
|
+
body: JSON.stringify(event),
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
method: "POST"
|
|
27
|
+
});
|
|
28
|
+
if (response.status === 204) return "delivered";
|
|
29
|
+
if (response.status === 404) return "disabled";
|
|
30
|
+
if (response.status === 400 && event.type === "UPDATE_FAILED") return "refused";
|
|
31
|
+
return {
|
|
32
|
+
error: /* @__PURE__ */ new Error(`Expected HTTP 204 from /events, received ${response.status}`),
|
|
33
|
+
retryable: response.status === 429 || response.status >= 500,
|
|
34
|
+
retryAfterMs: parseRetryAfterMs(response.headers.get("Retry-After"))
|
|
35
|
+
};
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return {
|
|
38
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
39
|
+
retryable: true,
|
|
40
|
+
retryAfterMs: null
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Sends Insights events one at a time and retries each in the background.
|
|
46
|
+
*
|
|
47
|
+
* The server keeps an installation's latest report by arrival, so a later
|
|
48
|
+
* event waits behind an earlier one's retries; otherwise a retried
|
|
49
|
+
* UPDATE_APPLIED could land after the UPDATE_DOWNLOADED that followed it.
|
|
50
|
+
* Callers wait only for first attempts, so a report never waits out a
|
|
51
|
+
* backoff: once an event backs off, every waiting caller is released, and a
|
|
52
|
+
* failure after that only warns. The gate sees each event before each
|
|
53
|
+
* attempt, after every earlier event settled, so it decides with what the
|
|
54
|
+
* server already answered.
|
|
55
|
+
*/
|
|
56
|
+
const createInsightsEventSender = (fetch, gate) => {
|
|
57
|
+
let queue = Promise.resolve();
|
|
58
|
+
let retrying = false;
|
|
59
|
+
const waiting = /* @__PURE__ */ new Set();
|
|
60
|
+
return (event) => new Promise((resolve, reject) => {
|
|
61
|
+
let released = false;
|
|
62
|
+
const release = () => {
|
|
63
|
+
released = true;
|
|
64
|
+
waiting.delete(release);
|
|
65
|
+
resolve();
|
|
66
|
+
};
|
|
67
|
+
if (retrying) release();
|
|
68
|
+
else waiting.add(release);
|
|
69
|
+
queue = queue.then(async () => {
|
|
70
|
+
try {
|
|
71
|
+
for (let attempt = 1;; attempt += 1) {
|
|
72
|
+
if (!gate.admit(event)) {
|
|
73
|
+
release();
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
const outcome = await postInsightsEvent(fetch, event);
|
|
77
|
+
if (typeof outcome === "string") {
|
|
78
|
+
gate.settle(event, outcome);
|
|
79
|
+
release();
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
if (!outcome.retryable || attempt === INSIGHTS_MAX_ATTEMPTS) {
|
|
83
|
+
gate.settle(event, "failed");
|
|
84
|
+
waiting.delete(release);
|
|
85
|
+
if (released) console.warn(`[HotUpdater] Insights ${event.type} event was not delivered:`, outcome.error);
|
|
86
|
+
else reject(outcome.error);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
retrying = true;
|
|
90
|
+
for (const releaseWaiting of waiting) releaseWaiting();
|
|
91
|
+
await wait(getRetryDelayMs(attempt, outcome.retryAfterMs));
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
waiting.delete(release);
|
|
95
|
+
if (!released) reject(error);
|
|
96
|
+
} finally {
|
|
97
|
+
retrying = false;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
//#endregion
|
|
103
|
+
exports.createInsightsEventSender = createInsightsEventSender;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
//#region src/client/sender.ts
|
|
2
|
+
/** Attempts per Insights event, the first one included. */
|
|
3
|
+
const INSIGHTS_MAX_ATTEMPTS = 3;
|
|
4
|
+
/** Wait before the second attempt; it doubles for each later attempt. */
|
|
5
|
+
const INSIGHTS_RETRY_BASE_DELAY_MS = 1e3;
|
|
6
|
+
/** Longest wait between attempts, a server's `Retry-After` included. */
|
|
7
|
+
const INSIGHTS_RETRY_MAX_DELAY_MS = 3e4;
|
|
8
|
+
/**
|
|
9
|
+
* Reads `Retry-After` as delay-seconds, the form a server under load sends;
|
|
10
|
+
* an HTTP-date falls back to the backoff.
|
|
11
|
+
*/
|
|
12
|
+
const parseRetryAfterMs = (value) => {
|
|
13
|
+
const seconds = value?.trim();
|
|
14
|
+
return seconds && /^\d+$/.test(seconds) ? Number(seconds) * 1e3 : null;
|
|
15
|
+
};
|
|
16
|
+
const getRetryDelayMs = (failedAttempt, retryAfterMs) => Math.min(retryAfterMs ?? INSIGHTS_RETRY_BASE_DELAY_MS * 2 ** (failedAttempt - 1) * (.5 + Math.random()), INSIGHTS_RETRY_MAX_DELAY_MS);
|
|
17
|
+
const wait = (ms) => new Promise((resolve) => {
|
|
18
|
+
setTimeout(resolve, ms);
|
|
19
|
+
});
|
|
20
|
+
/** Makes one POST, with the context's timeout, and settles with its outcome. */
|
|
21
|
+
const postInsightsEvent = async (fetch, event) => {
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch("events", {
|
|
24
|
+
body: JSON.stringify(event),
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
method: "POST"
|
|
27
|
+
});
|
|
28
|
+
if (response.status === 204) return "delivered";
|
|
29
|
+
if (response.status === 404) return "disabled";
|
|
30
|
+
if (response.status === 400 && event.type === "UPDATE_FAILED") return "refused";
|
|
31
|
+
return {
|
|
32
|
+
error: /* @__PURE__ */ new Error(`Expected HTTP 204 from /events, received ${response.status}`),
|
|
33
|
+
retryable: response.status === 429 || response.status >= 500,
|
|
34
|
+
retryAfterMs: parseRetryAfterMs(response.headers.get("Retry-After"))
|
|
35
|
+
};
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return {
|
|
38
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
39
|
+
retryable: true,
|
|
40
|
+
retryAfterMs: null
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Sends Insights events one at a time and retries each in the background.
|
|
46
|
+
*
|
|
47
|
+
* The server keeps an installation's latest report by arrival, so a later
|
|
48
|
+
* event waits behind an earlier one's retries; otherwise a retried
|
|
49
|
+
* UPDATE_APPLIED could land after the UPDATE_DOWNLOADED that followed it.
|
|
50
|
+
* Callers wait only for first attempts, so a report never waits out a
|
|
51
|
+
* backoff: once an event backs off, every waiting caller is released, and a
|
|
52
|
+
* failure after that only warns. The gate sees each event before each
|
|
53
|
+
* attempt, after every earlier event settled, so it decides with what the
|
|
54
|
+
* server already answered.
|
|
55
|
+
*/
|
|
56
|
+
const createInsightsEventSender = (fetch, gate) => {
|
|
57
|
+
let queue = Promise.resolve();
|
|
58
|
+
let retrying = false;
|
|
59
|
+
const waiting = /* @__PURE__ */ new Set();
|
|
60
|
+
return (event) => new Promise((resolve, reject) => {
|
|
61
|
+
let released = false;
|
|
62
|
+
const release = () => {
|
|
63
|
+
released = true;
|
|
64
|
+
waiting.delete(release);
|
|
65
|
+
resolve();
|
|
66
|
+
};
|
|
67
|
+
if (retrying) release();
|
|
68
|
+
else waiting.add(release);
|
|
69
|
+
queue = queue.then(async () => {
|
|
70
|
+
try {
|
|
71
|
+
for (let attempt = 1;; attempt += 1) {
|
|
72
|
+
if (!gate.admit(event)) {
|
|
73
|
+
release();
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
const outcome = await postInsightsEvent(fetch, event);
|
|
77
|
+
if (typeof outcome === "string") {
|
|
78
|
+
gate.settle(event, outcome);
|
|
79
|
+
release();
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
if (!outcome.retryable || attempt === INSIGHTS_MAX_ATTEMPTS) {
|
|
83
|
+
gate.settle(event, "failed");
|
|
84
|
+
waiting.delete(release);
|
|
85
|
+
if (released) console.warn(`[HotUpdater] Insights ${event.type} event was not delivered:`, outcome.error);
|
|
86
|
+
else reject(outcome.error);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
retrying = true;
|
|
90
|
+
for (const releaseWaiting of waiting) releaseWaiting();
|
|
91
|
+
await wait(getRetryDelayMs(attempt, outcome.retryAfterMs));
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
waiting.delete(release);
|
|
95
|
+
if (!released) reject(error);
|
|
96
|
+
} finally {
|
|
97
|
+
retrying = false;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
//#endregion
|
|
103
|
+
export { createInsightsEventSender };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
//#region src/client/state.ts
|
|
2
|
+
const DAY_MS = 864e5;
|
|
3
|
+
/** The UTC day of a time, as `YYYY-MM-DD`. */
|
|
4
|
+
const utcDay = (time) => new Date(time).toISOString().slice(0, 10);
|
|
5
|
+
/** The start of a time's UTC day, in milliseconds. */
|
|
6
|
+
const utcDayStart = (time) => Math.floor(time / DAY_MS) * DAY_MS;
|
|
7
|
+
const sameReport = (left, right) => left.day === right.day && left.channel === right.channel && left.appVersion === right.appVersion && left.bundleId === right.bundleId && left.releaseId === right.releaseId && left.userId === right.userId;
|
|
8
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
const isNullableString = (value) => value === null || typeof value === "string";
|
|
10
|
+
const parseReport = (value) => {
|
|
11
|
+
if (value === null) return null;
|
|
12
|
+
try {
|
|
13
|
+
const report = JSON.parse(value);
|
|
14
|
+
if (isRecord(report) && typeof report.day === "string" && typeof report.channel === "string" && typeof report.appVersion === "string" && typeof report.bundleId === "string" && isNullableString(report.releaseId) && isNullableString(report.userId)) return {
|
|
15
|
+
day: report.day,
|
|
16
|
+
channel: report.channel,
|
|
17
|
+
appVersion: report.appVersion,
|
|
18
|
+
bundleId: report.bundleId,
|
|
19
|
+
releaseId: report.releaseId,
|
|
20
|
+
userId: report.userId
|
|
21
|
+
};
|
|
22
|
+
} catch {}
|
|
23
|
+
return null;
|
|
24
|
+
};
|
|
25
|
+
const parseFailures = (value) => {
|
|
26
|
+
if (value === null) return null;
|
|
27
|
+
try {
|
|
28
|
+
const failures = JSON.parse(value);
|
|
29
|
+
if (isRecord(failures) && typeof failures.day === "string" && Array.isArray(failures.keys) && failures.keys.every((key) => typeof key === "string")) return {
|
|
30
|
+
day: failures.day,
|
|
31
|
+
keys: failures.keys
|
|
32
|
+
};
|
|
33
|
+
} catch {}
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The Insights plugin's memory across launches, in its plugin storage. Every
|
|
38
|
+
* read tolerates a missing or unreadable value, and a storage failure only
|
|
39
|
+
* costs a report that would otherwise have been skipped.
|
|
40
|
+
*/
|
|
41
|
+
const createInsightsState = (storage) => {
|
|
42
|
+
/** The server said Insights is off during this runtime. */
|
|
43
|
+
let pausedThisRuntime = false;
|
|
44
|
+
const read = (key) => {
|
|
45
|
+
try {
|
|
46
|
+
return storage.get(key);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const write = (key, value) => {
|
|
52
|
+
try {
|
|
53
|
+
storage.set(key, value);
|
|
54
|
+
} catch {}
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
/**
|
|
58
|
+
* Whether reporting pauses: the server answered that Insights is off
|
|
59
|
+
* during this runtime or in the 24 hours before `now`. A pause that
|
|
60
|
+
* starts after `now`, from a clock that went back, has ended.
|
|
61
|
+
*/
|
|
62
|
+
isPaused(now) {
|
|
63
|
+
if (pausedThisRuntime) return true;
|
|
64
|
+
const pausedAt = Number(read("pausedAt"));
|
|
65
|
+
return Number.isFinite(pausedAt) && pausedAt > 0 && pausedAt <= now && now < pausedAt + DAY_MS;
|
|
66
|
+
},
|
|
67
|
+
pause(now) {
|
|
68
|
+
pausedThisRuntime = true;
|
|
69
|
+
write("pausedAt", String(now));
|
|
70
|
+
},
|
|
71
|
+
/** The server stored a report, so Insights is on. */
|
|
72
|
+
resume() {
|
|
73
|
+
if (read("pausedAt") !== null) write("pausedAt", null);
|
|
74
|
+
},
|
|
75
|
+
/** Whether a launch report repeats what the installation reported that day. */
|
|
76
|
+
repeatsReport(report) {
|
|
77
|
+
const last = parseReport(read("report"));
|
|
78
|
+
return last !== null && sameReport(last, report);
|
|
79
|
+
},
|
|
80
|
+
/** Records a delivered report that says what runs. */
|
|
81
|
+
recordReport(report) {
|
|
82
|
+
write("report", JSON.stringify(report));
|
|
83
|
+
},
|
|
84
|
+
/**
|
|
85
|
+
* Forgets the last report after a delivered download or failure, so the
|
|
86
|
+
* next launch reports again, as the server records it after one.
|
|
87
|
+
*/
|
|
88
|
+
forgetReport() {
|
|
89
|
+
if (read("report") !== null) write("report", null);
|
|
90
|
+
},
|
|
91
|
+
/** Whether a failure was already reported, or refused, that day. */
|
|
92
|
+
hasFailure(day, key) {
|
|
93
|
+
const failures = parseFailures(read("failures"));
|
|
94
|
+
return failures?.day === day && failures.keys.includes(key);
|
|
95
|
+
},
|
|
96
|
+
recordFailure(day, key) {
|
|
97
|
+
const failures = parseFailures(read("failures"));
|
|
98
|
+
const keys = failures?.day === day ? failures.keys : [];
|
|
99
|
+
if (keys.includes(key)) return;
|
|
100
|
+
write("failures", JSON.stringify({
|
|
101
|
+
day,
|
|
102
|
+
keys: [...keys, key].slice(-32)
|
|
103
|
+
}));
|
|
104
|
+
},
|
|
105
|
+
readUserId() {
|
|
106
|
+
try {
|
|
107
|
+
const user = JSON.parse(read("user") ?? "null");
|
|
108
|
+
return isRecord(user) && typeof user.userId === "string" ? user.userId : null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
writeUserId(userId) {
|
|
114
|
+
write("user", userId === null ? null : JSON.stringify({ userId }));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
//#endregion
|
|
119
|
+
exports.createInsightsState = createInsightsState;
|
|
120
|
+
exports.utcDay = utcDay;
|
|
121
|
+
exports.utcDayStart = utcDayStart;
|