@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/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
- 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);
@@ -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('[BugCatch] an update listener threw:', err);
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
- return fetchReport({ apiBase: this.config.apiBase, bugId });
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: await this.#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
- return Object.assign(handle, { bugId, callId: started.call_id, live: true, filed });
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
- this.config.user = user;
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 (!title || !title.trim()) throw new Error('[BugCatch] a title is required.');
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, priority, metadata, media, references,
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
- if (typeof this.config.beforeSend === 'function') {
916
- const result = this.config.beforeSend(payload);
917
- if (result === false) return { cancelled: true };
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
- 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),
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
- throw new Error(`[BugCatch] report failed (HTTP ${res.status})${detail ? ': ' + detail : ''}`);
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: { ...environment, user: this.config.user || undefined, sdk: SDK_IDENTITY },
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;