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 | })(); |