Viewing is open to everyone. Downloading and starring need a GitHub sign-in.
Sign in with GitHub
telemetry.mjs
v0.1.0-build.85
Latest
Commit ede38f5
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | // Telemetry sender for the CKey Web SDK (docs/adoption-and-telemetry.md). |
| 2 | // Pure JavaScript; the counters, batching, cadence and mode rules all live in |
| 3 | // the C core (include/ckey/telemetry.h). This module only does what the core |
| 4 | // never does: read clocks and POST a batch, asynchronously, off the decode |
| 5 | // path, then report the HTTP result back. |
| 6 | |
| 7 | export const CKEY_TELEMETRY_ENDPOINT = 'https://openckey.org/api/telemetry/v1/batch'; |
| 8 | |
| 9 | /** How often an armed sender asks the core whether a batch is due. */ |
| 10 | export const CHECK_INTERVAL_MS = 60_000; |
| 11 | /** A send that has not finished after this long counts as a network error. */ |
| 12 | export const SEND_TIMEOUT_MS = 30_000; |
| 13 | |
| 14 | /** Monotonic milliseconds (performance.now()); Date.now() only as a last resort. */ |
| 15 | export function monotonicNow() { |
| 16 | const p = globalThis.performance; |
| 17 | return p && typeof p.now === 'function' ? p.now() : Date.now(); |
| 18 | } |
| 19 | |
| 20 | /** The UTC day "YYYY-MM-DD" for a batch. */ |
| 21 | export function utcDay(date = new Date()) { |
| 22 | return date.toISOString().slice(0, 10); |
| 23 | } |
| 24 | |
| 25 | /** |
| 26 | * Drives one core's batch loop. |
| 27 | * |
| 28 | * core: { |
| 29 | * shouldSend(nowMs) -> boolean, |
| 30 | * takeBatch(utcDay) -> { json: string, id: number } | null, |
| 31 | * batchResult(id, httpStatus, nowMs) -> boolean // false: stale batch, ignored |
| 32 | * } |
| 33 | * fetch: a fetch-compatible function, or null to never send (counting is |
| 34 | * unaffected; set telemetry = 2 for local-only counting instead). |
| 35 | */ |
| 36 | export class TelemetrySender { |
| 37 | constructor({ |
| 38 | core, |
| 39 | fetch = globalThis.fetch ? globalThis.fetch.bind(globalThis) : null, |
| 40 | endpoint = CKEY_TELEMETRY_ENDPOINT, |
| 41 | now = monotonicNow, |
| 42 | day = () => utcDay(), |
| 43 | checkIntervalMs = CHECK_INTERVAL_MS, |
| 44 | timeoutMs = SEND_TIMEOUT_MS, |
| 45 | timers = globalThis, |
| 46 | } = {}) { |
| 47 | if (!core) throw new TypeError('TelemetrySender needs a core'); |
| 48 | this.core = core; |
| 49 | this.fetch = fetch; |
| 50 | this.endpoint = endpoint; |
| 51 | this.now = now; |
| 52 | this.day = day; |
| 53 | this.checkIntervalMs = checkIntervalMs; |
| 54 | this.timeoutMs = timeoutMs; |
| 55 | this.timers = timers; |
| 56 | this.timer = null; |
| 57 | this.inFlight = null; |
| 58 | } |
| 59 | |
| 60 | /** Starts periodic checks (idempotent). The timer never keeps Node alive. */ |
| 61 | arm() { |
| 62 | if (this.timer !== null || !this.fetch) return; |
| 63 | this.timer = this.timers.setInterval(() => void this.trySend(), this.checkIntervalMs); |
| 64 | if (this.timer && typeof this.timer.unref === 'function') this.timer.unref(); |
| 65 | } |
| 66 | |
| 67 | disarm() { |
| 68 | if (this.timer !== null) this.timers.clearInterval(this.timer); |
| 69 | this.timer = null; |
| 70 | } |
| 71 | |
| 72 | /** Schedules a send attempt on a later task, so callers never wait on it. */ |
| 73 | trySendSoon() { |
| 74 | if (!this.fetch) return; |
| 75 | const t = this.timers.setTimeout(() => void this.trySend(), 0); |
| 76 | if (t && typeof t.unref === 'function') t.unref(); |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Sends one batch if the core says one is due. Never throws or rejects. |
| 81 | * Resolves to the HTTP status (0 for a network error), or null when |
| 82 | * nothing was sent. |
| 83 | */ |
| 84 | trySend() { |
| 85 | if (this.inFlight) return this.inFlight; |
| 86 | if (!this.fetch) return Promise.resolve(null); |
| 87 | let batch; |
| 88 | try { |
| 89 | if (!this.core.shouldSend(this.now())) return Promise.resolve(null); |
| 90 | batch = this.core.takeBatch(this.day()); |
| 91 | } catch { |
| 92 | return Promise.resolve(null); |
| 93 | } |
| 94 | if (!batch || !batch.id) return Promise.resolve(null); |
| 95 | this.inFlight = this.#post(batch).finally(() => { |
| 96 | this.inFlight = null; |
| 97 | }); |
| 98 | return this.inFlight; |
| 99 | } |
| 100 | |
| 101 | async #post(batch) { |
| 102 | let status = 0; |
| 103 | try { |
| 104 | const init = { |
| 105 | method: 'POST', |
| 106 | keepalive: true, |
| 107 | headers: { 'content-type': 'application/json' }, |
| 108 | body: batch.json, |
| 109 | // No cookies and no Referer: the page URL is not telemetry. |
| 110 | credentials: 'omit', |
| 111 | referrerPolicy: 'no-referrer', |
| 112 | }; |
| 113 | if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') { |
| 114 | init.signal = AbortSignal.timeout(this.timeoutMs); |
| 115 | } |
| 116 | const res = await this.fetch(this.endpoint, init); |
| 117 | status = res && Number.isInteger(res.status) && res.status >= 0 && res.status <= 999 ? res.status : 0; |
| 118 | } catch { |
| 119 | status = 0; // offline, blocked, timed out: the core keeps the batch for later |
| 120 | } |
| 121 | try { |
| 122 | this.core.batchResult(batch.id, status, this.now()); |
| 123 | } catch { |
| 124 | /* the result of a stale batch is ignored by design */ |
| 125 | } |
| 126 | return status; |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Tries a send when the page is hidden (tab switched, app backgrounded, |
| 132 | * page being unloaded). keepalive lets the request outlive the page. |
| 133 | * Returns a function that removes the listeners. |
| 134 | */ |
| 135 | export function installBackgroundSend(sender, doc = globalThis.document, win = globalThis.window) { |
| 136 | const onVisibility = () => { |
| 137 | if (doc.visibilityState === 'hidden') void sender.trySend(); |
| 138 | }; |
| 139 | const onPageHide = () => void sender.trySend(); |
| 140 | if (doc && typeof doc.addEventListener === 'function') doc.addEventListener('visibilitychange', onVisibility); |
| 141 | if (win && typeof win.addEventListener === 'function') win.addEventListener('pagehide', onPageHide); |
| 142 | return () => { |
| 143 | if (doc && typeof doc.removeEventListener === 'function') doc.removeEventListener('visibilitychange', onVisibility); |
| 144 | if (win && typeof win.removeEventListener === 'function') win.removeEventListener('pagehide', onPageHide); |
| 145 | }; |
| 146 | } |