@larc-iu/plaid-client 0.2.0-alpha.2 → 0.2.0-alpha.21

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/src/events.js ADDED
@@ -0,0 +1,266 @@
1
+ import { transformRequest } from "./transforms.js";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Client events: the opt-in research telemetry of a project (core manual,
5
+ // "Research telemetry").
6
+ //
7
+ // `client.events.record(type, fields)` is a fire-and-forget recorder. It
8
+ // buffers events and sends them every FLUSH_INTERVAL_MS or FLUSH_AT events,
9
+ // and when the page is hidden (`pagehide`, with `keepalive` so the request
10
+ // outlives the page). A batch that fails is dropped: no retry, no toast, no
11
+ // error for the caller. Nothing it does can block or break the screen that
12
+ // called it.
13
+ //
14
+ // It sends nothing for a project whose switch is off. The switch is read
15
+ // from the project (`config.plaid.research.telemetry`). An "on" is kept for
16
+ // FLAG_TTL_MS, one flush interval, and an "off" for FLAG_OFF_TTL_MS, so a
17
+ // switch another maintainer turns on reaches an open page within a minute,
18
+ // while a page whose switch is off, which records on every render of a grid,
19
+ // does not read the project every few seconds. An event recorded while an old
20
+ // "off" is being read again waits for the answer rather than being dropped.
21
+ // `events.setEnabled` tells it at once, which is what the settings checkbox
22
+ // does. A server refusal (the switch was turned off since) turns it off here
23
+ // too.
24
+ //
25
+ // ONE recorder per server per page, whatever client records into it: an app
26
+ // makes a client per editor, and a suggestion's "shown" is deduplicated for
27
+ // the page session, not per client. The last client to record lends its
28
+ // token to the next send.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Every event type the server accepts. */
32
+ export const EVENT_TYPES = Object.freeze([
33
+ "suggestion.shown",
34
+ "suggestion.adopted",
35
+ "suggestion.dismissed",
36
+ "plan.opened",
37
+ ]);
38
+
39
+ export const FLUSH_INTERVAL_MS = 10000;
40
+ export const FLUSH_AT = 50;
41
+ export const FLAG_TTL_MS = FLUSH_INTERVAL_MS;
42
+ export const FLAG_OFF_TTL_MS = 60000;
43
+ // Events per request. A browser refuses a `keepalive` request whose body,
44
+ // with every other keepalive request still in flight, passes 64 KiB, and a
45
+ // refused request is lost. So the pagehide send goes out in small pieces, and
46
+ // only that send is keepalive: a backlog sent while the page stays (the first
47
+ // page of a grid, drawn while the switch was still being read, can hold
48
+ // hundreds of guesses) would otherwise lose everything past the quota. The
49
+ // server takes up to 500 in one request.
50
+ const PER_REQUEST = FLUSH_AT;
51
+ // A suggested or written value is a gloss, not prose. A long one is cut, in
52
+ // code points as the server counts them, so no character is split in two.
53
+ // The server refuses a value past this length, and a key its type does not
54
+ // take (core manual, "Research telemetry").
55
+ const MAX_VALUE_CHARS = 200;
56
+
57
+ const clip = (v) => {
58
+ if (typeof v !== "string" || v.length <= MAX_VALUE_CHARS) return v;
59
+ const cps = Array.from(v);
60
+ return cps.length > MAX_VALUE_CHARS
61
+ ? cps.slice(0, MAX_VALUE_CHARS).join("")
62
+ : v;
63
+ };
64
+
65
+ export class EventRecorder {
66
+ /**
67
+ * @param {object} [opts]
68
+ * @param {function} [opts.now] - Clock, for tests
69
+ * @param {object} [opts.window] - Where `pagehide` fires (default globalThis)
70
+ */
71
+ constructor({ now = () => Date.now(), window = globalThis } = {}) {
72
+ this.client = null;
73
+ this.buffer = [];
74
+ this.timer = null;
75
+ this.now = now;
76
+ // projectId -> { state: 'on' | 'off' | 'pending', at }
77
+ this.flags = new Map();
78
+ // "shown" keys already recorded this page session.
79
+ this.seen = new Set();
80
+ this._onPageHide = () => this.flush({ keepalive: true });
81
+ this.window = window;
82
+ window?.addEventListener?.("pagehide", this._onPageHide);
83
+ }
84
+
85
+ /** Tell the recorder a project's switch, e.g. right after changing it. */
86
+ setEnabled(projectId, on) {
87
+ this.flags.set(projectId, { state: on ? "on" : "off", at: this.now() });
88
+ if (!on) this._drop(projectId);
89
+ else if (this.buffer.length) this._schedule();
90
+ }
91
+
92
+ /**
93
+ * Record one event. Returns true when it was buffered, false when it was
94
+ * not (switch off, a repeat "shown", an unknown type, no project).
95
+ */
96
+ record(client, type, { projectId, documentId, targetId, data } = {}) {
97
+ try {
98
+ if (!EVENT_TYPES.includes(type) || !projectId) return false;
99
+ this.client = client;
100
+ const flag = this._flag(projectId);
101
+ if (flag === "off") return false;
102
+ // A key with no value is left out: the server takes only a string or
103
+ // a number.
104
+ const clipped = data
105
+ ? Object.fromEntries(
106
+ Object.entries(data)
107
+ .filter(([, v]) => v != null)
108
+ .map(([k, v]) => [k, clip(v)]),
109
+ )
110
+ : undefined;
111
+ let key = null;
112
+ if (type === "suggestion.shown") {
113
+ key = JSON.stringify([
114
+ projectId,
115
+ targetId ?? null,
116
+ clipped?.field ?? null,
117
+ clipped?.value ?? null,
118
+ ]);
119
+ if (this.seen.has(key)) return false;
120
+ this.seen.add(key);
121
+ }
122
+ this.buffer.push({
123
+ projectId,
124
+ key,
125
+ event: {
126
+ type,
127
+ documentId: documentId ?? undefined,
128
+ targetId: targetId == null ? undefined : String(targetId),
129
+ data: clipped,
130
+ clientTs: new Date(this.now()).toISOString(),
131
+ },
132
+ });
133
+ if (this.buffer.length >= FLUSH_AT) this.flush();
134
+ else this._schedule();
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Send what is buffered for every project whose switch is known to be on,
143
+ * drop what is buffered for one known to be off, and keep the rest until
144
+ * the switch is known. With `keepalive` (the page is going away) nothing is
145
+ * kept.
146
+ */
147
+ flush({ keepalive = false } = {}) {
148
+ clearTimeout(this.timer);
149
+ this.timer = null;
150
+ const keep = [];
151
+ const byProject = new Map();
152
+ for (const entry of this.buffer) {
153
+ const state = this.flags.get(entry.projectId)?.state;
154
+ if (state === "on") {
155
+ if (!byProject.has(entry.projectId)) byProject.set(entry.projectId, []);
156
+ byProject.get(entry.projectId).push(entry.event);
157
+ } else if (state === "off") {
158
+ if (entry.key) this.seen.delete(entry.key);
159
+ } else if (!keepalive) {
160
+ keep.push(entry);
161
+ }
162
+ }
163
+ this.buffer = keep;
164
+ for (const [projectId, events] of byProject) {
165
+ for (let i = 0; i < events.length; i += PER_REQUEST) {
166
+ this._send(projectId, events.slice(i, i + PER_REQUEST), keepalive);
167
+ }
168
+ }
169
+ }
170
+
171
+ /** Stop listening for `pagehide` and forget everything (tests). */
172
+ close() {
173
+ clearTimeout(this.timer);
174
+ this.timer = null;
175
+ this.buffer = [];
176
+ this.window?.removeEventListener?.("pagehide", this._onPageHide);
177
+ }
178
+
179
+ _schedule() {
180
+ if (this.timer == null) {
181
+ this.timer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS);
182
+ }
183
+ }
184
+
185
+ _drop(projectId) {
186
+ this.buffer = this.buffer.filter((e) => {
187
+ if (e.projectId !== projectId) return true;
188
+ if (e.key) this.seen.delete(e.key);
189
+ return false;
190
+ });
191
+ }
192
+
193
+ // The switch as known now: 'on', 'off' or 'pending'. An unknown one sets
194
+ // off a read of the project, and a stale one a fresh read. While it is
195
+ // read, an old "on" stands and an old "off" is 'pending', so what is
196
+ // recorded meanwhile waits for the answer. The answer flushes or drops what
197
+ // is buffered.
198
+ _flag(projectId) {
199
+ const known = this.flags.get(projectId);
200
+ const ttl = known?.state === "on" ? FLAG_TTL_MS : FLAG_OFF_TTL_MS;
201
+ const fresh = known && this.now() - known.at < ttl;
202
+ if (known && (fresh || known.checking)) return known.state;
203
+ const state = known?.state === "on" ? "on" : "pending";
204
+ this.flags.set(projectId, {
205
+ state,
206
+ at: known ? known.at : 0,
207
+ checking: true,
208
+ });
209
+ const client = this.client;
210
+ Promise.resolve()
211
+ .then(() => client.projects.get(projectId))
212
+ .then(
213
+ (project) => project?.config?.plaid?.research?.telemetry === true,
214
+ () => false,
215
+ )
216
+ .then((on) => {
217
+ this.flags.set(projectId, { state: on ? "on" : "off", at: this.now() });
218
+ if (!on) this._drop(projectId);
219
+ else if (this.buffer.length >= FLUSH_AT) this.flush();
220
+ else if (this.buffer.length) this._schedule();
221
+ });
222
+ return state;
223
+ }
224
+
225
+ _send(projectId, events, keepalive) {
226
+ const client = this.client;
227
+ try {
228
+ const url = `${client.baseUrl}/api/v1/projects/${projectId}/events`;
229
+ fetch(url, {
230
+ method: "POST",
231
+ keepalive,
232
+ headers: {
233
+ Authorization: `Bearer ${client.token}`,
234
+ "Content-Type": "application/json",
235
+ },
236
+ body: JSON.stringify(transformRequest(events)),
237
+ }).then(
238
+ (res) => {
239
+ // The switch was turned off since it was read.
240
+ if (res.status === 403) this.setEnabled(projectId, false);
241
+ },
242
+ () => {},
243
+ );
244
+ } catch {
245
+ /* dropped, as every failed batch is */
246
+ }
247
+ }
248
+ }
249
+
250
+ const recorders = new Map();
251
+
252
+ /** The page's recorder for `client`'s server. */
253
+ export function recorderFor(client) {
254
+ let r = recorders.get(client.baseUrl);
255
+ if (!r) {
256
+ r = new EventRecorder();
257
+ recorders.set(client.baseUrl, r);
258
+ }
259
+ return r;
260
+ }
261
+
262
+ /** Forget every recorder (tests). */
263
+ export function resetEventRecorders() {
264
+ for (const r of recorders.values()) r.close();
265
+ recorders.clear();
266
+ }