Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/telemetry.js

32.9 KiB, 1 run

created by r2519314175:1447, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* telemetry.js — what a beta tester agrees to send, written out in full.
2 *
3 * ── Read this part even if you do not read code ─────────────────────
4 *
5 * Daimond's promise is that your content never reaches our server. Your chats,
6 * your files, your Diamonds' names, the paths on your disk: none of it leaves
7 * the browser except to the model provider you chose. A beta tester agrees to
8 * one narrow exception, and this file is the whole of it.
9 *
10 * What is sent is a list of NUMBERS. Nothing else. Each line of a batch is
11 * three integers -- which event, how many milliseconds into the session, and
12 * one count -- and the envelope around them is five more integers. There is no
13 * text field in this file's payload: not a message, not a file name, not a
14 * stack trace, not a "what were you doing" box. You can check that claim by
15 * reading `pack()` below, which is the only function that builds what is sent,
16 * and `onlyIntegers()`, which refuses to send anything it cannot prove is a
17 * number.
18 *
19 * The full list of events is `EVENTS`, a little way down. Every event Daimond
20 * can ever send is in that list, by name, with what its number means and the
21 * question it exists to answer. If an event is not in that list it cannot be
22 * sent, because `emit()` turns a name into its code by looking it up here and
23 * sends the CODE; a name it does not recognise is dropped and the name itself
24 * is never transmitted either way.
25 *
26 * ── The cost of that, stated plainly ────────────────────────────────
27 *
28 * When Daimond breaks for you, we get an event name and a count -- never the
29 * error message, never the file it happened in. That is a real cost and it was
30 * accepted deliberately, because the alternative is a text field, and a text
31 * field is how a chat fragment ends up on a server no matter how careful the
32 * scrubbing is. What stands in for a stack trace here is the ORDER: a batch
33 * carries its events in sequence with the milliseconds between them, so
34 * "opened the Files panel, ran two file tools, then threw" localises a fault
35 * without a single character of your data.
36 *
37 * ── Not to be confused with signals.js ──────────────────────────────
38 *
39 * `signals.js` is the Optimiser's index of how YOU work. It never leaves the
40 * device and it is not sent anywhere by anything. This file is the opposite
41 * arrangement: a small, fixed, numeric thing that does leave, and only from an
42 * account that asked to be in the beta.
43 *
44 * ── Consent, and why it is not a checkbox ───────────────────────────
45 *
46 * There is no `enabled` flag here, because a flag is a thing a future release
47 * forgets to check. Instead, the recorder does not exist until a beta grant
48 * makes one: `rec` is null, `emit()` has nowhere to put an event, `flush()`
49 * has nothing to send and no address to send it to. `consent()` is the only
50 * function that mints one, it needs a beta wave number it cannot invent, and
51 * nothing in the shipped tree calls it yet -- the consent moment is passcode
52 * redemption, which is not built. So today this module records nothing and
53 * sends nothing, and `dev/verify_telemetry.mjs` proves that at the network.
54 *
55 * ── Why consent IS remembered now, having deliberately not been ────
56 *
57 * This file used to say the opposite, and the argument was sound at the time:
58 * a remembered "yes" is a flag by another name -- it outlives the account it
59 * was given for, survives a passcode being revoked, and is the one piece of
60 * state a later release could inherit without meaning to. So a reload started
61 * with no recorder and something had to hand this module a grant again.
62 *
63 * WHAT CHANGED IS THAT THE GATEWAY CAN NOW BE ASKED. The wave used to reach the
64 * browser exactly once, in the reply to a redemption, so consent could not
65 * outlive the sitting it was given in and telemetry covered a tester's FIRST
66 * session and no other. `/api/account` now answers `beta` and `wave` on every
67 * registration round, which every device makes on every unlock
68 * (`gateway/src/handlers/account.rs`, `beta_standing`). That closes each of the
69 * three objections, and the closing is what the memory below rests on:
70 *
71 * - It cannot outlive the account. The remembered value IS the account id,
72 * and `resume()` re-arms only when the gateway names that same account on
73 * this boot. A second person signing in on the same laptop is a different
74 * id and gets nothing.
75 * - It cannot survive revocation. A passcode revoked in the console takes the
76 * account's status with it; the next bootstrap answers `beta:false` with no
77 * wave, and `resume()` has nothing to build a recorder from.
78 * - It cannot be inherited by a later release. `resume()` cannot create
79 * consent, only restore it: with nothing remembered it returns false, and
80 * the only function that can write the memory is `consent()`, which is
81 * still the one the person's own "yes" calls.
82 *
83 * The memory is one key, `daimond-telemetry`, holding an account id and nothing
84 * else. `withdraw()` removes it, which is what makes a withdrawal outlive the
85 * page it was made on -- and a withdrawal that a reload undid would be the
86 * cruellest bug in this file.
87 *
88 * Attaches a single global, `window.DaimondTelemetry`. Also exported for Node,
89 * so the vocabulary can be read by a checker without a browser.
90 */
91(function () {
92 'use strict';
93
94 // ── The event vocabulary ────────────────────────────────────
95 //
96 // The whole of what Daimond can send. Each entry is:
97 //
98 // code the integer that goes on the wire. ASSIGNED, never positional:
99 // reordering this list must not silently change what an old batch
100 // meant. A code is never reused for a different event.
101 // name what the code is called in this codebase. Never transmitted.
102 // n what this event's one number means.
103 // asks the question it exists to answer. An event nobody would act on
104 // is an event that should not be here.
105
106 var EVENTS = [
107 { code: 1, name: 'app.open', n: 'milliseconds from opening the page to a usable app',
108 asks: 'Does Daimond start on a real machine, and how long does it make people wait?' },
109 { code: 2, name: 'app.close', n: 'seconds this session lasted',
110 asks: 'Is Daimond used for two minutes or for two hours? Sitting length is the honest measure of whether it is being worked in.' },
111 { code: 3, name: 'onboard.step', n: 'the step reached, from STEPS',
112 asks: 'Where do new testers stop? The one number a beta most needs: passphrase, model, first turn, first answer.' },
113 { code: 4, name: 'panel.open', n: 'which panel, from PANELS',
114 asks: 'Which panels earn their place, and which has nobody ever opened?' },
115 { code: 5, name: 'diamond.new', n: 'how many Diamonds exist afterwards',
116 asks: 'Do people build a workspace of their own, or stay with the two Daimond seeds?' },
117 { code: 6, name: 'chat.new', n: 'how many chats exist afterwards',
118 asks: 'Is work divided into many short chats or kept in a few long ones? It decides what the rail should be optimised for.' },
119 { code: 7, name: 'turn.send', n: 'which turn of that chat this is, counting from one',
120 asks: 'How deep does a conversation actually go before it is left?' },
121 { code: 8, name: 'turn.done', n: 'seconds the turn took, end to end',
122 asks: 'What is a real turn worth of waiting, away from a developer machine on a fast line?' },
123 { code: 9, name: 'turn.stop', n: 'seconds into the turn when the user stopped it',
124 asks: 'Giving up early means it was going wrong; giving up late means it was too slow. Two different fixes.' },
125 { code: 10, name: 'turn.fail', n: 'which failure, from FAILURES',
126 asks: 'Which provider failure do testers actually meet, as against the ones we imagine?' },
127 { code: 11, name: 'chat.leave', n: 'how many turns the chat had when it was left',
128 asks: 'Abandoned after one turn is a different story from finished after twenty. This is where people give up.' },
129 { code: 12, name: 'tool.run', n: 'which tool, from TOOLS',
130 asks: 'Which capabilities are reached for? A tool nobody runs is a tool to remove or to explain better.' },
131 { code: 13, name: 'tool.fail', n: 'which tool, from TOOLS',
132 asks: 'Which capability breaks in the field, on machines we do not have?' },
133 { code: 14, name: 'error.thrown', n: 'how many uncaught errors so far this session, counting this one',
134 asks: 'Is the app throwing? The events before it in the same batch say roughly where, without a stack trace.' },
135 { code: 15, name: 'sync.done', n: 'seconds the push took',
136 asks: 'Is syncing usable on a real connection, or only on ours?' },
137 { code: 16, name: 'sync.fail', n: 'which failure, from FAILURES',
138 asks: 'Which sync failure is worth fixing first?' },
139 { code: 17, name: 'storage.high', n: 'megabytes held when the storage warning appeared',
140 asks: 'Does anybody reach the storage wall, and at what size?' },
141 { code: 18, name: 'buy.open', n: 'which offer, from OFFERS',
142 asks: 'Does anyone try to pay at all? Reaching for the offer is the signal; buying is the next one.' },
143 { code: 19, name: 'buy.done', n: 'which offer, from OFFERS',
144 asks: 'And does checkout finish? The gap between this and buy.open is where money is lost.' },
145 { code: 20, name: 'update.take', n: 'seconds from the new build being offered to the reload',
146 asks: 'Do testers get onto the fix, or stay on the build that has the fault?' },
147 ];
148
149 // ── The ordinal tables ──────────────────────────────────────
150 //
151 // The fields above that say "from PANELS" and so on take a number out of one
152 // of these fixed lists. This is the discipline that keeps a name off the
153 // wire: a panel, tool or failure that is not listed here becomes 0, which
154 // means "something else". It is never sent as text, and never added to at
155 // runtime.
156
157 /// The panels of the three-zone layout, BY THE ID THE APP ACTUALLY USES.
158 ///
159 /// Every name here is a `data-panel` attribute in `www/index.html`, and that
160 /// is not a detail: this table was first written from the panels as they are
161 /// spoken about -- chat, files, diamonds, terminal, viewer -- and the app
162 /// calls those `ai`, `work`, `rail`, `term` and `preview`. Emitting
163 /// `ordinal(PANELS, id)` against that list would have reported twelve of the
164 /// seventeen panels as 0, "something else", and the operator would have read
165 /// a table saying nobody opens anything.
166 ///
167 /// Nothing was collected under the old list -- the client had never been
168 /// loaded -- so it is corrected rather than appended to. From here it is
169 /// fixed: a panel added to the app goes on the END, and a panel renamed keeps
170 /// its position, or every number already gathered changes meaning.
171 //
172 // `social` sits at 16 because that is where `improve` sat: decision 13
173 // renamed the panel, and a rename that MOVED it would silently change the
174 // meaning of every number already gathered under 16.
175 var PANELS = ['other', 'ai', 'rail', 'work', 'web', 'preview', 'doc', 'mail',
176 'msg', 'compose', 'term', 'graph', 'spend', 'trash', 'agents', 'tools',
177 'social', 'pending'];
178
179 /// The tools a Diamond can run, BY THE NAME THE MODEL CALLS THEM BY.
180 ///
181 /// Checked against the wasm registry rather than against memory, for the
182 /// reason `PANELS` above gives: `agent` is called `spawn_agent`, and half the
183 /// registry -- the editing, searching, showing and browsing tools, which are
184 /// most of what a coding session actually runs -- was missing, so every one
185 /// of them would have been reported as 'other'. `mail_send` and `mail_sync`
186 /// are gone because no such tool exists; mail is a panel, not a tool call.
187 ///
188 /// The first eleven keep their positions, because those names were right.
189 /// From here a tool goes on the END.
190 ///
191 /// AND IT DRIFTED AGAIN. The twelve after `web_snapshot` were added on
192 /// 2026-08-28: the whole Social half, the spreadsheet and document tools, the
193 /// links, `ask`, `runs` and `verify` -- twelve of the registry's thirty-six,
194 /// so a third of every `tool.run` and `tool.fail` reported as 'other' and the
195 /// operator's picture of what a tool pack is worth was wrong for exactly the
196 /// tools a pack would be sold as. The paragraph above says this list is
197 /// checked against the registry, and it was: once, by hand, by somebody who
198 /// then left nothing behind that would notice. `dev/verify_telemetry.mjs`
199 /// now reads `Tool::name` out of `src/tools.rs` and fails on a name that is
200 /// not here, so a fourth drift cannot be silent.
201 var TOOLS = ['other', 'file_read', 'file_write', 'file_list', 'file_move',
202 'file_delete', 'dir_create', 'web_fetch', 'web_search', 'web_click',
203 'web_type', 'file_edit', 'file_glob', 'file_search', 'file_show',
204 'file_fetch', 'shell', 'run', 'spawn_agent', 'typst_compile',
205 'web_open', 'web_read', 'web_scroll', 'web_close', 'web_snapshot',
206 'ask', 'social_read', 'social_send', 'sheet_read', 'sheet_write',
207 'doc_edit', 'runs', 'verify', 'artefact_add', 'link_list', 'link_add',
208 'link_remove'];
209
210 /// Why something did not work. Deliberately coarse: a class of failure is
211 /// actionable and a message is not sendable.
212 var FAILURES = ['other', 'offline', 'refused', 'rate_limited', 'too_long',
213 'server_error', 'conflict', 'too_large', 'timed_out'];
214
215 /// What is on sale.
216 var OFFERS = ['other', 'credits', 'pro', 'pack'];
217
218 /// How far into a first run somebody got.
219 var STEPS = ['other', 'gate_shown', 'identity_made', 'unlocked',
220 'model_connected', 'turn_sent', 'turn_answered'];
221
222 /// The locales Daimond ships. The envelope carries the index, so we can see
223 /// whether a translation is being used without asking anybody.
224 var LOCALES = ['other', 'en', 'es', 'de', 'fr', 'pt-BR', 'zh-Hans', 'ja', 'ko'];
225
226 /// Every key that may appear in a batch. Nothing outside this set is built
227 /// by `pack()`, and `onlyIntegers()` refuses a batch carrying one.
228 ///
229 /// v this payload's version
230 /// b which build, as an integer (see `buildOrdinal`)
231 /// l which locale, from LOCALES
232 /// w the beta wave this account was let into
233 /// t the moment the batch was sent, in whole seconds since 1970
234 /// d events dropped since the last batch, because the buffer was full
235 /// e the events: [code, milliseconds since the session began, n]
236 var PAYLOAD_KEYS = ['v', 'b', 'l', 'w', 't', 'd', 'e'];
237
238 /// This payload's shape. Bumped only if the three-integer line changes.
239 var PAYLOAD_VERSION = 1;
240
241 /// Where a batch goes. A constant with no query string, so the address
242 /// itself cannot carry anything either.
243 var ENDPOINT = '/api/telemetry';
244
245 /// The most events one batch may carry. Beyond this the OLDEST are dropped
246 /// and counted into `d`: a session that throws five hundred times should
247 /// cost one batch, not five hundred, and the count is what says it happened.
248 var MAX_BATCH = 256;
249
250 /// How often a non-empty buffer is sent, in milliseconds.
251 var FLUSH_MS = 60000;
252
253 /// The largest COUNT that may travel. Anything above it, below zero, or not
254 /// a whole number becomes 0 -- a wrong count is a nuisance, an unbounded one
255 /// is a way to smuggle.
256 var MAX_N = 2147483647;
257
258 // ── Two fields that are not counts, and must not share a count's ceiling ──
259 //
260 // `MAX_N` guarded EVERY field, including two that are not quantities at all,
261 // and the damage was silent at both ends.
262 //
263 // `b` IS AN IDENTIFIER. `buildOrdinal` reads eight hex digits, which is a u32,
264 // and `MAX_N` is i32::MAX -- so every build id whose first hex digit is 8-f
265 // was floored to 0 by `whole()` on the last step before the wire. Measured
266 // against `verify/transparency.jsonl`, 65 of the 128 builds sealed when this
267 // was written: a coin flip on one hex digit deciding whether a batch could be
268 // attributed to a release at all. The gateway carried the SAME ceiling
269 // (`MAX_N` in `gateway/src/handlers/telemetry.rs`), so the zeroing was the only
270 // thing preventing something worse -- had the true ordinal arrived, the
271 // gateway would have refused the WHOLE BATCH, every event in it, for a field
272 // that is not an event. Widening one end alone turns silent misattribution
273 // into total loss, which is why both move together.
274 //
275 // `t` IS A CLOCK. Whole seconds since 1970 pass i32::MAX on 19 January 2038,
276 // after which every batch would carry `t: 0`. It is bounded, but bounded by a
277 // date rather than by a count's ceiling.
278 //
279 // THE RATIONALE ABOVE STILL HOLDS, and is the reason these are ceilings and
280 // not an exemption. "An unbounded number is a way to smuggle" is an argument
281 // about how much a field can carry; both of these are ONE value per batch, and
282 // each gains a single bit over what it had. A per-event count keeps `MAX_N`
283 // unchanged, which is where the capacity would actually be.
284
285 /// The ceiling on `b`, the build ordinal: eight hex digits is a u32.
286 var MAX_BUILD = 4294967295;
287
288 /// The ceiling on `t`, the send stamp: whole seconds to 2100-01-01T00:00:00Z.
289 var MAX_TIME = 4102444800;
290
291 /// The ceiling for each envelope key. `e` is not here: its rows are counts and
292 /// millisecond offsets, and they keep `MAX_N`.
293 var LIMITS = { v: MAX_N, b: MAX_BUILD, l: MAX_N, w: MAX_N, t: MAX_TIME, d: MAX_N };
294
295 // ── The recorder ────────────────────────────────────────────
296 //
297 // This is the consent gate, and it is a shape rather than a flag.
298 //
299 // `rec` holds the buffer, the session clock, the beta wave and the closure
300 // that reaches the network. All four are minted together by `consent()` and
301 // exist nowhere else. Until then `emit()` has nowhere to put an event --
302 // there is no buffer, so nothing accumulates to be sent later either -- and
303 // `flush()` has neither anything to send nor an address to send it to.
304 //
305 // Deleting the check in `emit()` would not open a channel; it would throw on
306 // a null. That is the difference between this and a boolean.
307
308 var rec = null;
309
310 // ── The remembered agreement ────────────────────────────────
311 //
312 // One key, holding the id of the account that agreed. Not a boolean, because
313 // a boolean cannot tell two people sharing a device apart; not a wave,
314 // because a wave is shared by everybody in an intake. See the header for why
315 // this exists at all, having once been argued against.
316
317 /// Where the agreeing account is written down.
318 var REMEMBER = 'daimond-telemetry';
319
320 /// The account that agreed on this device, or ''.
321 function remembered() {
322 try { return localStorage.getItem(REMEMBER) || ''; }
323 catch (e) { return ''; } // private mode: nothing is remembered.
324 }
325
326 /// Write the agreement down, or rub it out.
327 ///
328 /// Both directions fail silently. Storage a browser refuses is a session
329 /// that has to be asked again, which is the safe way for this to break: the
330 /// failure that matters is the other one, and it cannot happen here because
331 /// nothing is ever read as consent that was not written by `consent()`.
332 function remember(account) {
333 try {
334 if (account) localStorage.setItem(REMEMBER, account);
335 else localStorage.removeItem(REMEMBER);
336 } catch (e) { /* private mode, or a full store */ }
337 }
338
339 /// Agree to the beta, and start recording.
340 ///
341 /// THE ONE FUNCTION A PERSON'S OWN "YES" CALLS. Two callers, both in
342 /// `www/js/passcode.js` and both a button the person pressed: the card shown
343 /// when a passcode is redeemed, and the same question in the Credits drawer
344 /// for somebody who said no then and has changed their mind. Nothing else
345 /// may call it. A boot does not call it -- that is `resume()`, which cannot
346 /// create an agreement that was never given.
347 ///
348 /// # Arguments
349 /// * `grant` - Must carry `wave`, a whole number above zero naming the beta
350 /// intake this account was let into. There is no default: a recorder
351 /// cannot be minted from nothing, which is what makes a forgotten `if`
352 /// unable to start one. `account` is the gateway's id for whoever agreed,
353 /// and is what gets written down; without it the agreement holds for this
354 /// session only, because there is nothing to scope a memory to.
355 ///
356 /// # Returns
357 /// True if a recorder was minted.
358 function consent(grant) {
359 var account = (grant && typeof grant.account === 'string') ? grant.account : '';
360 if (account) remember(account);
361 if (rec) return true;
362 var wave = grant && grant.wave;
363 if (typeof wave !== 'number' || !isFinite(wave) || Math.floor(wave) !== wave || wave < 1) {
364 return false;
365 }
366 rec = makeRecorder(wave);
367 return true;
368 }
369
370 /// Start again on a device where this account has already agreed.
371 ///
372 /// The boot path, and it is deliberately weaker than `consent()`: it can
373 /// only restore an agreement, never make one. Three things must line up, and
374 /// each is one of the objections that kept consent unremembered until the
375 /// gateway could answer them (see the header):
376 ///
377 /// 1. the gateway says this account is in the beta, and names the intake;
378 /// 2. the account it names is the account that agreed on this device;
379 /// 3. something was written down at all.
380 ///
381 /// Miss any one and this returns false and mints nothing, which is the same
382 /// state as a device that has never been asked.
383 ///
384 /// # Arguments
385 /// * `grant` - `{wave, account}` as the gateway answered it this boot, NOT
386 /// as anything on the device remembers it.
387 ///
388 /// # Returns
389 /// True if a recorder was restored.
390 function resume(grant) {
391 if (rec) return true;
392 var account = (grant && typeof grant.account === 'string') ? grant.account : '';
393 if (!account) return false;
394 if (remembered() !== account) return false;
395 return consent(grant);
396 }
397
398 /// Has this account agreed on this device?
399 ///
400 /// For a surface that has to draw the question with the answer already in
401 /// it. It READS; like `armed()` it can never grant, and unlike `armed()` it
402 /// is true across a reload, which is what a settings control has to show.
403 function agreed(account) {
404 return !!account && remembered() === account;
405 }
406
407 /// Take the grant back. Nothing more is recorded, and nothing already
408 /// recorded is sent.
409 ///
410 /// The mirror of `consent()`, and deliberately the same shape: it does not
411 /// set a flag saying stop, it DESTROYS the thing consent minted. The buffer
412 /// goes with it, which is the whole difference between a withdrawal and a
413 /// promise to stop soon -- a tester who withdraws and then watches a batch
414 /// leave has been lied to, and the batch that would have left is the one
415 /// carrying what they did in the minute before they changed their mind.
416 ///
417 /// THE MEMORY GOES FIRST, and it goes whether or not anything is recording.
418 /// A withdrawal made in a session that never armed is still a withdrawal,
419 /// and a withdrawal a reload undid would be the cruellest bug in this file.
420 ///
421 /// # Returns
422 /// True if there was a recorder to destroy.
423 function withdraw() {
424 remember(null);
425 if (!rec) return false;
426 rec.close();
427 rec = null;
428 return true;
429 }
430
431 /// Everything a consenting session needs, and the only path to the network.
432 ///
433 /// The `fetch` below is inside this closure on purpose: there is no
434 /// module-level function that posts, so no other code in this file -- or a
435 /// later edit to it -- can send a batch without a grant having produced this
436 /// object first.
437 function makeRecorder(wave) {
438 var buf = [];
439 var t0 = Date.now();
440 var dropped = 0;
441 var build = 0;
442 var timer = null;
443
444 // Which build this is, as a number. Read from the same `build.json` the
445 // updater reads; failing that it stays 0, which the gateway takes as
446 // "unknown" rather than refusing the batch.
447 //
448 // A flush WAITS on this read. It is the difference between a batch that
449 // can be attributed to a release and one that cannot, and the first
450 // batch of a session -- the one carrying how the app started -- is
451 // exactly the one a race would rob of its build. `dev/verify_telemetry`
452 // asserts a real build ordinal for that reason; it caught this racing.
453 var known;
454 try {
455 known = fetch('build.json', { cache: 'no-store' })
456 .then(function (r) { return r.ok ? r.json() : null; })
457 .then(function (j) { build = buildOrdinal(j && j.build); })
458 .catch(function () { /* unknown build; batches still count */ });
459 } catch (e) { known = null; /* no fetch, no build id */ }
460 if (!known || typeof known.then !== 'function') known = Promise.resolve();
461
462 var self = {
463 wave: wave,
464
465 push: function (code, n) {
466 var dt = Date.now() - t0;
467 if (!(dt >= 0)) dt = 0;
468 buf.push([code, Math.min(Math.floor(dt), 86400000), n]);
469 // Oldest out first. The newest events are the ones nearest
470 // whatever went wrong.
471 while (buf.length > MAX_BATCH) { buf.shift(); dropped++; }
472 if (!timer) {
473 timer = setTimeout(function () { timer = null; self.flush(); }, FLUSH_MS);
474 }
475 },
476
477 /// Stop, and take the buffer with it.
478 ///
479 /// Called only by `withdraw()`. The timer is cleared and the buffer
480 /// emptied HERE rather than by the caller, because both live in this
481 /// closure and nothing outside it can reach either -- which is the
482 /// same fact that makes a withdrawal written anywhere else a lie. A
483 /// module that replaced `window.DaimondTelemetry` wholesale would
484 /// leave this timer running with this buffer in it, and the batch
485 /// would go a minute later with `armed()` reading false the whole
486 /// time. Measured, on 2026-08-14, before this existed.
487 close: function () {
488 if (timer) { clearTimeout(timer); timer = null; }
489 buf.length = 0;
490 dropped = 0;
491 },
492
493 /// Build a batch and send it. Resolves true if one went.
494 flush: function () {
495 if (!buf.length) return Promise.resolve(false);
496 return known.then(function () {
497 if (!buf.length) return false; // a flush overtook us
498 var body = pack(self.wave, build, buf, dropped);
499 if (!onlyIntegers(body)) {
500 // Unreachable by construction -- `pack` builds integers
501 // and nothing else -- so reaching it means this file has
502 // been changed in a way that breaks its promise. Drop
503 // the batch rather than send an unknown shape.
504 buf.length = 0; dropped = 0;
505 return false;
506 }
507 buf.length = 0; dropped = 0;
508 return fetch(ENDPOINT, {
509 method: 'POST',
510 credentials: 'same-origin',
511 headers: { 'Content-Type': 'application/json' },
512 body: JSON.stringify(body),
513 keepalive: true,
514 }).then(function (r) { return !!r && r.ok; })
515 .catch(function () { return false; });
516 });
517 },
518 };
519 return self;
520 }
521
522 // ── Emitting ────────────────────────────────────────────────
523
524 /// Code for a name, or 0 if it is not one of ours.
525 var CODES = {};
526 EVENTS.forEach(function (e) { CODES[e.name] = e.code; });
527
528 /// Record one event.
529 ///
530 /// The name is looked up and the CODE is what is kept; the name itself never
531 /// reaches the buffer, so it cannot reach the wire even by mistake. An
532 /// unknown name is dropped entirely rather than passed through, because
533 /// passing it through is exactly how a caller's string would travel.
534 ///
535 /// # Arguments
536 /// * `name` - One of the names in EVENTS.
537 /// * `n` - This event's one number, per its `n` line in EVENTS. Anything
538 /// that is not a whole number in [0, MAX_N] becomes 0.
539 function emit(name, n) {
540 if (!rec) return false;
541 var code = CODES[name];
542 if (!code) return false;
543 rec.push(code, whole(n));
544 return true;
545 }
546
547 /// A whole number in range, or 0.
548 ///
549 /// # Arguments
550 /// * `n` - What the caller has.
551 /// * `max` - The ceiling for THIS field, defaulting to a count's. A field
552 /// passing the wrong one here is how an identifier came to be judged as a
553 /// quantity; see the ceilings above.
554 function whole(n, max) {
555 if (typeof max !== 'number') max = MAX_N;
556 if (typeof n !== 'number' || !isFinite(n)) return 0;
557 n = Math.floor(n);
558 if (n < 0 || n > max) return 0;
559 return n;
560 }
561
562 /// The position of `name` in one of the ordinal tables, or 0 for "something
563 /// else". Exported so callers pass a number rather than inventing one, and
564 /// so a name outside the table can only ever become 0.
565 function ordinal(table, name) {
566 var i = table.indexOf(name);
567 return i > 0 ? i : 0;
568 }
569
570 // ── The payload ─────────────────────────────────────────────
571
572 /// The only function that builds what is sent. Integers throughout, keys
573 /// drawn from PAYLOAD_KEYS, and no argument reaches it except numbers that
574 /// have already been through `whole()`.
575 function pack(wave, build, events, dropped) {
576 var e = [];
577 for (var i = 0; i < events.length; i++) {
578 e.push([whole(events[i][0]), whole(events[i][1]), whole(events[i][2])]);
579 }
580 return {
581 v: PAYLOAD_VERSION,
582 b: whole(build, MAX_BUILD),
583 l: localeOrdinal(),
584 w: whole(wave),
585 // Through `whole` like everything else, so the one gate on what
586 // travels has no exception in it -- and with the clock's own ceiling,
587 // not a count's.
588 t: whole(Math.floor(Date.now() / 1000), MAX_TIME),
589 d: whole(dropped),
590 e: e,
591 };
592 }
593
594 /// A build id is twelve hex characters. The first eight of them, read as a
595 /// number, identify the build without carrying a string: the operator maps
596 /// it back through `build.json` or the transparency log.
597 function buildOrdinal(id) {
598 if (typeof id !== 'string' || !/^[0-9a-f]{8}/.test(id)) return 0;
599 var n = parseInt(id.slice(0, 8), 16);
600 return isFinite(n) ? n : 0;
601 }
602
603 /// Which of the shipped locales is in use, from LOCALES.
604 function localeOrdinal() {
605 var code = '';
606 try { code = window.DaimondI18n ? window.DaimondI18n.locale() : ''; }
607 catch (e) { code = ''; }
608 return ordinal(LOCALES, code);
609 }
610
611 /// Is this batch numbers all the way down?
612 ///
613 /// The last gate before the wire, and a belt over the braces: `pack()`
614 /// already builds nothing but integers. It is here so that a future edit
615 /// which adds a field has to defeat a check rather than merely forget one --
616 /// and so that this file states its promise as code a reader can run, not
617 /// only as a paragraph they have to believe.
618 function onlyIntegers(body) {
619 if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
620 var keys = Object.keys(body);
621 for (var i = 0; i < keys.length; i++) {
622 if (PAYLOAD_KEYS.indexOf(keys[i]) === -1) return false;
623 }
624 // Each envelope field against ITS OWN ceiling. A single ceiling here would
625 // re-impose the defect one step later: `pack` would build the true build
626 // ordinal and this gate would then drop the whole batch.
627 return keys.every(function (k) {
628 return k === 'e' ? rowsAreIntegers(body[k]) : isInt(body[k], LIMITS[k]);
629 });
630 }
631
632 function rowsAreIntegers(rows) {
633 if (!Array.isArray(rows)) return false;
634 return rows.every(function (row) {
635 return Array.isArray(row) && row.length === 3
636 && row.every(function (n) { return isInt(n, MAX_N); });
637 });
638 }
639
640 function isInt(x, max) {
641 if (typeof max !== 'number') max = MAX_N;
642 return typeof x === 'number' && isFinite(x) && Math.floor(x) === x
643 && x >= 0 && x <= max;
644 }
645
646 // ── What the rest of the app sees ───────────────────────────
647
648 var api = {
649 // The vocabulary, exported so a checker can compare it with the
650 // gateway's copy and so nothing else invents an event name.
651 EVENTS: EVENTS,
652 PANELS: PANELS,
653 TOOLS: TOOLS,
654 FAILURES: FAILURES,
655 OFFERS: OFFERS,
656 STEPS: STEPS,
657 LOCALES: LOCALES,
658 PAYLOAD_KEYS: PAYLOAD_KEYS,
659 PAYLOAD_VERSION: PAYLOAD_VERSION,
660 ENDPOINT: ENDPOINT,
661 MAX_BATCH: MAX_BATCH,
662 // The three ceilings, exported so `dev/verify_telemetry.mjs` can hold
663 // them against the gateway's copies. Two constants that have to match are
664 // two constants that will eventually not, and when these two did not
665 // match, half of all builds lost their identity on the way out.
666 MAX_N: MAX_N,
667 MAX_BUILD: MAX_BUILD,
668 MAX_TIME: MAX_TIME,
669
670 // Recording.
671 emit: emit,
672 ordinal: ordinal,
673
674 // Consent, and a way for a test or a settings pane to ask whether it
675 // has been given. `armed` READS; it can never grant.
676 consent: consent,
677 resume: resume,
678 withdraw: withdraw,
679 agreed: agreed,
680 armed: function () { return !!rec; },
681 wave: function () { return rec ? rec.wave : 0; },
682
683 // Send now. Nothing to send and nowhere to send it, until consent.
684 flush: function () { return rec ? rec.flush() : Promise.resolve(false); },
685
686 // Exported for the checker only. Both are pure: neither adds a path to
687 // the network, which stays inside the recorder's closure.
688 onlyIntegers: onlyIntegers,
689 buildOrdinal: buildOrdinal,
690 pack: pack,
691 };
692
693 if (typeof window !== 'undefined') window.DaimondTelemetry = api;
694 if (typeof module !== 'undefined' && module.exports) module.exports = api;
695})();