@sv.sphoro/bugcatch-web-sdk
v0.13.1
Published
Drop-in browser SDK — report bugs with console, network and environment context, without the extension.
Maintainers
Readme
@sv.sphoro/bugcatch-web-sdk
A ~4 kB browser SDK that reports bugs from your site with the context you'd otherwise have to ask for: console output, network activity, unhandled errors and environment.
No extension required. No dependencies.
Install
npm install @sv.sphoro/bugcatch-web-sdkimport BugCatch from '@sv.sphoro/bugcatch-web-sdk';
BugCatch.init({
apiBase: 'https://api.yourdomain.com',
apiKey: 'bc_live_…', // Workspace Settings → API Keys
projectId: '…', // Project Settings → General
});Or with a script tag:
<!-- Pin the version. Without one the CDN serves whatever is latest, so a
future release changes behaviour on a site nobody redeployed. -->
<script src="https://unpkg.com/@sv.sphoro/[email protected]/dist/bugcatch.umd.js"></script>
<script>
BugCatch.init({ apiBase: '…', apiKey: '…', projectId: '…' });
</script>That's it — a floating Report a bug button appears, and whatever the page was doing gets attached to the report.
Options
| Option | Default | What it does |
|---|---|---|
| apiKey | required | Workspace API key. |
| projectId | required | Project the bugs land in. |
| apiBase | http://localhost:3000 | Where the API lives. |
| widget | true | Floating launcher. Set false to drive it from your own UI. |
| widgetPosition | 'bottom-right' | bottom-left, top-right, top-left. |
| captureConsole | true | Wrap console.*. |
| captureNetwork | true | Wrap fetch and XMLHttpRequest. |
| captureErrors | true | Listen for error and unhandledrejection. |
| consoleLimit | 100 | Console entries retained. |
| networkLimit | 50 | Network entries retained. |
| 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. |
| accent | '#4F46E5' | Any CSS colour. Used for the launcher, focus rings and the primary button. |
| attachments | true | Offer screenshot, screen recording and file attach. |
| maxAttachments | 5 | Per report. |
| maxAttachmentBytes | 25 MB | Per file. |
| maxRecordingMs | 120000 | Hard cap on a single recording. |
| beforeSend | — | Mutate the payload, or return false to cancel. |
| updates | true | Tell the reporter, live, when a bug they filed moves. Connects only once this browser has filed something. |
| updateToast | true | Show the built-in notification. It stays until the reporter marks it read — a reload included. Set false to render your own. |
| onUpdate | — | { id, bugId, title, kind, summary, at } on every update, whether or not the built-in notification is shown. |
| 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. |
| onCallState | — | ('connecting' \| 'listening' \| 'speaking' \| 'ended' \| 'failed', { bugId }) as a call moves. |
| onCallTranscript | — | { bugId, role, text, final } for each line, as it is recognised. |
Screenshots, recordings and files
The widget can attach a screenshot, a screen recording, or a file the reporter
picks. Capture goes through getDisplayMedia, so the browser always shows its
own picker and permission prompt — nothing is captured without the reporter
choosing what to share. The widget hides itself first, so the capture shows the
page rather than this dialog, and a recording collapses the panel to a small
timer bar with Stop and Discard.
Each attachment uploads as soon as it is added, straight to storage on a presigned URL — the bytes never pass through the API. That means Send is instant and a failed upload is visible next to the thing that failed rather than at the end.
Accepted types are image/png, image/jpeg, image/webp, video/webm and
video/mp4. Anything else is refused in the widget, and again by the API.
Capture needs a secure context (HTTPS or localhost) and a browser with
getDisplayMedia. Where it is missing the two capture buttons are simply not
rendered — file attach still works. Set attachments: false to turn all of it
off.
Deploying with
STORAGE_DRIVER=s3? The bucket needs its own CORS policy, because the upload goes browser → bucket and never touches the API — so the API's CORS config never applies to it. Without one thePUTis blocked with no status at all, which surfaces as a bare network error while the API logs look perfectly healthy.aws s3api put-bucket-cors --bucket <bucket> --region <region> \ --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"], "AllowedMethods":["PUT","GET"],"AllowedHeaders":["Content-Type"], "MaxAgeSeconds":86400}]}'
API
BugCatch.identify({ id, email, name }); // attach the signed-in user
await BugCatch.report({ title, description, priority, metadata, attachments });
await BugCatch.uploadAttachment(blob, mime, onProgress);
BugCatch.snapshot(); // what would be sent right now
BugCatch.clear(); // empty the buffers
BugCatch.destroy(); // restore every patched global
const off = BugCatch.on('update', fn); // live updates on this browser's reports
await BugCatch.myReports(); // everything filed here
await BugCatch.myReport(bugId); // one report, with its full timeline
await BugCatch.replyToReport(bugId, { body, attachments }); // write back
BugCatch.hasReports(); // has this browser filed anything?
BugCatch.forgetReports(); // drop the subscription (call on sign-out)
await BugCatch.loginState(); // what this project asks, and who is signed in
BugCatch.signedInAs(); // { email, name } | null, from storage
await BugCatch.signIn(email); // step one: mail a code to any address
await BugCatch.confirmSignIn(email, code);// step two: spend it
await BugCatch.signOut(); // end the session, here and on the server
await BugCatch.canCallNow(); // may this visitor call instead of typing?
await BugCatch.startCall(); // file a report and call about it
await BugCatch.canCall(bugId); // may this report be talked through?
await BugCatch.startCall(bugId); // talk an existing report through
await BugCatch.canCallTeam(bugId); // may they reach a person about it?
await BugCatch.startLiveCall(bugId); // ring the team about an existing report
BugCatch.on('incoming-call', fn); // the team ringing *them* — the SDK draws the card
BugCatch.endCall(); // hang up, either kindTelling BugCatch who is reporting
BugCatch.identify({ id: 'u_42', email: '[email protected]', name: 'Alex' });
// On sign-out
BugCatch.identify(null);email is the field with behaviour attached. It does three things:
- Threads their reports together. Every report from that address belongs to one person in the dashboard, with a count and a last-seen — so "this user keeps hitting things" is visible rather than something you have to notice.
- Closes the loop. When someone marks the bug resolved, that person gets the project's resolution email. Nobody has to remember to tell them.
- Is trusted, because you authenticated them. An address typed into a
public recording-link form is unusable for outbound mail until the reporter
answers a confirmation code. One that arrives through
identify()is not, because your app already knows who they are. Only send addresses you have actually signed in.
Calling identify() is optional; without it reports are filed anonymously and
nobody gets a resolution email.
Letting them sign in themselves
identify() is your application vouching for its own user. Some projects want
the address proved instead — the reporter types one, we email a code, and they
type it back. That is a project setting, not an option here: open Project
Settings → General → Reporter sign-in in BugCatch and pick one of
| Setting | What the widget does | |---|---| | Off (default) | No sign-in. Nothing changes from the behaviour above. | | Optional | Offers a sign-in above the form. Anyone who would rather not can still report anonymously. | | Required | No form until they have signed in. Every report then carries an address its owner proved they can read. |
It is a project's own setting because the answer differs per product: a widget on a public marketing page would lose most of its reports behind a mandatory sign-in, and one behind a paywall gains little from an anonymous one.
Anyone can sign in with any address. There is no list of people allowed to report a bug in your product — the code proves the address, and that is all a reply-to has ever needed to be worth. Signing in also gives them their own report history and live updates without your app doing anything.
One live session per address per project. Signing in on a phone signs the same address out of that project on the laptop, immediately. The widget notices on its next open and says so rather than failing at the moment they press Send. Sessions are held per project, so a page mounting two projects' widgets has two independent sign-ins, and each project's is separate from the browser extension and from recording links.
The built-in widget draws all of this itself. With widget: false you drive it:
const { mode, signedIn } = await BugCatch.loginState();
if (mode !== 'off' && !signedIn) {
await BugCatch.signIn('[email protected]'); // a code goes out
await BugCatch.confirmSignIn('[email protected]', code); // whatever they typed back
}Where both are present the session wins: on a project that requires sign-in the
address on the report is the one that answered the code, never the one the page
passed to identify(). identify() still supplies the name and pre-fills the
address, so signing in is one field and one code rather than two.
Telling the reporter what is happening
The person who found the bug is the one a tracker usually forgets. They are already in your app, so that is where they hear about it — no email to open, no page to remember.
It is on by default and needs no setup beyond identify(). Once someone has
filed a report, the SDK watches it and shows a small card when something
happens:
BugCatch.init({ apiKey, projectId });
BugCatch.identify({ email: '[email protected]', name: 'Alex' });
// Alex reports a bug. Later, someone marks it resolved —
// Alex sees "Your report has been fixed" without leaving your app.Four kinds of update arrive, each with a summary written for the reporter:
| kind | When | Also carries |
|---|---|---|
| status | open → in progress → fixed → closed | status |
| severity | the team re-prioritised it | priority |
| assignment | someone picked it up, or put it back | assigned |
| reply | the team sent them a message | reply.body, reply.author |
Severity and assignment matter more than they look: someone who reported a crash and hears nothing cannot tell triaged and queued from ignored.
It stays until it is read
Nothing here is on a timer. A notification used to be shown for eight seconds and then dropped, which meant the reporter found out only if they happened to be looking at that tab at that moment — and the update most worth hearing, your bug is fixed, is the one most likely to arrive while somebody is elsewhere.
So each update is held until it is marked read, and read is something the reporter does rather than something a clock decides:
- The card carries a Mark as read button, and nothing else dismisses it — no timeout, and no stray click on the page.
- Unread cards come back after a reload. The stream replays nothing when it
reconnects, so they are kept in
localStorageuntil they are read. - Opening the report reads its updates. The widget marks a report's cards read when its thread opens: a card saying a bug was fixed, over the screen saying the same thing, is the notification arguing with itself.
- Three at a time, newest nearest the launcher, with anything older counted on a line above them and a Mark all as read beside it.
Driving your own list is four calls:
BugCatch.unreadUpdates(); // [{ id, bugId, summary, kind, at }, …] oldest first
BugCatch.markUpdateRead(id); // one — what your own "dismiss" should call
BugCatch.markReportRead(bugId); // everything about one report, when they open it
BugCatch.markUpdatesRead(); // clear all
// The number for your own bell: fires on every arrival and every mark-read.
BugCatch.on('unread', ({ count }) => setBadge(count));Read state is this browser's, kept beside the watch token and dropped with it — there is no "read on my laptop, read on my phone", because the SDK deliberately knows nothing about the reporter beyond having filed from here.
Render them yourself instead:
BugCatch.init({
apiKey,
projectId,
updateToast: false,
onUpdate: (u) => myToast(u.summary, () => BugCatch.markUpdateRead(u.id)),
}); // summary is already phrased for the reporter
// Or subscribe from anywhere — several listeners can coexist.
const off = BugCatch.on('update', (u) => {
if (u.kind === 'reply') openInbox(u.bugId, u.reply.body);
});With updateToast: false the SDK still holds what is unread — your UI is the
only thing that knows when its reader has seen one, so it is the one that has to
say.
Prefer summary over building the sentence yourself: the API writes it, so the
card, the widget timeline and the reporter portal all say the same thing.
Turn it off entirely with updates: false — no connection is opened at all.
Two things worth knowing:
Nothing connects until this browser has filed something. A visitor who has never reported has nothing to be told about, so there is no request on load.
Call
BugCatch.forgetReports()on sign-out. The subscription lives inlocalStorage; without this, the next person at a shared machine inherits the previous one's notifications. It takes the unread ones with it.Internal comments never arrive. Only a comment a team member explicitly marked as visible to the reporter becomes a
reply. The rest are the team talking to each other and are not sent, summarised or redacted — they are simply not the reporter's.The assignee is never named.
assigned: truesays the report has an owner; who that is stays inside the workspace.
Authentication is a token returned when the report was filed, not your API key — the key is publishable and sits in your page source, so a key-authenticated feed keyed on an email address would let anyone read anyone's reports.
Showing someone their own reports
Once this browser has filed something, the widget grows a Your reports button listing everything they've reported and where each one got to — and tapping one opens its timeline. No code, no sign-in.
To build your own, the data is two calls:
const { reports } = await BugCatch.myReports();
// [{ id, reference: '3F2A9B01', title: 'Checkout does nothing',
// status: 'RESOLVED', priority: 'HIGH', createdAt, project, attachments: 1 }]
if (BugCatch.hasReports()) showMyReportsLink(); // hide it until it has contentconst report = await BugCatch.myReport(id);
report.timeline
// [{ kind: 'filed', summary: 'You reported this.', at },
// { kind: 'severity', summary: 'Severity raised to high.', at },
// { kind: 'assignment', summary: 'Someone on the team has picked up your report.', at },
// { kind: 'reply', summary: 'The team replied to your report.',
// reply: { body: 'Confirmed — a fix ships this afternoon.', author: 'Priya' }, at },
// { kind: 'status', summary: 'Your report has been fixed.', at }]The timeline is stored server-side, not accumulated in the browser, so it survives a reload and a new device — someone who reports a bug on Monday and checks back on Friday sees the triage and the reply, not just "fixed".
myReports() resolves empty when nothing has been filed from this browser, or
when identify() supplied no email. myReport() resolves null when this
browser cannot prove it filed that particular report — a token opens only its
own reports, never another reporter's.
Letting them answer
The timeline above used to be the end of it: the team could say a fix had shipped, and the reporter had nowhere to say "still broken on Safari". So "which browser were you on?" meant leaving your app for email, and the answer came back attached to nothing.
Opening a report in the widget now opens a conversation. Status changes are the quiet lines between messages; the team's replies and the reporter's sit either side of them; and there is a box at the bottom that takes text, files, images and a voice note from the microphone. A reply from the team lands in the open thread live, without a reload.
Their message reaches the dashboard attributed to them, so somebody sees it.
To drive it from your own UI:
const file = picker.files[0];
const uploaded = await BugCatch.uploadAttachment(file, file.type);
await BugCatch.replyToReport(id, {
body: 'Still empty on page 3',
attachments: [{ ...uploaded, name: file.name, size: file.size }],
});Body or attachments — at least one. Messages take more types than a report
does: alongside images and video, a message carries audio/* voice notes and
documents (PDF, txt, csv, json, zip, doc(x), xls(x)). A report's own
attachments stay images and video, because they are typed SCREENSHOT or
VIDEO and a document filed as a screenshot renders as a broken image.
report.canReply says whether this reader can write back; the widget hides its
composer when it is false rather than offering a box that would fail on send.
Reporting from your own UI
BugCatch.init({ apiKey, projectId, widget: false });
document.querySelector('#help').onclick = () =>
BugCatch.report({ title: 'Something broke', priority: 'HIGH' });Attaching from your own UI
uploadAttachment() resolves to the storage key a report references, so you can
build your own capture flow and still get media onto the bug:
const { storageKey, mime } = await BugCatch.uploadAttachment(
file, file.type, (fraction) => showProgress(fraction),
);
await BugCatch.report({
title: 'Layout breaks on the pricing page',
attachments: [{ type: 'SCREENSHOT', storageKey, mime }], // or 'VIDEO'
});Entries without a usable key or an accepted mime type are dropped rather than failing the whole report.
Scrubbing before send
beforeSend runs in the browser, so you can drop things that should never leave it:
BugCatch.init({
apiKey, projectId,
beforeSend: (payload) => {
payload.logs.network.entries = payload.logs.network.entries
.filter((e) => !e.url.includes('/internal/'));
return payload;
},
});Server-side redaction rules (Workspace Settings → Security) run as well, so secrets are caught even if a client forgets.
Credential scrubbing (always on)
Bodies are never read, which leaves the URL as the place credentials actually turn up:
?api_key=, an S3 presigned X-Amz-Signature, an OAuth #access_token= fragment, a JWT
in a path segment, or https://user:password@host. All of those are replaced with
[REDACTED] before the entry enters the ring buffer — nothing you have to enable, and
the secret never leaves the page.
https://api.example.com/v1/me?api_key=abcd1234efgh5678&page=2
↓
https://api.example.com/v1/me?api_key=[REDACTED]&page=2Structure survives on purpose. Scheme, host, path and every parameter name stay readable, because a Network tab that blanks whole URLs is one nobody can triage with — and the usual response to that is to switch capture off, which helps no one.
Recognised without configuration: parameters named for a credential (including
app-specific ones like X-Custom-Auth-Token), plus JWTs, AWS/Google/GitHub/GitLab/
Stripe/Slack/Shopify/OpenAI/Anthropic key formats wherever they appear. Ordinary
parameters — ?page=2, ?sort=created_at, ?code=US — are left alone.
The API applies the same scrubbing on intake and again when a report is read back, so reports captured by older SDK versions are covered too.
Talking a report through
Where the workspace has sphoro.voice configured, a reporter can talk instead of type. There are two ways in.
Instead of the form. A Talk to us button appears next to Send report. Pressing it files the report immediately — carrying the console, network and environment the SDK is already holding — and puts the reporter straight on a call about it. The report exists before the call starts, so a call that drops still leaves the context behind, and it opens unassigned: whoever takes it in the dashboard owns it. Nobody typed a title, so it takes one from the first thing the reporter actually says.
After somebody picks it up. When a report is assigned, its reporter is offered a call about it. The agent asks the three questions the form did not, and what it learns lands in the report's thread.
Either way the other end is an automated agent, not a colleague — the widget says so before the first word — and the team reads the transcript rather than answering a phone.
BugCatch.init({
apiBase: '…', apiKey: '…', projectId: '…',
onCallState: (state) => setStatus(state),
onCallTranscript: ({ role, text }) => appendLine(role, text),
});Both affordances are built in: the Talk to us button on the composer, and —
with updateToast on, the default — a Talk it through button on the update
card when a report is picked up (pressing it marks that update read: acting on a
notification is the clearest way there is of having seen it). To render your own instead:
// Instead of the form: files a report and calls about it.
if (await BugCatch.canCallNow()) {
const call = await BugCatch.startCall();
call.bugId; // what it was filed as
}
// About a report already filed.
if (await BugCatch.canCall(bugId)) {
await BugCatch.startCall(bugId);
}startCall() with no id rejects with .filed set when the report was written
but no call could be placed — the report still exists, and telling the reporter
otherwise would have them file it twice.
Three things are worth being explicit about, because they are what people ask:
- Nobody from the team is on the line. The agent is software, it says so, and the team reads the transcript afterwards. Nothing rings anywhere. (Unless it is a live call — see below, which is the other feature entirely.)
- The microphone is asked for on the press, never before. An offer that raised a permission prompt on arrival would be a page listening to somebody who walked away from it.
- One call at a time, and
destroy()ends it. Two microphones on one page transcribe each other.
Calling a person instead
The other kind of call. startCall reaches the triage agent; startLiveCall
rings the team, and whoever is free picks it up. It goes both ways — the team
can ring back, which is the section after this one.
if (await BugCatch.canCallTeam(bugId)) {
const call = await BugCatch.startLiveCall(bugId); // resolves once it is *ringing*
call.setMuted(true);
call.hangUp();
}
BugCatch.on('call', ({ live, state }) => {
if (!live) return;
// 'ringing' → 'connecting' → 'connected' → 'ended'
// or 'ringing' → 'missed', when nobody picked up in about twenty seconds.
});The widget renders this itself: a phone button on an open report's composer, shown only where the workspace has switched live calling on — which most have not.
What is different about it, and worth telling a customer:
- It is a real person, and it is not recorded. The audio goes directly between the two browsers over WebRTC and never reaches BugCatch or sphoro.voice, so there is no transcript. The report's thread gets a note saying the call happened, who took it and how long it lasted.
- It can go unanswered. It rings everybody who can see the report for about
twenty seconds; if nobody is free, the state is
missedand the reporter is pointed back at the chat. - A report id is required. Unlike
startCall(), there is no file-and-call path: ringing a room about a report nobody can read would ask somebody to answer a call they know nothing about. - One microphone. A live call and an agent call share the slot, so
endCall()ends whichever is running and neither can start over the other.
Being called
The same live call, placed from the other end. Somebody on the team reads a report an hour after it was filed, has one question whose answer is thirty seconds of talking and four days of email, and rings the person who filed it.
Nothing is needed to switch it on beyond what live calling already needs — but it does need the update stream, because that is what a call arrives on:
BugCatch.init({
apiBase: '…', apiKey: '…', projectId: '…',
updates: true, // the default; the call arrives on this
onIncomingCall: ({ from, reference, recorded }) => pauseTheVideo(),
});
BugCatch.on('incoming-call', ({ state }) => {
// 'ringing' → 'answered' → (then the ordinary 'call' events)
// or 'ringing' → 'declined' | 'missed' | 'failed'
});The SDK draws the card itself — who is calling, which report, whether the call
would be recorded, and Answer / Not now — and rings softly while it is up.
onIncomingCall fires after that, for a host application that wants to duck
its own audio or pause a video; it is not a hook that can suppress a call the
person can already see ringing.
What is worth telling a customer about this one:
It is an offer, never a connection. No microphone is opened and no audio moves until Answer is pressed. A page somebody walked away from ten minutes ago does not start listening to the room because the team rang it.
It says who is calling. An unnamed voice asking a customer about their account is indistinguishable from the fraud their bank warns them about, so the card carries the caller's name and the report before anything else.
It says whether it is recorded, before Answer. On a peer-to-peer call the copy that gets kept is uplinked by the customer's own browser, which makes the card the only honest moment to mention it.
Declining is recorded as declining. Not the same as not being there: the report's thread says which, because one invites ringing again in ten minutes and the other does not.
updates: falseswitches it off, as doesvoice: false. With no stream there is nothing for a call to arrive on, and no card is ever drawn.It only reaches a signed-in reporter, and that is enforced. The team's button is refused unless the person who filed the report has proved their address — a widget sign-in (
sdkLoginon the project, giving a live session) or a sign-in on their own report page. An address that only arrived throughidentify()is the host application vouching for its own user, which is not a claim anybody outside that application can rely on, and calls are not placed on it.A project with
sdkLogin: OFFtherefore has no callable reports through the widget, because it never asks anyone who they are. Turn sign-in on to make calling available.And they have to be at the page. Being signed in is about who; a call still joins two browsers, so theirs has to be open. The two refusals read differently on the bug page because only one of them clears by itself.
The seat is theirs alone. The chair kept open on an outbound call can only be claimed with one of the reporter's own credentials, so a call placed to a customer cannot be picked up by anybody else — including a member of the team looking at the same report.
It also rings in the reporter portal — the page where somebody signs in by email to check back on what they reported. Whichever tab is in front of them gets the call, and if both are open, the first to answer gets it.
canCall and canCallNow answer false — and no button appears — on any
deployment without sphoro.voice and any workspace that has not switched calling
on, which is most of them. canCallNow additionally needs identify() to have
supplied an email: a call with nobody to attribute has no way to tell the
reporter what happened next. Set voice: false to opt out entirely; the code is
still in the bundle (about 1.5 kB gzipped, most of it the G.711 codec a browser
does not ship), but nothing runs.
What is captured
- Console — last N entries with level and timestamp, plus uncaught errors and unhandled promise rejections.
- Network — last N
fetch/XHR calls: URL, method, status, duration, and flags for failed or slow. Request and response bodies are never read, and credentials are stripped out of the URL before it is buffered — see below. - Environment — user agent, platform, language(s), viewport, screen (size, available size, colour depth, orientation), DPR, timezone and offset, connection type, CPU cores, device memory, touch points, battery level and charging state, colour-scheme and reduced-motion preferences, Do Not Track, online state, referrer, page title.
- Page URL at the moment of reporting.
Both buffers are ring buffers, so a long-lived page won't grow memory without bound.
Notes
- The widget renders in a shadow root, so your CSS and its CSS can't collide.
- Capture never throws into your page: every hook is wrapped, and originals are always called.
- Attachment uploads use pristine
fetch/XHRreferences taken before the network hooks are installed, so a report never ends up describing its own upload. - The panel traps focus, closes on
Escape, restores focus to the launcher, and honoursprefers-reduced-motion— every animation is an entrance or a state hint, so switching them off costs nothing. destroy()restoresconsole.*,fetchandXMLHttpRequestto their originals.
Build
npm install
npm run build # dist/bugcatch.es.js + dist/bugcatch.umd.js