@sphoro/bugcatch-web-sdk 0.14.4 → 0.16.0
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.
- package/README.md +390 -115
- package/dist/bugcatch.es.js +2798 -1797
- package/dist/bugcatch.es.js.map +1 -1
- package/dist/bugcatch.umd.js +804 -450
- package/dist/bugcatch.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/identity.js +277 -0
- package/src/index.d.ts +145 -7
- package/src/index.js +326 -36
- package/src/references.js +155 -3
- package/src/updates.js +36 -6
- package/src/widget.js +1170 -456
package/src/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { mountWidget } from './widget.js';
|
|
|
6
6
|
import { uploadAttachment, normalizeMime, REPORT_MIMES } from './capture.js';
|
|
7
7
|
import {
|
|
8
8
|
watchUpdates, createUpdateNotifier, fetchMyReports, fetchReport, writeToken, readToken,
|
|
9
|
-
clearToken, sendMessage, verifyFix, callKindsForProject,
|
|
9
|
+
clearToken, scopeReports, sendMessage, verifyFix, callKindsForProject,
|
|
10
10
|
reportByCall, liveCallAvailable, requestLiveCall, giveUpLiveCall,
|
|
11
11
|
answerIncomingCall, declineIncomingCall,
|
|
12
12
|
readUnread, rememberUpdate, forgetUpdate, forgetReportUpdates, forgetAllUpdates,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { joinLiveCall, liveCallSupported, openMicrophone } from './live-call.js';
|
|
20
20
|
import { showIncomingCall } from './incoming-call.js';
|
|
21
21
|
import { referenceFields } from './references.js';
|
|
22
|
+
import { normalizeUser, mergeContext, checkOptions, cleanApiBase } from './identity.js';
|
|
22
23
|
|
|
23
24
|
// Replaced at build time from package.json. The guard is for anyone importing
|
|
24
25
|
// src/ directly, where the define never runs.
|
|
@@ -78,6 +79,25 @@ const DEFAULTS = {
|
|
|
78
79
|
// that can suppress a call the person can see ringing. Set `voice: false` to
|
|
79
80
|
// switch the whole thing off.
|
|
80
81
|
onIncomingCall: null,
|
|
82
|
+
// Called with { bugId, title, via: 'form' | 'call', result } once a report
|
|
83
|
+
// has been accepted — from the widget, from report(), or from pressing Call.
|
|
84
|
+
// The same thing is emitted as the 'report' event.
|
|
85
|
+
onReportSubmitted: null,
|
|
86
|
+
// Called with { bugId, phase, title, detail } when the AI assistant working
|
|
87
|
+
// a report moves: 'analyzing' → 'needs_info' | 'confirming' | 'reviewing' |
|
|
88
|
+
// 'resolving' | 'resolved'. Seen whenever the report is fetched — the widget
|
|
89
|
+
// does while it is open; myReport(id) does for an integration's own view.
|
|
90
|
+
onAssistantPhase: null,
|
|
91
|
+
// Called with { action, error } when something the SDK did on the page's
|
|
92
|
+
// behalf failed (a report that was refused, a listener that threw). Also the
|
|
93
|
+
// 'error' event.
|
|
94
|
+
onError: null,
|
|
95
|
+
// Business context for the screen the SDK starts on — see setContext().
|
|
96
|
+
context: null,
|
|
97
|
+
// Log what the SDK is doing (identify, context, each report's shape) to the
|
|
98
|
+
// console. Off in production; the warnings about misconfiguration are
|
|
99
|
+
// printed either way.
|
|
100
|
+
debug: false,
|
|
81
101
|
};
|
|
82
102
|
|
|
83
103
|
class BugCatchClient {
|
|
@@ -105,6 +125,40 @@ class BugCatchClient {
|
|
|
105
125
|
// event -> Set<handler>. Several parts of a host app can listen without
|
|
106
126
|
// having to share the single `onUpdate` config callback between them.
|
|
107
127
|
this.listeners = new Map();
|
|
128
|
+
// What setContext() has said about the screen the reporter is on. Its own
|
|
129
|
+
// field rather than part of config, so init() — which rebuilds config —
|
|
130
|
+
// cannot wipe a context set before it ran.
|
|
131
|
+
this.context = {};
|
|
132
|
+
// Warnings already printed, so a misconfiguration in a render loop says so
|
|
133
|
+
// once rather than a thousand times.
|
|
134
|
+
this.warned = new Set();
|
|
135
|
+
// The last assistant phase seen per report, so onAssistantPhase fires on a
|
|
136
|
+
// change rather than on every poll.
|
|
137
|
+
this.phases = new Map();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A misconfiguration, said once. Always printed — it is what saves a day of guessing. */
|
|
141
|
+
warn(message) {
|
|
142
|
+
if (this.warned.has(message)) return;
|
|
143
|
+
this.warned.add(message);
|
|
144
|
+
// eslint-disable-next-line no-console
|
|
145
|
+
console.warn(`[BugCatch] ${message}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What the SDK is doing, for `debug: true`. */
|
|
149
|
+
debugLog(...args) {
|
|
150
|
+
// eslint-disable-next-line no-console
|
|
151
|
+
if (this.config.debug) console.info('[BugCatch]', ...args);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Call a host callback without letting it break the SDK. */
|
|
155
|
+
#callback(name, payload) {
|
|
156
|
+
const fn = this.config[name];
|
|
157
|
+
if (typeof fn !== 'function') return;
|
|
158
|
+
try { fn(payload); } catch (err) {
|
|
159
|
+
// eslint-disable-next-line no-console
|
|
160
|
+
console.error(`[BugCatch] your ${name} callback threw:`, err);
|
|
161
|
+
}
|
|
108
162
|
}
|
|
109
163
|
|
|
110
164
|
init(options = {}) {
|
|
@@ -112,10 +166,21 @@ class BugCatchClient {
|
|
|
112
166
|
console.warn('[BugCatch] init() called twice — ignoring the second call.');
|
|
113
167
|
return this;
|
|
114
168
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
169
|
+
const { errors, warnings } = checkOptions(options, {
|
|
170
|
+
pageHost: typeof location !== 'undefined' ? location.hostname : '',
|
|
171
|
+
});
|
|
172
|
+
if (errors.length) throw new Error(`[BugCatch] ${errors[0]}`);
|
|
173
|
+
for (const w of warnings) this.warn(w);
|
|
174
|
+
|
|
175
|
+
// identify() and setContext() may run before init() — an app that knows its
|
|
176
|
+
// user before it has loaded its config is ordinary. Rebuilding config here
|
|
177
|
+
// used to throw that user away; it is carried over unless init names one.
|
|
178
|
+
const earlier = this.config.user;
|
|
179
|
+
this.config = { ...DEFAULTS, ...options, apiBase: cleanApiBase(options.apiBase ?? DEFAULTS.apiBase) };
|
|
180
|
+
this.config.user = options.user !== undefined
|
|
181
|
+
? normalizeUser(options.user, (m) => this.warn(m.replace('identify()', 'init({ user })')))
|
|
182
|
+
: earlier || null;
|
|
183
|
+
if (options.context) this.setContext(options.context);
|
|
119
184
|
|
|
120
185
|
this.consoleBuffer = createRingBuffer(this.config.consoleLimit);
|
|
121
186
|
this.networkBuffer = createRingBuffer(this.config.networkLimit);
|
|
@@ -123,6 +188,9 @@ class BugCatchClient {
|
|
|
123
188
|
if (this.config.captureConsole) this.teardown.push(installConsoleCapture(this.consoleBuffer));
|
|
124
189
|
if (this.config.captureNetwork) this.teardown.push(installNetworkCapture(this.networkBuffer));
|
|
125
190
|
if (this.config.captureErrors) this.teardown.push(installErrorCapture(this.consoleBuffer));
|
|
191
|
+
// Before anything reads the watch token: it is kept, and asked about, per
|
|
192
|
+
// project. See scopeReports.
|
|
193
|
+
scopeReports(this.config.projectId);
|
|
126
194
|
if (this.config.widget) this.teardown.push(mountWidget(this));
|
|
127
195
|
|
|
128
196
|
// Only if this browser has actually filed something. A visitor who has
|
|
@@ -141,9 +209,49 @@ class BugCatchClient {
|
|
|
141
209
|
}
|
|
142
210
|
|
|
143
211
|
this.started = true;
|
|
212
|
+
this.#sayHello();
|
|
213
|
+
this.debugLog('initialised', {
|
|
214
|
+
version: SDK_IDENTITY.version, apiBase: this.config.apiBase, projectId: this.config.projectId,
|
|
215
|
+
user: this.config.user, context: this.context,
|
|
216
|
+
});
|
|
144
217
|
return this;
|
|
145
218
|
}
|
|
146
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Tell the project this site has the SDK — once a day per browser, a few
|
|
222
|
+
* seconds after init so identify() and setContext() have had their chance.
|
|
223
|
+
*
|
|
224
|
+
* What the Web SDK settings page shows under "Installed on": which site,
|
|
225
|
+
* which version, whether identify() is wired. Nothing personal goes: the
|
|
226
|
+
* origin comes from the browser's own Origin header, and the rest is a
|
|
227
|
+
* version string and four booleans. Every failure is silent — this must
|
|
228
|
+
* never cost a page anything but one small request a day.
|
|
229
|
+
*/
|
|
230
|
+
#sayHello() {
|
|
231
|
+
if (typeof window === 'undefined' || typeof fetch !== 'function') return;
|
|
232
|
+
const mark = `bugcatch:hello:${this.config.projectId}`;
|
|
233
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
234
|
+
try { if (window.localStorage.getItem(mark) === today) return; } catch { /* storage blocked: say it anyway */ }
|
|
235
|
+
const timer = setTimeout(() => {
|
|
236
|
+
try { window.localStorage.setItem(mark, today); } catch { /* fine */ }
|
|
237
|
+
fetch(`${this.config.apiBase}/ingest/hello`, {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
keepalive: true,
|
|
240
|
+
headers: { 'Content-Type': 'application/json', 'X-BugCatch-Key': this.config.apiKey },
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
projectId: this.config.projectId,
|
|
243
|
+
version: SDK_IDENTITY.version,
|
|
244
|
+
identify: Boolean(this.config.user),
|
|
245
|
+
context: Boolean(this.context && Object.keys(this.context).length),
|
|
246
|
+
widget: Boolean(this.config.widget),
|
|
247
|
+
environment: this.config.environment || null,
|
|
248
|
+
release: this.config.release || null,
|
|
249
|
+
}),
|
|
250
|
+
}).catch(() => {});
|
|
251
|
+
}, 5000);
|
|
252
|
+
this.teardown.push(() => clearTimeout(timer));
|
|
253
|
+
}
|
|
254
|
+
|
|
147
255
|
/**
|
|
148
256
|
* Open the update stream. Idempotent — init() and a first report both reach
|
|
149
257
|
* here, and a second connection would double every notification.
|
|
@@ -223,7 +331,7 @@ class BugCatchClient {
|
|
|
223
331
|
} catch (err) {
|
|
224
332
|
// Someone else's handler, on someone else's page.
|
|
225
333
|
// eslint-disable-next-line no-console
|
|
226
|
-
console.error(
|
|
334
|
+
console.error(`[BugCatch] a '${event}' listener threw:`, err);
|
|
227
335
|
}
|
|
228
336
|
}
|
|
229
337
|
}
|
|
@@ -327,8 +435,44 @@ class BugCatchClient {
|
|
|
327
435
|
return verifyFix({ apiBase: this.config.apiBase, bugId, result, resolvedAt, note, metadata: context.metadata, logs: context.logs });
|
|
328
436
|
}
|
|
329
437
|
|
|
330
|
-
myReport(bugId) {
|
|
331
|
-
|
|
438
|
+
async myReport(bugId) {
|
|
439
|
+
const report = await fetchReport({ apiBase: this.config.apiBase, bugId });
|
|
440
|
+
this.#notePhase(bugId, report?.assistant);
|
|
441
|
+
return report;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Tell the host app the assistant moved, once per change.
|
|
446
|
+
*
|
|
447
|
+
* The phase is only known when a report is fetched, which the widget does
|
|
448
|
+
* every few seconds while its thread is open and an integration does through
|
|
449
|
+
* myReport(). The first sighting counts as a change: a host that has just
|
|
450
|
+
* opened a report wants to know it is being analysed.
|
|
451
|
+
*/
|
|
452
|
+
#notePhase(bugId, assistant) {
|
|
453
|
+
const phase = assistant?.phase;
|
|
454
|
+
if (!bugId || typeof phase !== 'string') return;
|
|
455
|
+
if (this.phases.get(bugId) === phase) return;
|
|
456
|
+
this.phases.set(bugId, phase);
|
|
457
|
+
const detail = { bugId, phase, title: assistant.title ?? null, detail: assistant.detail ?? null };
|
|
458
|
+
this.debugLog('assistant', detail);
|
|
459
|
+
this.#callback('onAssistantPhase', detail);
|
|
460
|
+
this.emit('assistant', detail);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** A report was accepted: tell the host app, however it was filed. */
|
|
464
|
+
#submitted(result, { title, via }) {
|
|
465
|
+
if (!result?.id) return;
|
|
466
|
+
const detail = { bugId: result.id, title: title ?? null, via, result };
|
|
467
|
+
this.debugLog('report accepted', detail);
|
|
468
|
+
this.#callback('onReportSubmitted', detail);
|
|
469
|
+
this.emit('report', detail);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Something done on the page's behalf failed. */
|
|
473
|
+
#failed(action, error) {
|
|
474
|
+
this.#callback('onError', { action, error });
|
|
475
|
+
this.emit('error', { action, error });
|
|
332
476
|
}
|
|
333
477
|
|
|
334
478
|
/**
|
|
@@ -356,7 +500,9 @@ class BugCatchClient {
|
|
|
356
500
|
* copies of this would drift, and the half that drifted would be the one that
|
|
357
501
|
* stopped attaching the console.
|
|
358
502
|
*/
|
|
359
|
-
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [] }) {
|
|
503
|
+
async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [], context = null }) {
|
|
504
|
+
// This report's own context over the screen's, without keeping it.
|
|
505
|
+
const ctx = context ? mergeContext(this.context, context, (m) => this.warn(m.replace('setContext()', 'report({ context })'))) : this.context;
|
|
360
506
|
return {
|
|
361
507
|
projectId: this.config.projectId,
|
|
362
508
|
...(title ? { title } : {}),
|
|
@@ -373,7 +519,13 @@ class BugCatchClient {
|
|
|
373
519
|
environment: this.config.environment,
|
|
374
520
|
buildId: this.config.buildId,
|
|
375
521
|
...metadata,
|
|
522
|
+
// Who is signed in, as identify() shaped it: `id`, `email` and `phone`
|
|
523
|
+
// are where the API and the ownership check read them, and `loanId`,
|
|
524
|
+
// `applicationId`, `customerNumber` become trusted lookup keys.
|
|
376
525
|
user: this.config.user || undefined,
|
|
526
|
+
// The screen the report came from — setContext(). Its identifiers
|
|
527
|
+
// outrank the same kind on `user`.
|
|
528
|
+
...(Object.keys(ctx).length ? { context: ctx } : {}),
|
|
377
529
|
sdk: SDK_IDENTITY,
|
|
378
530
|
},
|
|
379
531
|
logs: {
|
|
@@ -401,24 +553,59 @@ class BugCatchClient {
|
|
|
401
553
|
* a project switched sign-in on, and then a signed-in reporter pressing Call
|
|
402
554
|
* was told to sign in.
|
|
403
555
|
*/
|
|
556
|
+
/**
|
|
557
|
+
* The host's `beforeSend`, applied to anything about to leave the browser.
|
|
558
|
+
*
|
|
559
|
+
* Its own method because it was written into `report()` alone, and a
|
|
560
|
+
* reporter pressing Call files a report by another route — carrying the same
|
|
561
|
+
* console buffer, the same network buffer and the same identify() fields,
|
|
562
|
+
* past a hook whose whole purpose is to strip them. A host that set this to
|
|
563
|
+
* scrub PII was scrubbed on one button and not the other.
|
|
564
|
+
*
|
|
565
|
+
* Returns the payload to send, or false when the host cancelled.
|
|
566
|
+
*/
|
|
567
|
+
#screen(payload) {
|
|
568
|
+
if (typeof this.config.beforeSend !== 'function') return payload;
|
|
569
|
+
let result;
|
|
570
|
+
try {
|
|
571
|
+
result = this.config.beforeSend(payload);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
// A throwing hook is not permission to send what it was meant to clean.
|
|
574
|
+
this.warn(`beforeSend threw, so nothing was sent: ${err?.message || err}`);
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
if (result === false) return false;
|
|
578
|
+
return result && typeof result === 'object' ? result : payload;
|
|
579
|
+
}
|
|
580
|
+
|
|
404
581
|
async #fileByCall() {
|
|
405
582
|
const session = readSession(this.config.projectId);
|
|
406
583
|
|
|
584
|
+
// The same hook the form honours. Cancelling here cancels the call: there
|
|
585
|
+
// is nothing to ring about once the report has been refused.
|
|
586
|
+
const payload = this.#screen(await this.#payload({}));
|
|
587
|
+
if (payload === false) {
|
|
588
|
+
this.debugLog('call cancelled by beforeSend');
|
|
589
|
+
return { cancelled: true };
|
|
590
|
+
}
|
|
591
|
+
|
|
407
592
|
let filed;
|
|
408
593
|
try {
|
|
409
594
|
filed = await reportByCall({
|
|
410
595
|
apiBase: this.config.apiBase,
|
|
411
596
|
apiKey: this.config.apiKey,
|
|
412
597
|
token: session?.token,
|
|
413
|
-
payload
|
|
598
|
+
payload,
|
|
414
599
|
});
|
|
415
600
|
} catch (err) {
|
|
416
601
|
// A session the server no longer recognises, dropped exactly as
|
|
417
602
|
// `report()` drops it: the widget re-reads state every time it opens, so
|
|
418
603
|
// forgetting it now is what makes it draw the sign-in step instead.
|
|
419
604
|
if (err.status === 401 && session) clearSession(this.config.projectId);
|
|
605
|
+
this.#failed('call', err);
|
|
420
606
|
throw err;
|
|
421
607
|
}
|
|
608
|
+
this.#submitted(filed, { title: null, via: 'call' });
|
|
422
609
|
|
|
423
610
|
// The same token `report()` keeps, and for the same reason: it is what lets
|
|
424
611
|
// this browser hear what happens to the report afterwards.
|
|
@@ -648,7 +835,12 @@ class BugCatchClient {
|
|
|
648
835
|
// Set after the await, not before: a call that never connected is not one
|
|
649
836
|
// this page is on, and leaving the slot taken would block the retry.
|
|
650
837
|
this.call = handle;
|
|
651
|
-
|
|
838
|
+
// Whether the workspace keeps this call. The widget says so before anybody
|
|
839
|
+
// speaks — the team's incoming card has always been told; the person
|
|
840
|
+
// placing the call was not, which is the wrong way round.
|
|
841
|
+
return Object.assign(handle, {
|
|
842
|
+
bugId, callId: started.call_id, live: true, filed, recorded: Boolean(started.recorded),
|
|
843
|
+
});
|
|
652
844
|
}
|
|
653
845
|
|
|
654
846
|
/**
|
|
@@ -864,10 +1056,64 @@ class BugCatchClient {
|
|
|
864
1056
|
* Pass null to forget them again on sign-out.
|
|
865
1057
|
*/
|
|
866
1058
|
identify(user) {
|
|
867
|
-
|
|
1059
|
+
const shaped = normalizeUser(user, (m) => this.warn(m));
|
|
1060
|
+
// Anything but null/undefined that yielded nothing usable is a mistake, not
|
|
1061
|
+
// a sign-out: keep who was there rather than silently forgetting them.
|
|
1062
|
+
if (user !== null && user !== undefined && !shaped) return this;
|
|
1063
|
+
this.config.user = shaped;
|
|
1064
|
+
this.debugLog('identify', shaped);
|
|
868
1065
|
return this;
|
|
869
1066
|
}
|
|
870
1067
|
|
|
1068
|
+
/**
|
|
1069
|
+
* Say what the reporter is looking at — the loan, application or customer on
|
|
1070
|
+
* this screen — so every report filed from it carries that record.
|
|
1071
|
+
*
|
|
1072
|
+
* BugCatch.setContext({ loanId: 'L26090028459', screen: 'repayment' });
|
|
1073
|
+
* BugCatch.setContext({ loanId: null }); // left the loan screen
|
|
1074
|
+
* BugCatch.setContext(null); // clear everything
|
|
1075
|
+
*
|
|
1076
|
+
* Merged over what was set before and kept until changed — for the life of
|
|
1077
|
+
* the page, not across reloads. `loanId`, `applicationId` and
|
|
1078
|
+
* `customerNumber` are read by bugcatch as identifiers your app asserted, and
|
|
1079
|
+
* outrank the same field on identify(); anything else is readable metadata.
|
|
1080
|
+
*/
|
|
1081
|
+
setContext(context) {
|
|
1082
|
+
this.context = mergeContext(this.context, context, (m) => this.warn(m));
|
|
1083
|
+
this.debugLog('context', this.context);
|
|
1084
|
+
return this;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** What setContext() currently holds (a copy). */
|
|
1088
|
+
getContext() {
|
|
1089
|
+
return { ...this.context };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Open the report panel, as if the floating button had been pressed — for a
|
|
1094
|
+
* "Report a problem" item in your own menu.
|
|
1095
|
+
*
|
|
1096
|
+
* Needs the built-in widget (`widget: true`, the default). With `widget:
|
|
1097
|
+
* false` there is no panel to open: draw your own form and call report().
|
|
1098
|
+
*/
|
|
1099
|
+
open() {
|
|
1100
|
+
if (!this.started) { this.warn('open() was called before init() — nothing to open yet.'); return this; }
|
|
1101
|
+
if (!this.widgetControls) { this.warn('open() needs the built-in widget; it was turned off with widget: false.'); return this; }
|
|
1102
|
+
this.widgetControls.open();
|
|
1103
|
+
return this;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Close the report panel if it is open. */
|
|
1107
|
+
close() {
|
|
1108
|
+
this.widgetControls?.close();
|
|
1109
|
+
return this;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** Whether the report panel is open. */
|
|
1113
|
+
isOpen() {
|
|
1114
|
+
return Boolean(this.widgetControls?.isOpen());
|
|
1115
|
+
}
|
|
1116
|
+
|
|
871
1117
|
/**
|
|
872
1118
|
* Upload a screenshot, recording or file and get back the storage key that
|
|
873
1119
|
* `report({ attachments })` references.
|
|
@@ -890,10 +1136,14 @@ class BugCatchClient {
|
|
|
890
1136
|
/** File a bug with everything captured so far. */
|
|
891
1137
|
async report({
|
|
892
1138
|
title, description = '', priority = 'MEDIUM', metadata = {}, attachments = [],
|
|
893
|
-
references = [],
|
|
1139
|
+
references = [], context = null,
|
|
894
1140
|
} = {}) {
|
|
895
1141
|
if (!this.started) throw new Error('[BugCatch] call init() first.');
|
|
896
|
-
if (
|
|
1142
|
+
if (typeof title !== 'string' || !title.trim()) throw new Error('[BugCatch] a title is required.');
|
|
1143
|
+
if (!['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'].includes(String(priority).toUpperCase())) {
|
|
1144
|
+
this.warn(`priority "${priority}" is not one of LOW, MEDIUM, HIGH, CRITICAL — sent as MEDIUM.`);
|
|
1145
|
+
priority = 'MEDIUM';
|
|
1146
|
+
}
|
|
897
1147
|
|
|
898
1148
|
// Drop anything the API would reject outright, so one bad entry can't cost
|
|
899
1149
|
// the reporter the whole report.
|
|
@@ -908,30 +1158,54 @@ class BugCatchClient {
|
|
|
908
1158
|
mime: normalizeMime(a.mime, REPORT_MIMES),
|
|
909
1159
|
}));
|
|
910
1160
|
|
|
1161
|
+
// Only well-formed entries: one malformed reference must not cost the
|
|
1162
|
+
// reporter the report (the API refuses the whole body on a bad one).
|
|
1163
|
+
const refs = (Array.isArray(references) ? references : [])
|
|
1164
|
+
.filter((r) => r && typeof r.key === 'string' && r.key && (typeof r.value === 'string' || typeof r.value === 'number'))
|
|
1165
|
+
.map((r) => ({ key: r.key.slice(0, 64), value: String(r.value).trim().slice(0, 120) }))
|
|
1166
|
+
.filter((r) => r.value);
|
|
1167
|
+
if (Array.isArray(references) && refs.length < references.length) {
|
|
1168
|
+
this.warn('report({ references }) takes [{ key, value }] with string values; malformed entries were dropped.');
|
|
1169
|
+
}
|
|
1170
|
+
|
|
911
1171
|
let payload = await this.#payload({
|
|
912
|
-
title: title.trim(), description
|
|
1172
|
+
title: title.trim(), description: typeof description === 'string' ? description : String(description ?? ''),
|
|
1173
|
+
priority: String(priority).toUpperCase(), metadata, media, references: refs, context,
|
|
913
1174
|
});
|
|
914
1175
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
if (result && typeof result === 'object') payload = result;
|
|
919
|
-
}
|
|
1176
|
+
const screened = this.#screen(payload);
|
|
1177
|
+
if (screened === false) return { cancelled: true };
|
|
1178
|
+
payload = screened;
|
|
920
1179
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
'Content-Type': 'application/json',
|
|
926
|
-
'X-BugCatch-Key': this.config.apiKey,
|
|
927
|
-
// Present only where the reporter signed in. The server takes the
|
|
928
|
-
// address from here rather than from the payload when it is — on a
|
|
929
|
-
// project that requires sign-in, letting the page name a different
|
|
930
|
-
// reporter would undo the requirement.
|
|
931
|
-
...(session?.token ? { 'X-BugCatch-Session': session.token } : {}),
|
|
932
|
-
},
|
|
933
|
-
body: JSON.stringify(payload),
|
|
1180
|
+
this.debugLog('sending report', {
|
|
1181
|
+
title: payload.title, user: payload.metadata?.user, context: payload.metadata?.context,
|
|
1182
|
+
references: payload.references, console: payload.logs?.console?.entries?.length,
|
|
1183
|
+
network: payload.logs?.network?.entries?.length,
|
|
934
1184
|
});
|
|
1185
|
+
const session = readSession(this.config.projectId);
|
|
1186
|
+
let res;
|
|
1187
|
+
try {
|
|
1188
|
+
res = await fetch(`${this.config.apiBase}/ingest/bugs`, {
|
|
1189
|
+
method: 'POST',
|
|
1190
|
+
headers: {
|
|
1191
|
+
'Content-Type': 'application/json',
|
|
1192
|
+
'X-BugCatch-Key': this.config.apiKey,
|
|
1193
|
+
// Present only where the reporter signed in. The server takes the
|
|
1194
|
+
// address from here rather than from the payload when it is — on a
|
|
1195
|
+
// project that requires sign-in, letting the page name a different
|
|
1196
|
+
// reporter would undo the requirement.
|
|
1197
|
+
...(session?.token ? { 'X-BugCatch-Session': session.token } : {}),
|
|
1198
|
+
},
|
|
1199
|
+
body: JSON.stringify(payload),
|
|
1200
|
+
});
|
|
1201
|
+
} catch (cause) {
|
|
1202
|
+
// No response at all: the wrong apiBase, the API down, or CORS refusing
|
|
1203
|
+
// this origin. The browser's own "Failed to fetch" names none of them.
|
|
1204
|
+
const err = new Error(`[BugCatch] could not reach ${this.config.apiBase} — check apiBase, and that this site's origin is allowed by the API.`);
|
|
1205
|
+
err.cause = cause;
|
|
1206
|
+
this.#failed('report', err);
|
|
1207
|
+
throw err;
|
|
1208
|
+
}
|
|
935
1209
|
|
|
936
1210
|
// A session the server no longer recognises — expired, or ended by a
|
|
937
1211
|
// sign-in somewhere else. Dropped here rather than left to fail again on
|
|
@@ -945,10 +1219,17 @@ class BugCatchClient {
|
|
|
945
1219
|
const body = await res.json();
|
|
946
1220
|
detail = Array.isArray(body.message) ? body.message.join(', ') : body.message || '';
|
|
947
1221
|
} catch { /* non-JSON error */ }
|
|
948
|
-
|
|
1222
|
+
// The two refusals an integrator hits first, said in their terms.
|
|
1223
|
+
if (res.status === 401 && !session) detail ||= 'the apiKey was not accepted';
|
|
1224
|
+
if (res.status === 403) detail ||= 'the apiKey is not allowed to report to this projectId';
|
|
1225
|
+
const err = new Error(`[BugCatch] report failed (HTTP ${res.status})${detail ? ': ' + detail : ''}`);
|
|
1226
|
+
err.status = res.status;
|
|
1227
|
+
this.#failed('report', err);
|
|
1228
|
+
throw err;
|
|
949
1229
|
}
|
|
950
1230
|
|
|
951
1231
|
const result = await res.json();
|
|
1232
|
+
this.#submitted(result, { title: payload.title ?? title.trim(), via: 'form' });
|
|
952
1233
|
|
|
953
1234
|
// The API returns a watch token only when it could attribute the report to
|
|
954
1235
|
// someone — which means identify() was called with an email. Without one
|
|
@@ -1095,7 +1376,6 @@ class BugCatchClient {
|
|
|
1095
1376
|
return result;
|
|
1096
1377
|
}
|
|
1097
1378
|
|
|
1098
|
-
/** What would be sent right now — useful for debugging integrations. */
|
|
1099
1379
|
/**
|
|
1100
1380
|
* What this project is already known to be having trouble with.
|
|
1101
1381
|
*
|
|
@@ -1153,7 +1433,12 @@ class BugCatchClient {
|
|
|
1153
1433
|
bugId,
|
|
1154
1434
|
reporterEmail: email || this.config.user?.email,
|
|
1155
1435
|
reporterName: name || this.config.user?.name,
|
|
1156
|
-
metadata: {
|
|
1436
|
+
metadata: {
|
|
1437
|
+
...environment,
|
|
1438
|
+
user: this.config.user || undefined,
|
|
1439
|
+
...(Object.keys(this.context).length ? { context: this.context } : {}),
|
|
1440
|
+
sdk: SDK_IDENTITY,
|
|
1441
|
+
},
|
|
1157
1442
|
logs: {
|
|
1158
1443
|
console: { entries: this.consoleBuffer.all() },
|
|
1159
1444
|
network: { entries: this.networkBuffer.all() },
|
|
@@ -1162,8 +1447,11 @@ class BugCatchClient {
|
|
|
1162
1447
|
});
|
|
1163
1448
|
}
|
|
1164
1449
|
|
|
1450
|
+
/** What would be sent right now — useful for debugging integrations. */
|
|
1165
1451
|
snapshot() {
|
|
1166
1452
|
return {
|
|
1453
|
+
user: this.config.user || null,
|
|
1454
|
+
context: { ...this.context },
|
|
1167
1455
|
console: this.consoleBuffer.all(),
|
|
1168
1456
|
network: this.networkBuffer.all(),
|
|
1169
1457
|
environment: collectEnvironmentSync(),
|
|
@@ -1196,5 +1484,7 @@ const BugCatch = new BugCatchClient();
|
|
|
1196
1484
|
// build, a named export alongside the default turns `window.BugCatch` into a
|
|
1197
1485
|
// namespace object, so `BugCatch.init(...)` from a <script> tag would break.
|
|
1198
1486
|
BugCatch.Client = BugCatchClient;
|
|
1487
|
+
// For support tickets and `console.log(BugCatch.version)`.
|
|
1488
|
+
BugCatch.version = SDK_IDENTITY.version;
|
|
1199
1489
|
|
|
1200
1490
|
export default BugCatch;
|