@sphoro/bugcatch-web-sdk 0.13.1

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.
@@ -0,0 +1,213 @@
1
+ // Ring-buffered capture of the things you wish you had when a bug comes in:
2
+ // console output, failed/slow requests, unhandled errors, and the environment.
3
+ //
4
+ // Everything is bounded — a long-lived page must not grow memory without limit.
5
+
6
+ import { redactUrl, redactText } from './redact.js';
7
+
8
+ export function createRingBuffer(limit) {
9
+ const items = [];
10
+ return {
11
+ push(item) {
12
+ items.push(item);
13
+ if (items.length > limit) items.splice(0, items.length - limit);
14
+ },
15
+ all: () => items.slice(),
16
+ clear: () => { items.length = 0; },
17
+ get size() { return items.length; },
18
+ };
19
+ }
20
+
21
+ const LEVELS = ['log', 'info', 'warn', 'error', 'debug'];
22
+
23
+ /** Wraps console.* and records what goes through, then calls the original. */
24
+ export function installConsoleCapture(buffer, { levels = ['warn', 'error', 'log', 'info'] } = {}) {
25
+ const originals = {};
26
+ for (const level of LEVELS) {
27
+ originals[level] = console[level];
28
+ if (!levels.includes(level)) continue;
29
+ console[level] = (...args) => {
30
+ try {
31
+ buffer.push({
32
+ level,
33
+ text: args.map(stringify).join(' ').slice(0, 2000),
34
+ at: new Date().toISOString(),
35
+ });
36
+ } catch { /* capture must never break the page */ }
37
+ originals[level].apply(console, args);
38
+ };
39
+ }
40
+ return () => { for (const level of LEVELS) console[level] = originals[level]; };
41
+ }
42
+
43
+ /**
44
+ * Records fetch + XHR: url, method, status, duration. Bodies are never read,
45
+ * which leaves the URL as the only place a credential can arrive — so every
46
+ * entry goes through redact.js on the way into the buffer.
47
+ */
48
+ export function installNetworkCapture(buffer, { slowMs = 2000 } = {}) {
49
+ const originalFetch = window.fetch;
50
+ const originalOpen = XMLHttpRequest.prototype.open;
51
+ const originalSend = XMLHttpRequest.prototype.send;
52
+
53
+ if (originalFetch) {
54
+ window.fetch = async function patchedFetch(...args) {
55
+ const started = performance.now();
56
+ const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
57
+ const method = args[1]?.method || (typeof args[0] === 'object' ? args[0]?.method : 'GET') || 'GET';
58
+ try {
59
+ const res = await originalFetch.apply(this, args);
60
+ record(buffer, { url, method, status: res.status, ms: Math.round(performance.now() - started), slowMs });
61
+ return res;
62
+ } catch (err) {
63
+ record(buffer, { url, method, status: 0, error: String(err?.message || err), ms: Math.round(performance.now() - started), slowMs });
64
+ throw err;
65
+ }
66
+ };
67
+ }
68
+
69
+ XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) {
70
+ this.__bc = { method, url, started: 0 };
71
+ return originalOpen.call(this, method, url, ...rest);
72
+ };
73
+ XMLHttpRequest.prototype.send = function patchedSend(...args) {
74
+ if (this.__bc) {
75
+ this.__bc.started = performance.now();
76
+ this.addEventListener('loadend', () => {
77
+ record(buffer, {
78
+ url: this.__bc.url,
79
+ method: this.__bc.method,
80
+ status: this.status,
81
+ ms: Math.round(performance.now() - this.__bc.started),
82
+ slowMs,
83
+ });
84
+ });
85
+ }
86
+ return originalSend.apply(this, args);
87
+ };
88
+
89
+ return () => {
90
+ if (originalFetch) window.fetch = originalFetch;
91
+ XMLHttpRequest.prototype.open = originalOpen;
92
+ XMLHttpRequest.prototype.send = originalSend;
93
+ };
94
+ }
95
+
96
+ function record(buffer, { url, method, status, ms, error, slowMs }) {
97
+ try {
98
+ buffer.push({
99
+ // Scrubbed before it is buffered, not before it is sent: a credential
100
+ // that never enters the ring buffer cannot leave with a report, and
101
+ // cannot be read back out of memory by the page either. Redact first,
102
+ // then truncate — truncating first would split a token into something
103
+ // no pattern recognises, and the fragment would still be a fragment of
104
+ // a live credential.
105
+ url: redactUrl(String(url)).slice(0, 500),
106
+ method,
107
+ status,
108
+ ms,
109
+ error: error === undefined ? undefined : redactText(error),
110
+ // Flagged so triage can spot the interesting rows without reading everything.
111
+ failed: status === 0 || status >= 400,
112
+ slow: ms >= slowMs,
113
+ at: new Date().toISOString(),
114
+ });
115
+ } catch { /* ignore */ }
116
+ }
117
+
118
+ /** Unhandled errors and rejections, appended to the console buffer. */
119
+ export function installErrorCapture(buffer) {
120
+ const onError = (event) => {
121
+ // Browsers already prefix event.message with "Uncaught" — don't say it twice.
122
+ const message = String(event.message || 'Error');
123
+ const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : '';
124
+ buffer.push({
125
+ level: 'error',
126
+ text: (/^uncaught/i.test(message) ? message : `Uncaught ${message}`) + where,
127
+ at: new Date().toISOString(),
128
+ });
129
+ };
130
+ const onRejection = (event) => {
131
+ buffer.push({
132
+ level: 'error',
133
+ text: `Unhandled rejection: ${stringify(event.reason)}`,
134
+ at: new Date().toISOString(),
135
+ });
136
+ };
137
+ window.addEventListener('error', onError);
138
+ window.addEventListener('unhandledrejection', onRejection);
139
+ return () => {
140
+ window.removeEventListener('error', onError);
141
+ window.removeEventListener('unhandledrejection', onRejection);
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Battery needs the async Battery Status API, so environment collection is
147
+ * async now. Unsupported browsers (Safari, Firefox) simply omit the fields.
148
+ */
149
+ export async function collectEnvironment() {
150
+ const base = collectEnvironmentSync();
151
+ try {
152
+ if (navigator.getBattery) {
153
+ const b = await navigator.getBattery();
154
+ base.battery = Math.round(b.level * 100);
155
+ base.charging = b.charging;
156
+ }
157
+ } catch { /* not available — leave the fields out */ }
158
+ return base;
159
+ }
160
+
161
+ export function collectEnvironmentSync() {
162
+ const nav = window.navigator || {};
163
+ return {
164
+ userAgent: nav.userAgent,
165
+ platform: nav.platform,
166
+ language: nav.language,
167
+ languages: nav.languages ? [...nav.languages] : null,
168
+ online: nav.onLine,
169
+ cookieEnabled: nav.cookieEnabled,
170
+ maxTouchPoints: nav.maxTouchPoints ?? null,
171
+ viewport: { width: window.innerWidth, height: window.innerHeight },
172
+ screen: {
173
+ width: window.screen?.width,
174
+ height: window.screen?.height,
175
+ availWidth: window.screen?.availWidth,
176
+ availHeight: window.screen?.availHeight,
177
+ colorDepth: window.screen?.colorDepth,
178
+ orientation: window.screen?.orientation?.type || null,
179
+ },
180
+ devicePixelRatio: window.devicePixelRatio,
181
+ referrer: document.referrer || null,
182
+ title: document.title,
183
+ capturedAt: new Date().toISOString(),
184
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
185
+ timezoneOffsetMinutes: new Date().getTimezoneOffset(),
186
+ // A hint, not a guarantee: `webdriver` is the only broadly-available signal
187
+ // that something other than a person is driving the page.
188
+ browserMode: nav.webdriver ? 'Automated' : 'Normal',
189
+ doNotTrack: nav.doNotTrack || null,
190
+ hardwareConcurrency: nav.hardwareConcurrency ?? null,
191
+ deviceMemory: nav.deviceMemory ?? null,
192
+ connection: nav.connection
193
+ ? {
194
+ effectiveType: nav.connection.effectiveType,
195
+ downlink: nav.connection.downlink,
196
+ rtt: nav.connection.rtt,
197
+ saveData: nav.connection.saveData,
198
+ }
199
+ : null,
200
+ prefersColorScheme: window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
201
+ prefersReducedMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches || false,
202
+ };
203
+ }
204
+
205
+ function stringify(value) {
206
+ if (typeof value === 'string') return value;
207
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
208
+ try {
209
+ return JSON.stringify(value);
210
+ } catch {
211
+ return String(value);
212
+ }
213
+ }
@@ -0,0 +1,324 @@
1
+ // The reporter's telephone.
2
+ //
3
+ // Everything else this SDK draws is a consequence of something the person on
4
+ // the page did: they pressed the widget, they filed a report, they asked to
5
+ // talk it through. This is the one thing that arrives unasked — a member of the
6
+ // team has a question about a report and is ringing to ask it — and that makes
7
+ // it the piece with the most rules around it.
8
+ //
9
+ // # What it is allowed to do
10
+ //
11
+ // Appear, and make a noise. That is the whole of it. No microphone is opened,
12
+ // no audio moves and nothing is connected until the person presses Answer, and
13
+ // if they press nothing at all it takes itself away when the ringing stops. The
14
+ // card is an offer; the SDK does not put a customer on a call because somebody
15
+ // at the vendor clicked a button.
16
+ //
17
+ // # And what it says
18
+ //
19
+ // A name, a product and what the call is about, because a card that says
20
+ // "someone is calling about a report" is indistinguishable from the thing every
21
+ // bank tells people to hang up on. And whether the call would be recorded,
22
+ // stated before Answer rather than after — on a peer-to-peer call the copy that
23
+ // gets kept is uplinked by this browser, so telling somebody afterwards is
24
+ // telling them about something that already happened.
25
+ //
26
+ // # The noise
27
+ //
28
+ // Synthesised, for the reason the dashboard's is: a ringtone as an audio file
29
+ // is a binary in a bundle that runs on somebody else's site, an extra request
30
+ // on their page, and a thing that can go missing behind a cache header. Four
31
+ // oscillators and a table of numbers cost none of that.
32
+ //
33
+ // It is also softer and shorter than the dashboard's. A phone on a support
34
+ // desk is meant to interrupt; this one is ringing in the middle of a stranger's
35
+ // shopping cart, and a page that suddenly plays a loud telephone at somebody is
36
+ // a page they close. If the browser refuses to play it at all — no interaction
37
+ // yet, which is its rule and not ours — the card rings silently rather than not
38
+ // appearing.
39
+
40
+ /**
41
+ * A quiet double-pulse, once every three seconds.
42
+ *
43
+ * Two notes rather than one so it reads as a telephone rather than as an alert,
44
+ * and a fifth apart because that interval is the one people hear as "ringing"
45
+ * across every phone system anybody has used.
46
+ */
47
+ const RING = {
48
+ cycleMs: 3000,
49
+ peak: 0.045,
50
+ bursts: [
51
+ { at: 0, seconds: 0.4, hz: [440, 660] },
52
+ { at: 0.55, seconds: 0.4, hz: [440, 660] },
53
+ ],
54
+ };
55
+
56
+ /**
57
+ * Whether anybody has touched this page yet.
58
+ *
59
+ * Tracked rather than asked, because there is nothing to ask: a browser will
60
+ * tell you an AudioContext is suspended, but only after you have built one —
61
+ * and building one on a page with no interaction is what writes
62
+ *
63
+ * The AudioContext was not allowed to start. It must be resumed (or
64
+ * created) after a user gesture on the page.
65
+ *
66
+ * into the console. This SDK runs on somebody else's site, and that console
67
+ * belongs to whoever is trying to debug their own product. A warning we cannot
68
+ * act on, for a sound that was never going to play, is not ours to put there.
69
+ */
70
+ let touched = false;
71
+ if (typeof window !== 'undefined' && window.addEventListener) {
72
+ for (const evt of ['pointerdown', 'keydown', 'touchstart']) {
73
+ // Capture, and it matters more here than it would in our own app: this runs
74
+ // on somebody else's page, whose handlers we have never seen and one of
75
+ // which may well stop a click before it reaches the window.
76
+ window.addEventListener(evt, () => { touched = true; }, {
77
+ once: true, capture: true, passive: true,
78
+ });
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The same question asked of the browser rather than of ourselves.
84
+ *
85
+ * Chrome and Edge implement this and are the browsers that enforce the policy
86
+ * in the first place, so where it matters it answers. It is the recovery for a
87
+ * host page that swallowed every gesture before we saw it — better to make a
88
+ * quiet noise the person expects than to stay silent because of somebody else's
89
+ * event handler.
90
+ */
91
+ function activatedByBrowser() {
92
+ try {
93
+ return Boolean(navigator.userActivation?.hasBeenActive);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Start ringing, and give back the way to stop.
101
+ *
102
+ * Returns a no-op teardown on every browser that will not do it, which is a
103
+ * real and ordinary case: a page nobody has clicked on yet has no audio
104
+ * permission, and the correct behaviour there is a card that appears in silence
105
+ * rather than no card.
106
+ */
107
+ function startRinging() {
108
+ if (!touched && !activatedByBrowser()) return () => {};
109
+
110
+ const Ctx = typeof window !== 'undefined' && (window.AudioContext || window.webkitAudioContext);
111
+ if (!Ctx) return () => {};
112
+
113
+ let context;
114
+ try {
115
+ context = new Ctx();
116
+ } catch {
117
+ return () => {};
118
+ }
119
+
120
+ let stopped = false;
121
+ let timer = null;
122
+ const voices = [];
123
+
124
+ const burst = ({ at, seconds, hz }) => {
125
+ const start = context.currentTime + at;
126
+ const gain = context.createGain();
127
+ gain.connect(context.destination);
128
+ // Shaped rather than switched. A square edge on a sine is a click, and a
129
+ // click every three seconds is more irritating than the tone it introduces.
130
+ gain.gain.setValueAtTime(0, start);
131
+ gain.gain.linearRampToValueAtTime(RING.peak, start + 0.02);
132
+ gain.gain.setValueAtTime(RING.peak, start + seconds - 0.06);
133
+ gain.gain.linearRampToValueAtTime(0, start + seconds);
134
+
135
+ for (const frequency of hz) {
136
+ const osc = context.createOscillator();
137
+ osc.type = 'sine';
138
+ osc.frequency.value = frequency;
139
+ osc.connect(gain);
140
+ osc.start(start);
141
+ osc.stop(start + seconds + 0.02);
142
+ voices.push(osc);
143
+ }
144
+ };
145
+
146
+ const cycle = () => {
147
+ if (stopped) return;
148
+ try {
149
+ RING.bursts.forEach(burst);
150
+ } catch { /* the card is still on screen, which is the part that matters */ }
151
+ };
152
+
153
+ const begin = () => {
154
+ if (stopped) return;
155
+ cycle();
156
+ timer = setInterval(cycle, RING.cycleMs);
157
+ };
158
+
159
+ if (context.state === 'running') begin();
160
+ else context.resume().then(begin).catch(() => {});
161
+
162
+ return () => {
163
+ stopped = true;
164
+ if (timer) clearInterval(timer);
165
+ // Stopped rather than left to finish. Answered, this is cut off by the
166
+ // microphone opening a fraction of a second later, and a ring still sounding
167
+ // over the first word of a conversation is the thing people apologise for.
168
+ for (const osc of voices) {
169
+ try { osc.stop(); } catch { /* already stopped on its own schedule */ }
170
+ }
171
+ try { context.close(); } catch { /* nothing left to close */ }
172
+ };
173
+ }
174
+
175
+ /** Seconds left until a deadline, floored at zero. */
176
+ function secondsLeft(ringsUntil) {
177
+ const until = Date.parse(ringsUntil || '');
178
+ if (Number.isNaN(until)) return 0;
179
+ return Math.max(0, Math.round((until - Date.now()) / 1000));
180
+ }
181
+
182
+ const BUTTON = [
183
+ 'flex:1',
184
+ 'padding:9px 12px',
185
+ 'border:0',
186
+ 'border-radius:8px',
187
+ 'font:600 13px/1.4 inherit',
188
+ 'cursor:pointer',
189
+ ].join(';');
190
+
191
+ /**
192
+ * Put the card on screen and ring until something happens to it.
193
+ *
194
+ * @param {object} opts
195
+ * @param {object} opts.call The `incomingCall` object off the update stream —
196
+ * `callId`, `from`, `project`, `reference`, `ringsUntil`, `recorded`.
197
+ * @param {string} opts.accent The integration's own colour, for Answer.
198
+ * @param {string} [opts.position] Same two words the toast takes.
199
+ * @param {() => void} opts.onAnswer Pressed Answer.
200
+ * @param {() => void} opts.onDecline Pressed Decline.
201
+ * @param {() => void} [opts.onExpire] Nobody pressed anything in time.
202
+ * @returns {(reason?: string) => void} Take it away. Called with no reason on
203
+ * an ordinary dismissal; the caller passes one only for its own logging.
204
+ */
205
+ export function showIncomingCall({
206
+ call, accent = '#5b6ef5', position = 'bottom-right',
207
+ onAnswer, onDecline, onExpire,
208
+ }) {
209
+ if (typeof document === 'undefined' || !document.body) return () => {};
210
+
211
+ const [vertical, horizontal] = String(position).split('-');
212
+ const el = document.createElement('div');
213
+ el.setAttribute('role', 'alertdialog');
214
+ el.setAttribute('aria-live', 'assertive');
215
+ el.setAttribute('aria-label', `${call.from || 'Support'} is calling about ${call.reference}`);
216
+ el.style.cssText = [
217
+ 'position:fixed',
218
+ `${vertical === 'top' ? 'top' : 'bottom'}:20px`,
219
+ `${horizontal === 'left' ? 'left' : 'right'}:20px`,
220
+ // Above the update toast, which uses the maximum and may be on screen at
221
+ // the same moment — a status line must not sit on top of a ringing phone.
222
+ 'z-index:2147483647',
223
+ 'width:300px',
224
+ 'max-width:calc(100vw - 40px)',
225
+ 'padding:14px',
226
+ 'border-radius:12px',
227
+ 'background:#1b1e26',
228
+ 'color:#eceef2',
229
+ 'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
230
+ 'box-shadow:0 18px 44px rgba(0,0,0,0.5)',
231
+ `border:1px solid ${accent}`,
232
+ 'opacity:0',
233
+ 'transform:translateY(8px)',
234
+ 'transition:opacity .2s ease,transform .2s ease',
235
+ ].join(';');
236
+
237
+ const heading = document.createElement('div');
238
+ heading.style.cssText = 'font-weight:600;font-size:14px;margin-bottom:2px';
239
+ // textContent throughout: every string on this card came from a person typing
240
+ // it, and it is being written into somebody else's page.
241
+ heading.textContent = `${call.from || 'Support'} is calling`;
242
+
243
+ const about = document.createElement('div');
244
+ about.style.cssText = 'color:#a4abba;margin-bottom:10px';
245
+ about.textContent = call.project
246
+ ? `About your report ${call.reference} · ${call.project}`
247
+ : `About your report ${call.reference}`;
248
+
249
+ const countdown = document.createElement('div');
250
+ countdown.style.cssText = 'color:#7d8598;font-size:12px;margin-bottom:10px';
251
+
252
+ const answer = document.createElement('button');
253
+ answer.type = 'button';
254
+ answer.textContent = 'Answer';
255
+ answer.style.cssText = `${BUTTON};background:${accent};color:#fff`;
256
+
257
+ const decline = document.createElement('button');
258
+ decline.type = 'button';
259
+ decline.textContent = 'Not now';
260
+ decline.style.cssText = `${BUTTON};background:#2b2f3a;color:#c8cedb`;
261
+
262
+ const actions = document.createElement('div');
263
+ actions.style.cssText = 'display:flex;gap:8px';
264
+ actions.append(answer, decline);
265
+
266
+ el.append(heading, about, countdown);
267
+ if (call.recorded) {
268
+ const kept = document.createElement('div');
269
+ kept.style.cssText = 'color:#7d8598;font-size:12px;margin-bottom:10px';
270
+ kept.textContent = 'This call will be recorded.';
271
+ el.append(kept);
272
+ }
273
+ el.append(actions);
274
+ document.body.appendChild(el);
275
+
276
+ const stopRinging = startRinging();
277
+ let closed = false;
278
+ let ticker = null;
279
+
280
+ const close = () => {
281
+ if (closed) return;
282
+ closed = true;
283
+ if (ticker) clearInterval(ticker);
284
+ stopRinging();
285
+ el.style.opacity = '0';
286
+ el.style.transform = 'translateY(8px)';
287
+ setTimeout(() => el.remove(), 250);
288
+ };
289
+
290
+ const tick = () => {
291
+ const left = secondsLeft(call.ringsUntil);
292
+ countdown.textContent = left
293
+ ? `Ringing — ${left}s`
294
+ : 'The call has stopped ringing.';
295
+ if (left) return;
296
+ // The deadline is the server's, not this browser's arithmetic, for the
297
+ // reason the API writes it down: two clocks disagreeing about when a phone
298
+ // stopped ringing is how somebody gets cut off mid-answer.
299
+ close();
300
+ try { onExpire?.(); } catch { /* theirs */ }
301
+ };
302
+ tick();
303
+ ticker = setInterval(tick, 1000);
304
+
305
+ answer.addEventListener('click', () => {
306
+ // Stopped before the handler runs, not after: opening a microphone takes a
307
+ // moment, and a phone still ringing during it sounds like the button
308
+ // did nothing.
309
+ close();
310
+ try { onAnswer?.(); } catch { /* theirs */ }
311
+ });
312
+
313
+ decline.addEventListener('click', () => {
314
+ close();
315
+ try { onDecline?.(); } catch { /* theirs */ }
316
+ });
317
+
318
+ requestAnimationFrame(() => {
319
+ el.style.opacity = '1';
320
+ el.style.transform = 'translateY(0)';
321
+ });
322
+
323
+ return close;
324
+ }