@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/LICENSE +21 -0
- package/README.md +717 -0
- package/dist/bugcatch.es.js +4393 -0
- package/dist/bugcatch.es.js.map +1 -0
- package/dist/bugcatch.umd.js +952 -0
- package/dist/bugcatch.umd.js.map +1 -0
- package/package.json +57 -0
- package/src/audio.js +191 -0
- package/src/capture.js +300 -0
- package/src/collectors.js +213 -0
- package/src/incoming-call.js +324 -0
- package/src/index.d.ts +684 -0
- package/src/index.js +1217 -0
- package/src/live-call.js +870 -0
- package/src/redact.js +177 -0
- package/src/session.js +189 -0
- package/src/updates.js +1062 -0
- package/src/voice.js +372 -0
- package/src/widget.js +2481 -0
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
|
+
```
|