@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.
package/README.md ADDED
@@ -0,0 +1,717 @@
1
+ # @sphoro/bugcatch-web-sdk
2
+
3
+ A ~4 kB browser SDK that reports bugs from your site with the context you'd otherwise
4
+ have to ask for: console output, network activity, unhandled errors and environment.
5
+
6
+ No extension required. No dependencies.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @sphoro/bugcatch-web-sdk
12
+ ```
13
+
14
+ ```js
15
+ import BugCatch from '@sphoro/bugcatch-web-sdk';
16
+
17
+ BugCatch.init({
18
+ apiBase: 'https://api.yourdomain.com',
19
+ apiKey: 'bc_live_…', // Workspace Settings → API Keys
20
+ projectId: '…', // Project Settings → General
21
+ });
22
+ ```
23
+
24
+ Or with a script tag:
25
+
26
+ ```html
27
+ <!-- Pin the version. Without one the CDN serves whatever is latest, so a
28
+ future release changes behaviour on a site nobody redeployed. -->
29
+ <script src="https://unpkg.com/@sphoro/bugcatch-web-sdk@0.13.1/dist/bugcatch.umd.js"></script>
30
+ <script>
31
+ BugCatch.init({ apiBase: '…', apiKey: '…', projectId: '…' });
32
+ </script>
33
+ ```
34
+
35
+ That's it — a floating **Report a bug** button appears, and whatever the page was
36
+ doing gets attached to the report.
37
+
38
+ ## Options
39
+
40
+ | Option | Default | What it does |
41
+ |---|---|---|
42
+ | `apiKey` | *required* | Workspace API key. |
43
+ | `projectId` | *required* | Project the bugs land in. |
44
+ | `apiBase` | `http://localhost:3000` | Where the API lives. |
45
+ | `widget` | `true` | Floating launcher. Set `false` to drive it from your own UI. |
46
+ | `widgetPosition` | `'bottom-right'` | `bottom-left`, `top-right`, `top-left`. |
47
+ | `captureConsole` | `true` | Wrap `console.*`. |
48
+ | `captureNetwork` | `true` | Wrap `fetch` and `XMLHttpRequest`. |
49
+ | `captureErrors` | `true` | Listen for `error` and `unhandledrejection`. |
50
+ | `consoleLimit` | `100` | Console entries retained. |
51
+ | `networkLimit` | `50` | Network entries retained. |
52
+ | `theme` | `'auto'` | Starting theme: `auto` follows the visitor's system setting; `light` / `dark` pin it. A light/dark/auto switch sits above the launcher, and once the visitor uses it their choice is remembered in `localStorage` and wins over this value on later visits. |
53
+ | `accent` | `'#4F46E5'` | Any CSS colour. Used for the launcher, focus rings and the primary button. |
54
+ | `attachments` | `true` | Offer screenshot, screen recording and file attach. |
55
+ | `knownIssues` | `true` | Show what this project already knows is broken above the form, and offer "this is happening to me too" instead of a second report. Draws nothing until the project switches the feature on and somebody lists an individual report — see [What is already known](#what-is-already-known). |
56
+ | `maxAttachments` | `5` | Per report. |
57
+ | `maxAttachmentBytes` | `25 MB` | Per file. |
58
+ | `maxRecordingMs` | `120000` | Hard cap on a single recording. |
59
+ | `beforeSend` | — | Mutate the payload, or return `false` to cancel. |
60
+ | `updates` | `true` | Tell the reporter, live, when a bug they filed moves. Connects only once this browser has filed something. |
61
+ | `updateToast` | `true` | Show the built-in notification. It stays until the reporter marks it read — a reload included. Set `false` to render your own. |
62
+ | `onUpdate` | — | `{ id, bugId, title, kind, summary, at }` on every update, whether or not the built-in notification is shown. |
63
+ | `voice` | `true` | Offer a voice call when a report is picked up, where the workspace has one configured. Nothing is dialled and no microphone is asked for until the reporter presses the button — see [Talking a report through](#talking-a-report-through). |
64
+ | `onCallState` | — | `('connecting' \| 'listening' \| 'speaking' \| 'ended' \| 'failed', { bugId })` as a call moves. |
65
+ | `onCallTranscript` | — | `{ bugId, role, text, final }` for each line, as it is recognised. |
66
+
67
+ ## Screenshots, recordings and files
68
+
69
+ The widget can attach a screenshot, a screen recording, or a file the reporter
70
+ picks. Capture goes through `getDisplayMedia`, so **the browser always shows its
71
+ own picker and permission prompt** — nothing is captured without the reporter
72
+ choosing what to share. The widget hides itself first, so the capture shows the
73
+ page rather than this dialog, and a recording collapses the panel to a small
74
+ timer bar with **Stop** and **Discard**.
75
+
76
+ Each attachment uploads as soon as it is added, straight to storage on a
77
+ presigned URL — the bytes never pass through the API. That means **Send** is
78
+ instant and a failed upload is visible next to the thing that failed rather than
79
+ at the end.
80
+
81
+ Accepted types are `image/png`, `image/jpeg`, `image/webp`, `video/webm` and
82
+ `video/mp4`. Anything else is refused in the widget, and again by the API.
83
+
84
+ Capture needs a secure context (HTTPS or `localhost`) and a browser with
85
+ `getDisplayMedia`. Where it is missing the two capture buttons are simply not
86
+ rendered — file attach still works. Set `attachments: false` to turn all of it
87
+ off.
88
+
89
+ > **Deploying with `STORAGE_DRIVER=s3`?** The bucket needs its own CORS policy,
90
+ > because the upload goes browser → bucket and never touches the API — so the
91
+ > API's CORS config never applies to it. Without one the `PUT` is blocked with
92
+ > no status at all, which surfaces as a bare network error while the API logs
93
+ > look perfectly healthy.
94
+ >
95
+ > ```bash
96
+ > aws s3api put-bucket-cors --bucket <bucket> --region <region> \
97
+ > --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"],
98
+ > "AllowedMethods":["PUT","GET"],"AllowedHeaders":["Content-Type"],
99
+ > "MaxAgeSeconds":86400}]}'
100
+ > ```
101
+
102
+ ## API
103
+
104
+ ```js
105
+ BugCatch.identify({ id, email, name }); // attach the signed-in user
106
+ await BugCatch.report({ title, description, priority, metadata, attachments });
107
+ await BugCatch.uploadAttachment(blob, mime, onProgress);
108
+ BugCatch.snapshot(); // what would be sent right now
109
+ BugCatch.clear(); // empty the buffers
110
+ BugCatch.destroy(); // restore every patched global
111
+
112
+ const off = BugCatch.on('update', fn); // live updates on this browser's reports
113
+ await BugCatch.myReports(); // everything filed here
114
+ await BugCatch.myReport(bugId); // one report, with its full timeline
115
+ await BugCatch.replyToReport(bugId, { body, attachments }); // write back
116
+ BugCatch.hasReports(); // has this browser filed anything?
117
+ BugCatch.forgetReports(); // drop the subscription (call on sign-out)
118
+
119
+ await BugCatch.loginState(); // what this project asks, and who is signed in
120
+ BugCatch.signedInAs(); // { email, name } | null, from storage
121
+ await BugCatch.signIn(email); // step one: mail a code to any address
122
+ await BugCatch.confirmSignIn(email, code);// step two: spend it
123
+ await BugCatch.signOut(); // end the session, here and on the server
124
+
125
+ await BugCatch.knownIssues(); // what this project says is already broken
126
+ await BugCatch.matchKnownIssues(q, { signal }); // the ones matching what they are typing
127
+ await BugCatch.meToo(bugId, { email }); // join a report instead of filing a second
128
+
129
+ await BugCatch.canCallNow(); // may this visitor call instead of typing?
130
+ await BugCatch.startCall(); // file a report and call about it
131
+ await BugCatch.canCall(bugId); // may this report be talked through?
132
+ await BugCatch.startCall(bugId); // talk an existing report through
133
+ await BugCatch.canCallTeam(bugId); // may they reach a person about it?
134
+ await BugCatch.startLiveCall(bugId); // ring the team about an existing report
135
+ BugCatch.on('incoming-call', fn); // the team ringing *them* — the SDK draws the card
136
+ BugCatch.endCall(); // hang up, either kind
137
+ ```
138
+
139
+ ### Telling BugCatch who is reporting
140
+
141
+ ```js
142
+ BugCatch.identify({ id: 'u_42', email: 'alex@acme.com', name: 'Alex' });
143
+
144
+ // On sign-out
145
+ BugCatch.identify(null);
146
+ ```
147
+
148
+ `email` is the field with behaviour attached. It does three things:
149
+
150
+ - **Threads their reports together.** Every report from that address belongs to
151
+ one person in the dashboard, with a count and a last-seen — so "this user
152
+ keeps hitting things" is visible rather than something you have to notice.
153
+ - **Closes the loop.** When someone marks the bug resolved, that person gets
154
+ the project's resolution email. Nobody has to remember to tell them.
155
+ - **Is trusted, because you authenticated them.** An address typed into a
156
+ public recording-link form is unusable for outbound mail until the reporter
157
+ answers a confirmation code. One that arrives through `identify()` is not,
158
+ because your app already knows who they are. Only send addresses you have
159
+ actually signed in.
160
+
161
+ Calling `identify()` is optional; without it reports are filed anonymously and
162
+ nobody gets a resolution email.
163
+
164
+ ### Letting them sign in themselves
165
+
166
+ `identify()` is your application vouching for its own user. Some projects want
167
+ the address proved instead — the reporter types one, we email a code, and they
168
+ type it back. That is a **project setting**, not an option here: open **Project
169
+ Settings → General → Reporter sign-in** in BugCatch and pick one of
170
+
171
+ | Setting | What the widget does |
172
+ |---|---|
173
+ | **Off** *(default)* | No sign-in. Nothing changes from the behaviour above. |
174
+ | **Optional** | Offers a sign-in above the form. Anyone who would rather not can still report anonymously. |
175
+ | **Required** | No form until they have signed in. Every report then carries an address its owner proved they can read. |
176
+
177
+ It is a project's own setting because the answer differs per product: a widget
178
+ on a public marketing page would lose most of its reports behind a mandatory
179
+ sign-in, and one behind a paywall gains little from an anonymous one.
180
+
181
+ **Anyone can sign in with any address.** There is no list of people allowed to
182
+ report a bug in your product — the code proves the address, and that is all a
183
+ reply-to has ever needed to be worth. Signing in also gives them their own
184
+ report history and live updates without your app doing anything.
185
+
186
+ **One live session per address per project.** Signing in on a phone signs the
187
+ same address out of that project on the laptop, immediately. The widget notices
188
+ on its next open and says so rather than failing at the moment they press Send.
189
+ Sessions are held per project, so a page mounting two projects' widgets has two
190
+ independent sign-ins, and each project's is separate from the browser extension
191
+ and from recording links.
192
+
193
+ The built-in widget draws all of this itself. With `widget: false` you drive it:
194
+
195
+ ```js
196
+ const { mode, signedIn } = await BugCatch.loginState();
197
+ if (mode !== 'off' && !signedIn) {
198
+ await BugCatch.signIn('alex@acme.com'); // a code goes out
199
+ await BugCatch.confirmSignIn('alex@acme.com', code); // whatever they typed back
200
+ }
201
+ ```
202
+
203
+ Where both are present the session wins: on a project that requires sign-in the
204
+ address on the report is the one that answered the code, never the one the page
205
+ passed to `identify()`. `identify()` still supplies the name and pre-fills the
206
+ address, so signing in is one field and one code rather than two.
207
+
208
+ ### Telling the reporter what is happening
209
+
210
+ The person who found the bug is the one a tracker usually forgets. They are
211
+ already in your app, so that is where they hear about it — no email to open, no
212
+ page to remember.
213
+
214
+ It is on by default and needs no setup beyond `identify()`. Once someone has
215
+ filed a report, the SDK watches it and shows a small card when something
216
+ happens:
217
+
218
+ ```js
219
+ BugCatch.init({ apiKey, projectId });
220
+ BugCatch.identify({ email: 'alex@acme.com', name: 'Alex' });
221
+ // Alex reports a bug. Later, someone marks it resolved —
222
+ // Alex sees "Your report has been fixed" without leaving your app.
223
+ ```
224
+
225
+ Four kinds of update arrive, each with a `summary` written for the reporter:
226
+
227
+ | `kind` | When | Also carries |
228
+ |---|---|---|
229
+ | `status` | open → in progress → fixed → closed | `status` |
230
+ | `severity` | the team re-prioritised it | `priority` |
231
+ | `assignment` | someone picked it up, or put it back | `assigned` |
232
+ | `reply` | the team sent them a message | `reply.body`, `reply.author` |
233
+
234
+ Severity and assignment matter more than they look: someone who reported a
235
+ crash and hears nothing cannot tell *triaged and queued* from *ignored*.
236
+
237
+ #### It stays until it is read
238
+
239
+ Nothing here is on a timer. A notification used to be shown for eight seconds
240
+ and then dropped, which meant the reporter found out only if they happened to be
241
+ looking at that tab at that moment — and the update most worth hearing, *your
242
+ bug is fixed*, is the one most likely to arrive while somebody is elsewhere.
243
+
244
+ So each update is held until it is marked read, and read is something the
245
+ reporter does rather than something a clock decides:
246
+
247
+ - **The card carries a Mark as read button**, and nothing else dismisses it — no
248
+ timeout, and no stray click on the page.
249
+ - **Unread cards come back after a reload.** The stream replays nothing when it
250
+ reconnects, so they are kept in `localStorage` until they are read.
251
+ - **Opening the report reads its updates.** The widget marks a report's cards
252
+ read when its thread opens: a card saying a bug was fixed, over the screen
253
+ saying the same thing, is the notification arguing with itself.
254
+ - **Three at a time**, newest nearest the launcher, with anything older counted
255
+ on a line above them and a **Mark all as read** beside it.
256
+
257
+ Driving your own list is four calls:
258
+
259
+ ```js
260
+ BugCatch.unreadUpdates(); // [{ id, bugId, summary, kind, at }, …] oldest first
261
+ BugCatch.markUpdateRead(id); // one — what your own "dismiss" should call
262
+ BugCatch.markReportRead(bugId); // everything about one report, when they open it
263
+ BugCatch.markUpdatesRead(); // clear all
264
+
265
+ // The number for your own bell: fires on every arrival and every mark-read.
266
+ BugCatch.on('unread', ({ count }) => setBadge(count));
267
+ ```
268
+
269
+ Read state is this browser's, kept beside the watch token and dropped with it —
270
+ there is no "read on my laptop, read on my phone", because the SDK deliberately
271
+ knows nothing about the reporter beyond having filed from here.
272
+
273
+ Render them yourself instead:
274
+
275
+ ```js
276
+ BugCatch.init({
277
+ apiKey,
278
+ projectId,
279
+ updateToast: false,
280
+ onUpdate: (u) => myToast(u.summary, () => BugCatch.markUpdateRead(u.id)),
281
+ }); // summary is already phrased for the reporter
282
+
283
+ // Or subscribe from anywhere — several listeners can coexist.
284
+ const off = BugCatch.on('update', (u) => {
285
+ if (u.kind === 'reply') openInbox(u.bugId, u.reply.body);
286
+ });
287
+ ```
288
+
289
+ With `updateToast: false` the SDK still holds what is unread — your UI is the
290
+ only thing that knows when its reader has seen one, so it is the one that has to
291
+ say.
292
+
293
+ Prefer `summary` over building the sentence yourself: the API writes it, so the
294
+ card, the widget timeline and the reporter portal all say the same thing.
295
+
296
+ Turn it off entirely with `updates: false` — no connection is opened at all.
297
+
298
+ Two things worth knowing:
299
+
300
+ - **Nothing connects until this browser has filed something.** A visitor who has
301
+ never reported has nothing to be told about, so there is no request on load.
302
+ - **Call `BugCatch.forgetReports()` on sign-out.** The subscription lives in
303
+ `localStorage`; without this, the next person at a shared machine inherits the
304
+ previous one's notifications. It takes the unread ones with it.
305
+
306
+ - **Internal comments never arrive.** Only a comment a team member explicitly
307
+ marked as visible to the reporter becomes a `reply`. The rest are the team
308
+ talking to each other and are not sent, summarised or redacted — they are
309
+ simply not the reporter's.
310
+ - **The assignee is never named.** `assigned: true` says the report has an
311
+ owner; who that is stays inside the workspace.
312
+
313
+ Authentication is a token returned when the report was filed, not your API key —
314
+ the key is publishable and sits in your page source, so a key-authenticated feed
315
+ keyed on an email address would let anyone read anyone's reports.
316
+
317
+ ### Showing someone their own reports
318
+
319
+ Once this browser has filed something, the widget grows a **Your reports**
320
+ button listing everything they've reported and where each one got to — and
321
+ tapping one opens its timeline. No code, no sign-in.
322
+
323
+ To build your own, the data is two calls:
324
+
325
+ ```js
326
+ const { reports } = await BugCatch.myReports();
327
+ // [{ id, reference: '3F2A9B01', title: 'Checkout does nothing',
328
+ // status: 'RESOLVED', priority: 'HIGH', createdAt, project, attachments: 1 }]
329
+
330
+ if (BugCatch.hasReports()) showMyReportsLink(); // hide it until it has content
331
+ ```
332
+
333
+ ```js
334
+ const report = await BugCatch.myReport(id);
335
+ report.timeline
336
+ // [{ kind: 'filed', summary: 'You reported this.', at },
337
+ // { kind: 'severity', summary: 'Severity raised to high.', at },
338
+ // { kind: 'assignment', summary: 'Someone on the team has picked up your report.', at },
339
+ // { kind: 'reply', summary: 'The team replied to your report.',
340
+ // reply: { body: 'Confirmed — a fix ships this afternoon.', author: 'Priya' }, at },
341
+ // { kind: 'status', summary: 'Your report has been fixed.', at }]
342
+ ```
343
+
344
+ The timeline is stored server-side, not accumulated in the browser, so it
345
+ survives a reload and a new device — someone who reports a bug on Monday and
346
+ checks back on Friday sees the triage and the reply, not just "fixed".
347
+
348
+ `myReports()` resolves empty when nothing has been filed from this browser, or
349
+ when `identify()` supplied no email. `myReport()` resolves `null` when this
350
+ browser cannot prove it filed that particular report — a token opens only its
351
+ own reports, never another reporter's.
352
+
353
+ ### Letting them answer
354
+
355
+ The timeline above used to be the end of it: the team could say a fix had
356
+ shipped, and the reporter had nowhere to say "still broken on Safari". So
357
+ "which browser were you on?" meant leaving your app for email, and the answer
358
+ came back attached to nothing.
359
+
360
+ Opening a report in the widget now opens a **conversation**. Status changes are
361
+ the quiet lines between messages; the team's replies and the reporter's sit
362
+ either side of them; and there is a box at the bottom that takes text, files,
363
+ images and a voice note from the microphone. A reply from the team lands in the
364
+ open thread live, without a reload.
365
+
366
+ Their message reaches the dashboard attributed to them, so somebody sees it.
367
+
368
+ To drive it from your own UI:
369
+
370
+ ```js
371
+ const file = picker.files[0];
372
+ const uploaded = await BugCatch.uploadAttachment(file, file.type);
373
+
374
+ await BugCatch.replyToReport(id, {
375
+ body: 'Still empty on page 3',
376
+ attachments: [{ ...uploaded, name: file.name, size: file.size }],
377
+ });
378
+ ```
379
+
380
+ Body or attachments — at least one. Messages take more types than a report
381
+ does: alongside images and video, a message carries `audio/*` voice notes and
382
+ documents (PDF, txt, csv, json, zip, doc(x), xls(x)). A report's own
383
+ attachments stay images and video, because they are typed `SCREENSHOT` or
384
+ `VIDEO` and a document filed as a screenshot renders as a broken image.
385
+
386
+ `report.canReply` says whether this reader can write back; the widget hides its
387
+ composer when it is false rather than offering a box that would fail on send.
388
+
389
+ ### Reporting from your own UI
390
+
391
+ ```js
392
+ BugCatch.init({ apiKey, projectId, widget: false });
393
+
394
+ document.querySelector('#help').onclick = () =>
395
+ BugCatch.report({ title: 'Something broke', priority: 'HIGH' });
396
+ ```
397
+
398
+ ### Attaching from your own UI
399
+
400
+ `uploadAttachment()` resolves to the storage key a report references, so you can
401
+ build your own capture flow and still get media onto the bug:
402
+
403
+ ```js
404
+ const { storageKey, mime } = await BugCatch.uploadAttachment(
405
+ file, file.type, (fraction) => showProgress(fraction),
406
+ );
407
+
408
+ await BugCatch.report({
409
+ title: 'Layout breaks on the pricing page',
410
+ attachments: [{ type: 'SCREENSHOT', storageKey, mime }], // or 'VIDEO'
411
+ });
412
+ ```
413
+
414
+ Entries without a usable key or an accepted mime type are dropped rather than
415
+ failing the whole report.
416
+
417
+ ### Scrubbing before send
418
+
419
+ `beforeSend` runs in the browser, so you can drop things that should never leave it:
420
+
421
+ ```js
422
+ BugCatch.init({
423
+ apiKey, projectId,
424
+ beforeSend: (payload) => {
425
+ payload.logs.network.entries = payload.logs.network.entries
426
+ .filter((e) => !e.url.includes('/internal/'));
427
+ return payload;
428
+ },
429
+ });
430
+ ```
431
+
432
+ Server-side redaction rules (Workspace Settings → Security) run *as well*, so secrets
433
+ are caught even if a client forgets.
434
+
435
+ ### Credential scrubbing (always on)
436
+
437
+ Bodies are never read, which leaves the URL as the place credentials actually turn up:
438
+ `?api_key=`, an S3 presigned `X-Amz-Signature`, an OAuth `#access_token=` fragment, a JWT
439
+ in a path segment, or `https://user:password@host`. All of those are replaced with
440
+ `[REDACTED]` **before the entry enters the ring buffer** — nothing you have to enable, and
441
+ the secret never leaves the page.
442
+
443
+ ```
444
+ https://api.example.com/v1/me?api_key=abcd1234efgh5678&page=2
445
+ ↓
446
+ https://api.example.com/v1/me?api_key=[REDACTED]&page=2
447
+ ```
448
+
449
+ Structure survives on purpose. Scheme, host, path and every parameter *name* stay
450
+ readable, because a Network tab that blanks whole URLs is one nobody can triage with —
451
+ and the usual response to that is to switch capture off, which helps no one.
452
+
453
+ Recognised without configuration: parameters named for a credential (including
454
+ app-specific ones like `X-Custom-Auth-Token`), plus JWTs, AWS/Google/GitHub/GitLab/
455
+ Stripe/Slack/Shopify/OpenAI/Anthropic key formats wherever they appear. Ordinary
456
+ parameters — `?page=2`, `?sort=created_at`, `?code=US` — are left alone.
457
+
458
+ The API applies the same scrubbing on intake and again when a report is read back, so
459
+ reports captured by older SDK versions are covered too.
460
+
461
+ ## What is already known
462
+
463
+ The widget opens with the reports this project has chosen to say are already
464
+ broken, above the form rather than after it, and offers **Me too** beside each
465
+ one. Somebody who came to report a thing that is already known can say so in one
466
+ tap and leave, and the team gets one report carrying everybody's environment
467
+ instead of fourteen reports of the same thing.
468
+
469
+ Nothing here is in anybody's way. The list is dismissed by ignoring it, and the
470
+ form underneath is the form that was always there.
471
+
472
+ Two switches, both off until somebody sets them, and both required:
473
+
474
+ - **Project Settings → General → "Show what is already known before the report
475
+ form"** decides whether this project has a list at all.
476
+ - **A report's own "Known issue" control**, on its page in the dashboard, decides
477
+ whether *that* report is on it.
478
+
479
+ Two rather than one because they are two decisions, made by different people at
480
+ different times: a team deciding it is willing to publish anything, and a member
481
+ deciding this particular title is fit to be read by strangers. A report title is
482
+ written for a queue, not for publication, so nothing is ever listed by default.
483
+
484
+ This is deliberately **not** the same control as session visibility. A public
485
+ session is readable by whoever holds its link, which somebody hands to one
486
+ person on purpose; listing puts the title in front of every visitor of your site.
487
+
488
+ Joining is a promise, and the SDK keeps it: everyone who pressed **Me too** is
489
+ told when the report is resolved — over the same live stream and the same email
490
+ as the person who filed it. That is why it asks for an address when neither a
491
+ sign-in nor `identify()` has supplied one, rather than recording somebody nobody
492
+ can ever write to.
493
+
494
+ ```js
495
+ const issues = await BugCatch.knownIssues();
496
+ // [{ id, title, status, affectedCount, since, joined }]
497
+
498
+ await BugCatch.meToo(issues[0].id, { email: 'them@example.com' });
499
+ // { joined: true, affectedCount: 4 }
500
+ ```
501
+
502
+ `knownIssues()` resolves to an empty list on every project that has not turned
503
+ this on, which is the default — draw nothing rather than an empty heading. What
504
+ comes back is a title, a state and a count, and never a severity, an assignee or
505
+ a description: the question it answers is "is this the thing I came here about".
506
+
507
+ ## Talking a report through
508
+
509
+ Where the workspace has sphoro.voice configured, a reporter can talk instead of
510
+ type. There are two ways in.
511
+
512
+ **Instead of the form.** A **Talk to us** button appears next to *Send report*.
513
+ Pressing it files the report immediately — carrying the console, network and
514
+ environment the SDK is already holding — and puts the reporter straight on a
515
+ call about it. The report exists before the call starts, so a call that drops
516
+ still leaves the context behind, and it opens unassigned: whoever takes it in
517
+ the dashboard owns it. Nobody typed a title, so it takes one from the first
518
+ thing the reporter actually says.
519
+
520
+ **After somebody picks it up.** When a report is assigned, its reporter is
521
+ offered a call about it. The agent asks the three questions the form did not,
522
+ and what it learns lands in the report's thread.
523
+
524
+ Either way the other end is an automated agent, not a colleague — the widget
525
+ says so before the first word — and the team reads the transcript rather than
526
+ answering a phone.
527
+
528
+ ```js
529
+ BugCatch.init({
530
+ apiBase: '…', apiKey: '…', projectId: '…',
531
+ onCallState: (state) => setStatus(state),
532
+ onCallTranscript: ({ role, text }) => appendLine(role, text),
533
+ });
534
+ ```
535
+
536
+ Both affordances are built in: the **Talk to us** button on the composer, and —
537
+ with `updateToast` on, the default — a **Talk it through** button on the update
538
+ card when a report is picked up (pressing it marks that update read: acting on a
539
+ notification is the clearest way there is of having seen it). To render your own instead:
540
+
541
+ ```js
542
+ // Instead of the form: files a report and calls about it.
543
+ if (await BugCatch.canCallNow()) {
544
+ const call = await BugCatch.startCall();
545
+ call.bugId; // what it was filed as
546
+ }
547
+
548
+ // About a report already filed.
549
+ if (await BugCatch.canCall(bugId)) {
550
+ await BugCatch.startCall(bugId);
551
+ }
552
+ ```
553
+
554
+ `startCall()` with no id rejects with `.filed` set when the report was written
555
+ but no call could be placed — the report still exists, and telling the reporter
556
+ otherwise would have them file it twice.
557
+
558
+ Three things are worth being explicit about, because they are what people ask:
559
+
560
+ - **Nobody from the team is on the line.** The agent is software, it says so,
561
+ and the team reads the transcript afterwards. Nothing rings anywhere. (Unless
562
+ it is a *live* call — see below, which is the other feature entirely.)
563
+ - **The microphone is asked for on the press, never before.** An offer that
564
+ raised a permission prompt on arrival would be a page listening to somebody
565
+ who walked away from it.
566
+ - **One call at a time**, and `destroy()` ends it. Two microphones on one page
567
+ transcribe each other.
568
+
569
+ ### Calling a person instead
570
+
571
+ The other kind of call. `startCall` reaches the triage agent; `startLiveCall`
572
+ rings the team, and whoever is free picks it up. It goes both ways — the team
573
+ can ring back, which is the section after this one.
574
+
575
+ ```js
576
+ if (await BugCatch.canCallTeam(bugId)) {
577
+ const call = await BugCatch.startLiveCall(bugId); // resolves once it is *ringing*
578
+ call.setMuted(true);
579
+ call.hangUp();
580
+ }
581
+
582
+ BugCatch.on('call', ({ live, state }) => {
583
+ if (!live) return;
584
+ // 'ringing' → 'connecting' → 'connected' → 'ended'
585
+ // or 'ringing' → 'missed', when nobody picked up in about twenty seconds.
586
+ });
587
+ ```
588
+
589
+ The widget renders this itself: a phone button on an open report's composer,
590
+ shown only where the workspace has switched live calling on — which most have
591
+ not.
592
+
593
+ What is different about it, and worth telling a customer:
594
+
595
+ - **It is a real person, and it is not recorded.** The audio goes directly
596
+ between the two browsers over WebRTC and never reaches BugCatch or
597
+ sphoro.voice, so there is no transcript. The report's thread gets a note
598
+ saying the call happened, who took it and how long it lasted.
599
+ - **It can go unanswered.** It rings everybody who can see the report for about
600
+ twenty seconds; if nobody is free, the state is `missed` and the reporter is
601
+ pointed back at the chat.
602
+ - **A report id is required.** Unlike `startCall()`, there is no file-and-call
603
+ path: ringing a room about a report nobody can read would ask somebody to
604
+ answer a call they know nothing about.
605
+ - **One microphone.** A live call and an agent call share the slot, so
606
+ `endCall()` ends whichever is running and neither can start over the other.
607
+
608
+ ### Being called
609
+
610
+ The same live call, placed from the other end. Somebody on the team reads a
611
+ report an hour after it was filed, has one question whose answer is thirty
612
+ seconds of talking and four days of email, and rings the person who filed it.
613
+
614
+ Nothing is needed to switch it on beyond what live calling already needs — but
615
+ it does need the update stream, because that is what a call arrives on:
616
+
617
+ ```js
618
+ BugCatch.init({
619
+ apiBase: '…', apiKey: '…', projectId: '…',
620
+ updates: true, // the default; the call arrives on this
621
+ onIncomingCall: ({ from, reference, recorded }) => pauseTheVideo(),
622
+ });
623
+
624
+ BugCatch.on('incoming-call', ({ state }) => {
625
+ // 'ringing' → 'answered' → (then the ordinary 'call' events)
626
+ // or 'ringing' → 'declined' | 'missed' | 'failed'
627
+ });
628
+ ```
629
+
630
+ The SDK draws the card itself — who is calling, which report, whether the call
631
+ would be recorded, and Answer / Not now — and rings softly while it is up.
632
+ `onIncomingCall` fires *after* that, for a host application that wants to duck
633
+ its own audio or pause a video; it is not a hook that can suppress a call the
634
+ person can already see ringing.
635
+
636
+ What is worth telling a customer about this one:
637
+
638
+ - **It is an offer, never a connection.** No microphone is opened and no audio
639
+ moves until Answer is pressed. A page somebody walked away from ten minutes
640
+ ago does not start listening to the room because the team rang it.
641
+ - **It says who is calling.** An unnamed voice asking a customer about their
642
+ account is indistinguishable from the fraud their bank warns them about, so
643
+ the card carries the caller's name and the report before anything else.
644
+ - **It says whether it is recorded, before Answer.** On a peer-to-peer call the
645
+ copy that gets kept is uplinked by the customer's own browser, which makes
646
+ the card the only honest moment to mention it.
647
+ - **Declining is recorded as declining.** Not the same as not being there: the
648
+ report's thread says which, because one invites ringing again in ten minutes
649
+ and the other does not.
650
+ - **`updates: false` switches it off**, as does `voice: false`. With no stream
651
+ there is nothing for a call to arrive on, and no card is ever drawn.
652
+ - **It only reaches a signed-in reporter, and that is enforced.** The team's
653
+ button is refused unless the person who filed the report has *proved their
654
+ address* — a widget sign-in (`sdkLogin` on the project, giving a live
655
+ session) or a sign-in on their own report page. An address that only arrived
656
+ through `identify()` is the host application vouching for its own user, which
657
+ is not a claim anybody outside that application can rely on, and calls are
658
+ not placed on it.
659
+
660
+ A project with `sdkLogin: OFF` therefore has no callable reports through the
661
+ widget, because it never asks anyone who they are. Turn sign-in on to make
662
+ calling available.
663
+
664
+ - **And they have to be at the page.** Being signed in is about *who*; a call
665
+ still joins two browsers, so theirs has to be open. The two refusals read
666
+ differently on the bug page because only one of them clears by itself.
667
+
668
+ - **The seat is theirs alone.** The chair kept open on an outbound call can
669
+ only be claimed with one of the reporter's own credentials, so a call placed
670
+ to a customer cannot be picked up by anybody else — including a member of the
671
+ team looking at the same report.
672
+
673
+ It also rings in the reporter portal — the page where somebody signs in by email
674
+ to check back on what they reported. Whichever tab is in front of them gets the
675
+ call, and if both are open, the first to answer gets it.
676
+
677
+ `canCall` and `canCallNow` answer `false` — and no button appears — on any
678
+ deployment without sphoro.voice and any workspace that has not switched calling
679
+ on, which is most of them. `canCallNow` additionally needs `identify()` to have
680
+ supplied an email: a call with nobody to attribute has no way to tell the
681
+ reporter what happened next. Set `voice: false` to opt out entirely; the code is
682
+ still in the bundle (about 1.5 kB gzipped, most of it the G.711 codec a browser
683
+ does not ship), but nothing runs.
684
+
685
+ ## What is captured
686
+
687
+ - **Console** — last N entries with level and timestamp, plus uncaught errors and
688
+ unhandled promise rejections.
689
+ - **Network** — last N `fetch`/XHR calls: URL, method, status, duration, and flags for
690
+ failed or slow. **Request and response bodies are never read**, and **credentials are
691
+ stripped out of the URL before it is buffered** — see below.
692
+ - **Environment** — user agent, platform, language(s), viewport, screen (size, available
693
+ size, colour depth, orientation), DPR, timezone and offset, connection type, CPU cores,
694
+ device memory, touch points, battery level and charging state, colour-scheme and
695
+ reduced-motion preferences, Do Not Track, online state, referrer, page title.
696
+ - **Page URL** at the moment of reporting.
697
+
698
+ Both buffers are ring buffers, so a long-lived page won't grow memory without bound.
699
+
700
+ ## Notes
701
+
702
+ - The widget renders in a shadow root, so your CSS and its CSS can't collide.
703
+ - Capture never throws into your page: every hook is wrapped, and originals are always
704
+ called.
705
+ - Attachment uploads use pristine `fetch`/`XHR` references taken before the network hooks
706
+ are installed, so a report never ends up describing its own upload.
707
+ - The panel traps focus, closes on `Escape`, restores focus to the launcher, and honours
708
+ `prefers-reduced-motion` — every animation is an entrance or a state hint, so switching
709
+ them off costs nothing.
710
+ - `destroy()` restores `console.*`, `fetch` and `XMLHttpRequest` to their originals.
711
+
712
+ ## Build
713
+
714
+ ```bash
715
+ npm install
716
+ npm run build # dist/bugcatch.es.js + dist/bugcatch.umd.js
717
+ ```