@sphoro/bugcatch-web-sdk 0.14.3 → 0.15.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/src/index.js CHANGED
@@ -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
- this.config = { ...DEFAULTS, ...options };
116
-
117
- if (!this.config.apiKey) throw new Error('[BugCatch] apiKey is required.');
118
- if (!this.config.projectId) throw new Error('[BugCatch] projectId is required.');
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);
@@ -141,6 +206,10 @@ class BugCatchClient {
141
206
  }
142
207
 
143
208
  this.started = true;
209
+ this.debugLog('initialised', {
210
+ version: SDK_IDENTITY.version, apiBase: this.config.apiBase, projectId: this.config.projectId,
211
+ user: this.config.user, context: this.context,
212
+ });
144
213
  return this;
145
214
  }
146
215
 
@@ -223,7 +292,7 @@ class BugCatchClient {
223
292
  } catch (err) {
224
293
  // Someone else's handler, on someone else's page.
225
294
  // eslint-disable-next-line no-console
226
- console.error('[BugCatch] an update listener threw:', err);
295
+ console.error(`[BugCatch] a '${event}' listener threw:`, err);
227
296
  }
228
297
  }
229
298
  }
@@ -327,8 +396,44 @@ class BugCatchClient {
327
396
  return verifyFix({ apiBase: this.config.apiBase, bugId, result, resolvedAt, note, metadata: context.metadata, logs: context.logs });
328
397
  }
329
398
 
330
- myReport(bugId) {
331
- return fetchReport({ apiBase: this.config.apiBase, bugId });
399
+ async myReport(bugId) {
400
+ const report = await fetchReport({ apiBase: this.config.apiBase, bugId });
401
+ this.#notePhase(bugId, report?.assistant);
402
+ return report;
403
+ }
404
+
405
+ /**
406
+ * Tell the host app the assistant moved, once per change.
407
+ *
408
+ * The phase is only known when a report is fetched, which the widget does
409
+ * every few seconds while its thread is open and an integration does through
410
+ * myReport(). The first sighting counts as a change: a host that has just
411
+ * opened a report wants to know it is being analysed.
412
+ */
413
+ #notePhase(bugId, assistant) {
414
+ const phase = assistant?.phase;
415
+ if (!bugId || typeof phase !== 'string') return;
416
+ if (this.phases.get(bugId) === phase) return;
417
+ this.phases.set(bugId, phase);
418
+ const detail = { bugId, phase, title: assistant.title ?? null, detail: assistant.detail ?? null };
419
+ this.debugLog('assistant', detail);
420
+ this.#callback('onAssistantPhase', detail);
421
+ this.emit('assistant', detail);
422
+ }
423
+
424
+ /** A report was accepted: tell the host app, however it was filed. */
425
+ #submitted(result, { title, via }) {
426
+ if (!result?.id) return;
427
+ const detail = { bugId: result.id, title: title ?? null, via, result };
428
+ this.debugLog('report accepted', detail);
429
+ this.#callback('onReportSubmitted', detail);
430
+ this.emit('report', detail);
431
+ }
432
+
433
+ /** Something done on the page's behalf failed. */
434
+ #failed(action, error) {
435
+ this.#callback('onError', { action, error });
436
+ this.emit('error', { action, error });
332
437
  }
333
438
 
334
439
  /**
@@ -356,7 +461,9 @@ class BugCatchClient {
356
461
  * copies of this would drift, and the half that drifted would be the one that
357
462
  * stopped attaching the console.
358
463
  */
359
- async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [] }) {
464
+ async #payload({ title, description = '', priority = 'MEDIUM', metadata = {}, media = [], references = [], context = null }) {
465
+ // This report's own context over the screen's, without keeping it.
466
+ const ctx = context ? mergeContext(this.context, context, (m) => this.warn(m.replace('setContext()', 'report({ context })'))) : this.context;
360
467
  return {
361
468
  projectId: this.config.projectId,
362
469
  ...(title ? { title } : {}),
@@ -373,7 +480,13 @@ class BugCatchClient {
373
480
  environment: this.config.environment,
374
481
  buildId: this.config.buildId,
375
482
  ...metadata,
483
+ // Who is signed in, as identify() shaped it: `id`, `email` and `phone`
484
+ // are where the API and the ownership check read them, and `loanId`,
485
+ // `applicationId`, `customerNumber` become trusted lookup keys.
376
486
  user: this.config.user || undefined,
487
+ // The screen the report came from — setContext(). Its identifiers
488
+ // outrank the same kind on `user`.
489
+ ...(Object.keys(ctx).length ? { context: ctx } : {}),
377
490
  sdk: SDK_IDENTITY,
378
491
  },
379
492
  logs: {
@@ -417,8 +530,10 @@ class BugCatchClient {
417
530
  // `report()` drops it: the widget re-reads state every time it opens, so
418
531
  // forgetting it now is what makes it draw the sign-in step instead.
419
532
  if (err.status === 401 && session) clearSession(this.config.projectId);
533
+ this.#failed('call', err);
420
534
  throw err;
421
535
  }
536
+ this.#submitted(filed, { title: null, via: 'call' });
422
537
 
423
538
  // The same token `report()` keeps, and for the same reason: it is what lets
424
539
  // this browser hear what happens to the report afterwards.
@@ -864,10 +979,64 @@ class BugCatchClient {
864
979
  * Pass null to forget them again on sign-out.
865
980
  */
866
981
  identify(user) {
867
- this.config.user = user;
982
+ const shaped = normalizeUser(user, (m) => this.warn(m));
983
+ // Anything but null/undefined that yielded nothing usable is a mistake, not
984
+ // a sign-out: keep who was there rather than silently forgetting them.
985
+ if (user !== null && user !== undefined && !shaped) return this;
986
+ this.config.user = shaped;
987
+ this.debugLog('identify', shaped);
988
+ return this;
989
+ }
990
+
991
+ /**
992
+ * Say what the reporter is looking at — the loan, application or customer on
993
+ * this screen — so every report filed from it carries that record.
994
+ *
995
+ * BugCatch.setContext({ loanId: 'L26090028459', screen: 'repayment' });
996
+ * BugCatch.setContext({ loanId: null }); // left the loan screen
997
+ * BugCatch.setContext(null); // clear everything
998
+ *
999
+ * Merged over what was set before and kept until changed — for the life of
1000
+ * the page, not across reloads. `loanId`, `applicationId` and
1001
+ * `customerNumber` are read by bugcatch as identifiers your app asserted, and
1002
+ * outrank the same field on identify(); anything else is readable metadata.
1003
+ */
1004
+ setContext(context) {
1005
+ this.context = mergeContext(this.context, context, (m) => this.warn(m));
1006
+ this.debugLog('context', this.context);
868
1007
  return this;
869
1008
  }
870
1009
 
1010
+ /** What setContext() currently holds (a copy). */
1011
+ getContext() {
1012
+ return { ...this.context };
1013
+ }
1014
+
1015
+ /**
1016
+ * Open the report panel, as if the floating button had been pressed — for a
1017
+ * "Report a problem" item in your own menu.
1018
+ *
1019
+ * Needs the built-in widget (`widget: true`, the default). With `widget:
1020
+ * false` there is no panel to open: draw your own form and call report().
1021
+ */
1022
+ open() {
1023
+ if (!this.started) { this.warn('open() was called before init() — nothing to open yet.'); return this; }
1024
+ if (!this.widgetControls) { this.warn('open() needs the built-in widget; it was turned off with widget: false.'); return this; }
1025
+ this.widgetControls.open();
1026
+ return this;
1027
+ }
1028
+
1029
+ /** Close the report panel if it is open. */
1030
+ close() {
1031
+ this.widgetControls?.close();
1032
+ return this;
1033
+ }
1034
+
1035
+ /** Whether the report panel is open. */
1036
+ isOpen() {
1037
+ return Boolean(this.widgetControls?.isOpen());
1038
+ }
1039
+
871
1040
  /**
872
1041
  * Upload a screenshot, recording or file and get back the storage key that
873
1042
  * `report({ attachments })` references.
@@ -890,10 +1059,14 @@ class BugCatchClient {
890
1059
  /** File a bug with everything captured so far. */
891
1060
  async report({
892
1061
  title, description = '', priority = 'MEDIUM', metadata = {}, attachments = [],
893
- references = [],
1062
+ references = [], context = null,
894
1063
  } = {}) {
895
1064
  if (!this.started) throw new Error('[BugCatch] call init() first.');
896
- if (!title || !title.trim()) throw new Error('[BugCatch] a title is required.');
1065
+ if (typeof title !== 'string' || !title.trim()) throw new Error('[BugCatch] a title is required.');
1066
+ if (!['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'].includes(String(priority).toUpperCase())) {
1067
+ this.warn(`priority "${priority}" is not one of LOW, MEDIUM, HIGH, CRITICAL — sent as MEDIUM.`);
1068
+ priority = 'MEDIUM';
1069
+ }
897
1070
 
898
1071
  // Drop anything the API would reject outright, so one bad entry can't cost
899
1072
  // the reporter the whole report.
@@ -908,8 +1081,19 @@ class BugCatchClient {
908
1081
  mime: normalizeMime(a.mime, REPORT_MIMES),
909
1082
  }));
910
1083
 
1084
+ // Only well-formed entries: one malformed reference must not cost the
1085
+ // reporter the report (the API refuses the whole body on a bad one).
1086
+ const refs = (Array.isArray(references) ? references : [])
1087
+ .filter((r) => r && typeof r.key === 'string' && r.key && (typeof r.value === 'string' || typeof r.value === 'number'))
1088
+ .map((r) => ({ key: r.key.slice(0, 64), value: String(r.value).trim().slice(0, 120) }))
1089
+ .filter((r) => r.value);
1090
+ if (Array.isArray(references) && refs.length < references.length) {
1091
+ this.warn('report({ references }) takes [{ key, value }] with string values; malformed entries were dropped.');
1092
+ }
1093
+
911
1094
  let payload = await this.#payload({
912
- title: title.trim(), description, priority, metadata, media, references,
1095
+ title: title.trim(), description: typeof description === 'string' ? description : String(description ?? ''),
1096
+ priority: String(priority).toUpperCase(), metadata, media, references: refs, context,
913
1097
  });
914
1098
 
915
1099
  if (typeof this.config.beforeSend === 'function') {
@@ -918,20 +1102,35 @@ class BugCatchClient {
918
1102
  if (result && typeof result === 'object') payload = result;
919
1103
  }
920
1104
 
921
- const session = readSession(this.config.projectId);
922
- const res = await fetch(`${this.config.apiBase}/ingest/bugs`, {
923
- method: 'POST',
924
- headers: {
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),
1105
+ this.debugLog('sending report', {
1106
+ title: payload.title, user: payload.metadata?.user, context: payload.metadata?.context,
1107
+ references: payload.references, console: payload.logs?.console?.entries?.length,
1108
+ network: payload.logs?.network?.entries?.length,
934
1109
  });
1110
+ const session = readSession(this.config.projectId);
1111
+ let res;
1112
+ try {
1113
+ res = await fetch(`${this.config.apiBase}/ingest/bugs`, {
1114
+ method: 'POST',
1115
+ headers: {
1116
+ 'Content-Type': 'application/json',
1117
+ 'X-BugCatch-Key': this.config.apiKey,
1118
+ // Present only where the reporter signed in. The server takes the
1119
+ // address from here rather than from the payload when it is — on a
1120
+ // project that requires sign-in, letting the page name a different
1121
+ // reporter would undo the requirement.
1122
+ ...(session?.token ? { 'X-BugCatch-Session': session.token } : {}),
1123
+ },
1124
+ body: JSON.stringify(payload),
1125
+ });
1126
+ } catch (cause) {
1127
+ // No response at all: the wrong apiBase, the API down, or CORS refusing
1128
+ // this origin. The browser's own "Failed to fetch" names none of them.
1129
+ const err = new Error(`[BugCatch] could not reach ${this.config.apiBase} — check apiBase, and that this site's origin is allowed by the API.`);
1130
+ err.cause = cause;
1131
+ this.#failed('report', err);
1132
+ throw err;
1133
+ }
935
1134
 
936
1135
  // A session the server no longer recognises — expired, or ended by a
937
1136
  // sign-in somewhere else. Dropped here rather than left to fail again on
@@ -945,10 +1144,17 @@ class BugCatchClient {
945
1144
  const body = await res.json();
946
1145
  detail = Array.isArray(body.message) ? body.message.join(', ') : body.message || '';
947
1146
  } catch { /* non-JSON error */ }
948
- throw new Error(`[BugCatch] report failed (HTTP ${res.status})${detail ? ': ' + detail : ''}`);
1147
+ // The two refusals an integrator hits first, said in their terms.
1148
+ if (res.status === 401 && !session) detail ||= 'the apiKey was not accepted';
1149
+ if (res.status === 403) detail ||= 'the apiKey is not allowed to report to this projectId';
1150
+ const err = new Error(`[BugCatch] report failed (HTTP ${res.status})${detail ? ': ' + detail : ''}`);
1151
+ err.status = res.status;
1152
+ this.#failed('report', err);
1153
+ throw err;
949
1154
  }
950
1155
 
951
1156
  const result = await res.json();
1157
+ this.#submitted(result, { title: payload.title ?? title.trim(), via: 'form' });
952
1158
 
953
1159
  // The API returns a watch token only when it could attribute the report to
954
1160
  // someone — which means identify() was called with an email. Without one
@@ -1095,7 +1301,6 @@ class BugCatchClient {
1095
1301
  return result;
1096
1302
  }
1097
1303
 
1098
- /** What would be sent right now — useful for debugging integrations. */
1099
1304
  /**
1100
1305
  * What this project is already known to be having trouble with.
1101
1306
  *
@@ -1153,7 +1358,12 @@ class BugCatchClient {
1153
1358
  bugId,
1154
1359
  reporterEmail: email || this.config.user?.email,
1155
1360
  reporterName: name || this.config.user?.name,
1156
- metadata: { ...environment, user: this.config.user || undefined, sdk: SDK_IDENTITY },
1361
+ metadata: {
1362
+ ...environment,
1363
+ user: this.config.user || undefined,
1364
+ ...(Object.keys(this.context).length ? { context: this.context } : {}),
1365
+ sdk: SDK_IDENTITY,
1366
+ },
1157
1367
  logs: {
1158
1368
  console: { entries: this.consoleBuffer.all() },
1159
1369
  network: { entries: this.networkBuffer.all() },
@@ -1162,8 +1372,11 @@ class BugCatchClient {
1162
1372
  });
1163
1373
  }
1164
1374
 
1375
+ /** What would be sent right now — useful for debugging integrations. */
1165
1376
  snapshot() {
1166
1377
  return {
1378
+ user: this.config.user || null,
1379
+ context: { ...this.context },
1167
1380
  console: this.consoleBuffer.all(),
1168
1381
  network: this.networkBuffer.all(),
1169
1382
  environment: collectEnvironmentSync(),
@@ -1196,5 +1409,7 @@ const BugCatch = new BugCatchClient();
1196
1409
  // build, a named export alongside the default turns `window.BugCatch` into a
1197
1410
  // namespace object, so `BugCatch.init(...)` from a <script> tag would break.
1198
1411
  BugCatch.Client = BugCatchClient;
1412
+ // For support tickets and `console.log(BugCatch.version)`.
1413
+ BugCatch.version = SDK_IDENTITY.version;
1199
1414
 
1200
1415
  export default BugCatch;