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

Size
5.2 KB
Lines
146
Language
javascript
SHA-256
b55e1c859d17…
View on GitHub

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
7export 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. */
10export const CHECK_INTERVAL_MS = 60_000;
11/** A send that has not finished after this long counts as a network error. */
12export const SEND_TIMEOUT_MS = 30_000;
13
14/** Monotonic milliseconds (performance.now()); Date.now() only as a last resort. */
15export 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. */
21export 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 */
36export 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 */
135export 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}