Documentation Web SDK

Web SDK

A ~4 kB script that puts a Report a bug button in your own product. Whoever presses it files a report carrying the console, the network activity, the environment and the page URL — the things you would otherwise have to ask for and usually never get.

Package: @sphoro/bugcatch-web-sdk Version: 0.13.1 Dependencies: none Install time: about 5 minutes

This page is the whole integration, in order: install it, initialise it, tell it who is reporting, then decide how much of the rest you want. Steps 1 to 3 are everything most products ever do.

You need two values before you start — an API key and a project id. If you do not have them yet, getting started is three screens in the dashboard.

1. Install

Pick whichever matches how your app is built. They are the same code.

Installation method
terminal
npm install @sphoro/bugcatch-web-sdk
anywhere in your app
import BugCatch from '@sphoro/bugcatch-web-sdk';
in <head>, or before </body>
<!-- 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/@sphoro/bugcatch-web-sdk@0.13.1/dist/bugcatch.umd.js"></script>

The UMD build puts BugCatch on window. Load it early — capture only sees what happens after the script runs, so a script at the very bottom of a slow page misses the first few seconds of console output.

If your CSP will not allow a third-party CDN, serve the file yourself. It is one file with nothing else to fetch.

terminal
npm install @sphoro/bugcatch-web-sdk
cp node_modules/@sphoro/bugcatch-web-sdk/dist/bugcatch.umd.js public/vendor/
your page
<script src="/vendor/bugcatch.umd.js"></script>

2. Initialise

One call, as early in your app's life as you can manage. Three options are required; everything else has a default that is right for most products.

the minimum
BugCatch.init({
  apiBase: 'https://api.bugcatch.sphoro.com',
  apiKey: 'bc_live_…',      // Workspace Settings → API Keys
  projectId: '',             // Project Settings → General
});
OptionRequiredNotes
apiKey Required Throws immediately if missing. Publishable — it is meant to be in your page source.
projectId Required Throws immediately if missing. The id, not the short project key.
apiBase In practice, yes Defaults to http://localhost:3000, which is right for developing against a local API and wrong in production. Set it explicitly.

The most common first-day mistake is leaving apiBase out. The widget renders, the reporter presses Send, and the request goes to localhost:3000 on their machine — where there is nothing. The report is never filed and the dashboard shows nothing to explain why.

Two things worth knowing about init()

  • Calling it twice is ignored, with a warning in the console. A second call does not reconfigure anything — to change options, call destroy() and then init() again.
  • It is synchronous and makes no request. A visitor who has never filed a bug costs one script parse and nothing on the network.

3. Where to put it, per framework

The rule in every case: initialise once, at the top level, on the client. The SDK patches console, fetch and XMLHttpRequest, so it needs a browser and it needs to be early.

Framework

Initialise in the entry module, outside any component. Module scope runs once even under StrictMode's double-mounted effects.

src/main.jsx
import BugCatch from '@sphoro/bugcatch-web-sdk';

BugCatch.init({
  apiBase: import.meta.env.VITE_BUGCATCH_API,
  apiKey: import.meta.env.VITE_BUGCATCH_KEY,
  projectId: import.meta.env.VITE_BUGCATCH_PROJECT,
});

ReactDOM.createRoot(document.getElementById('root')).render(<App />);

Then, wherever you know who is signed in:

a component that has the user
useEffect(() => {
  if (user) BugCatch.identify({ id: user.id, email: user.email, name: user.name });
  else BugCatch.identify(null);
}, [user]);

The App Router renders on the server, where there is no window. Put the call in a client component mounted from the root layout.

app/bugcatch.tsx
'use client';

import { useEffect } from 'react';
import BugCatch from '@sphoro/bugcatch-web-sdk';

export default function BugCatchProvider() {
  useEffect(() => {
    BugCatch.init({
      apiBase: process.env.NEXT_PUBLIC_BUGCATCH_API,
      apiKey: process.env.NEXT_PUBLIC_BUGCATCH_KEY,
      projectId: process.env.NEXT_PUBLIC_BUGCATCH_PROJECT,
    });
    // StrictMode mounts effects twice in development. init() ignores the
    // second call, so no cleanup is needed — but tearing down here also works
    // and releases the console/fetch patches on unmount.
  }, []);
  return null;
}
app/layout.tsx
import BugCatchProvider from './bugcatch';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <BugCatchProvider />
        {children}
      </body>
    </html>
  );
}

The variables must be NEXT_PUBLIC_-prefixed. The key is publishable and belongs in the browser bundle — that is what it is for.

src/main.js
import { createApp } from 'vue';
import BugCatch from '@sphoro/bugcatch-web-sdk';
import App from './App.vue';

BugCatch.init({
  apiBase: import.meta.env.VITE_BUGCATCH_API,
  apiKey: import.meta.env.VITE_BUGCATCH_KEY,
  projectId: import.meta.env.VITE_BUGCATCH_PROJECT,
});

createApp(App).mount('#app');

Then, in whatever holds your session:

stores/session.js
watch(user, (u) => BugCatch.identify(u ? { id: u.id, email: u.email, name: u.name } : null));
src/main.ts
import BugCatch from '@sphoro/bugcatch-web-sdk';
import { environment } from './environments/environment';

BugCatch.init({
  apiBase: environment.bugcatchApi,
  apiKey: environment.bugcatchKey,
  projectId: environment.bugcatchProject,
});

bootstrapApplication(AppComponent, appConfig);

Angular's HttpClient uses XMLHttpRequest, which the SDK captures — so your service calls appear in the report's Network tab without any interceptor of your own.

src/main.js
import BugCatch from '@sphoro/bugcatch-web-sdk';
import App from './App.svelte';

BugCatch.init({
  apiBase: import.meta.env.VITE_BUGCATCH_API,
  apiKey: import.meta.env.VITE_BUGCATCH_KEY,
  projectId: import.meta.env.VITE_BUGCATCH_PROJECT,
});

export default new App({ target: document.getElementById('app') });

On SvelteKit, do the same from onMount in +layout.svelteonMount only runs in the browser.

index.html
<script src="https://unpkg.com/@sphoro/bugcatch-web-sdk@0.13.1/dist/bugcatch.umd.js"></script>
<script>
  BugCatch.init({
    apiBase: 'https://api.bugcatch.sphoro.com',
    apiKey: 'bc_live_…',
    projectId: '',
  });

  // If your page knows who is signed in, say so.
  if (window.currentUser) {
    BugCatch.identify({ email: window.currentUser.email, name: window.currentUser.name });
  }
</script>

4. Verify the install

Do this before you go further. Everything below assumes reports are arriving.

  • A Report a bug launcher is visible in the bottom-right of your app.
  • The browser console shows no [BugCatch] warning or error on load.
  • Filing a test report closes the panel with a confirmation rather than an error.
  • The report appears in the dashboard, in the project you expected.
  • Its Console tab has your app's own log lines in it, and its Network tab has your app's own requests. If both are empty, the SDK loaded after everything interesting had already happened — move the call earlier.
  • Its Environment tab shows the browser and viewport you filed from.

Test it from a real page, not from the console. Typing BugCatch.report(…) into devtools works, but it tells you nothing about whether your build shipped the call.

5. Tell BugCatch who is reporting

Optional, and the single highest-value line you will add. Without it, reports are anonymous and nobody ever hears what happened to them.

after your user signs in
BugCatch.identify({ id: 'u_42', email: 'alex@acme.com', name: 'Alex' });

// And on sign-out — otherwise the next person at a shared machine
// inherits the previous one's reports.
BugCatch.identify(null);
BugCatch.forgetReports();

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 customer keeps hitting things" is visible rather than something someone has to notice.
  • Closes the loop. When the bug is marked 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 form is unusable for outbound mail until its owner answers a code. One that arrives through identify() is not, because your app already knows who they are. Only pass addresses you have actually signed in.

Letting reporters prove the address themselves

identify() is your application vouching for its own user. Some products want the address proved instead — the reporter types one, we email a code, they type it back. That is a project setting, not an SDK option: a widget that could opt out of a required sign-in would not be a requirement.

Open Project Settings → General → Reporter sign-in and pick one:

SettingWhat the widget does
Off (default)No sign-in. An address only reaches a report through identify().
OptionalOffers a sign-in above the form. Anyone who would rather not can still report anonymously.
RequiredNo form at all until they have signed in. Every report then carries a proved address.

Two consequences worth planning for:

  • One live session per address per project. Signing in on a phone signs that address out on the laptop. The widget notices on its next open and says so, rather than failing at the moment they press Send.
  • Where both exist, 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 field, so signing in is one box and one code rather than two.

Driving it yourself, with widget: false:

your own sign-in UI
const { mode, signedIn } = await BugCatch.loginState();

if (mode !== 'off' && !signedIn) {
  await BugCatch.signIn('alex@acme.com');              // a code goes out
  await BugCatch.confirmSignIn('alex@acme.com', code);  // whatever they typed back
}

await BugCatch.signOut();   // ends it here and on the server

6. Options reference

Everything init() accepts. Defaults are what you get by leaving it out.

Identity and destination

OptionDefaultWhat it does
apiKeyrequiredWorkspace API key.
projectIdrequiredProject the bugs land in.
apiBasehttp://localhost:3000Where the API lives. Set it.
usernullSame shape as identify(), if you have it at init time.

The widget

OptionDefaultWhat it does
widgettrueThe floating launcher. false to drive it from your own UI.
widgetPosition'bottom-right'Also bottom-left, top-right, top-left.
theme'auto'auto follows the visitor's system setting; light / dark pin it. A switch sits above the launcher, and once a visitor uses it their choice is remembered and wins on later visits.
accent'#4F46E5'Any CSS colour. Used for the launcher, focus rings and the primary button.
knownIssuestrueShow 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 you switch it on for the project and something has been reported twice — see What is already known.

Capture

OptionDefaultWhat it does
captureConsoletrueWrap console.*.
captureNetworktrueWrap fetch and XMLHttpRequest.
captureErrorstrueListen for error and unhandledrejection.
consoleLimit100Console entries retained. A ring buffer, so memory is bounded.
networkLimit50Network entries retained.
beforeSendMutate the payload, or return false to cancel the report.

Attachments

OptionDefaultWhat it does
attachmentstrueOffer screenshot, screen recording and file attach.
maxAttachments5Per report.
maxAttachmentBytes25 MBPer file.
maxRecordingMs120000Hard cap on a single recording.

The reporter loop

OptionDefaultWhat it does
updatestrueTell the reporter, live, when a bug they filed moves. Connects only once this browser has filed something.
updateToasttrueShow the built-in notification. Set false to render your own.
onUpdateCalled on every update, whether or not the built-in card is shown.

Calling

OptionDefaultWhat it does
voicetrueOffer calls in both directions, where the workspace has one configured. Nothing is dialled and no microphone is opened until somebody presses a button.
onCallStateAs a call moves between states.
onCallTranscriptEach line of a call, as it is recognised.
onIncomingCallSomeone on the team is ringing this page. The card is already drawn; this is for ducking your own audio.

What is already known

The widget can open with the reports you have said are already broken, above the form rather than after it, with a Me too button beside each one. Somebody who came to report a thing you already know about says so in one tap and leaves — and you get one report carrying everybody’s environment instead of fourteen tickets carrying one each.

Nothing is in anybody’s way. The list is dismissed by ignoring it, and the form underneath is the form that was always there.

One switch, off until you set it:

  • Project Settings → General → “Show what is already known before the report form” — whether this project publishes anything at all.

Which reports appear is then decided by the queue rather than by anybody remembering to tick a box. A report goes on the list once a second person has hit it — a report matched to it as a repeat, or a Me too — and comes off the moment it is resolved. Never on severity: how bad your team thinks something is is a judgement, and this publishes on facts.

That used to be a second, per-report switch, and the honest reason it is not any more is that the moment a report becomes worth publishing is the moment somebody reports it twice — which is a moment nobody on your team is present for. The list it produced was empty.

Anything that should not be public comes off on the report’s own page, and stays off however many more people report it.

This is not the same control as session visibility. A public session is readable by whoever holds its link, which you hand to one person on purpose. Listing puts the title in front of every visitor of your site.

Joining is a promise, and it is kept: everyone who pressed Me too is told when the report is resolved, over the same live updates and the same email as the person who filed it. That is why the widget asks for an address when neither a sign-in nor identify() has given it one.

driving it yourself
const issues = await BugCatch.knownIssues();
// [{ id, title, status, affectedCount, since, joined }]

await BugCatch.meToo(issues[0].id, { email: 'them@example.com' });
// { joined: true, affectedCount: 4 }

What comes back is a title, a state and a count — never a severity, an assignee or a description. The question it answers is “is this the thing I came here about”, and your own estimate of how bad something is reads as a promise once it is published.

7. Reporting from your own UI

Turn the launcher off and call report() from your own button. Everything captured still comes along.

your own entry point
BugCatch.init({ apiBase, apiKey, projectId, widget: false });

document.querySelector('#help').onclick = () =>
  BugCatch.report({
    title: 'Checkout button does nothing',
    description: 'Clicked Pay, no request fired',
    priority: 'HIGH',                 // LOW | MEDIUM | HIGH | CRITICAL
    metadata: { plan: 'team', cartId: 'c_881' },
  });

report() resolves to { id, redactionsApplied, watchToken, estimate }, or to { cancelled: true } if your beforeSend returned false. It rejects if title is empty.

Attaching your own files

uploadAttachment() resolves the storage key a report references, so you can build your own capture flow and still get media onto the bug. Bytes go straight to storage, never through your own server.

your own capture flow
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'
});

A report's attachments are typed SCREENSHOT or VIDEO, so they accept image/png, image/jpeg, image/webp, video/webm and video/mp4. Entries with an unusable key or an unaccepted type are dropped rather than failing the whole report. Documents and voice notes belong on a message, which takes more types.

What the built-in widget captures

Screenshot and screen recording both go 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 your page rather than the dialog, and a recording collapses the panel to a timer bar with Stop and Discard.

Capture needs a secure context (HTTPS or localhost). Where getDisplayMedia is missing the two capture buttons are simply not rendered, and file attach still works.

8. Closing the loop with the reporter

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. This is on by default and needs no setup beyond identify().

Four kinds of update arrive, each with a summary already phrased for the reporter:

kindWhenAlso carries
statusopen → in progress → fixed → closedstatus
severitythe team re-prioritised itpriority
assignmentsomeone picked it up, or put it backassigned
replythe team sent them a messagereply.body, reply.author

Internal comments never arrive. Only a comment a team member explicitly marked as visible to the reporter becomes a reply. And the assignee is never named: assigned: true says the report has an owner; who that is stays inside your workspace.

Rendering it yourself

Nothing here is on a timer. Each update is held until it is marked read — and read is something the reporter does, not something a clock decides — so if you replace the built-in card, your UI is the thing that has to say when.

your own notifications
BugCatch.init({
  apiKey, projectId,
  updateToast: false,
  onUpdate: (u) => myToast(u.summary, () => BugCatch.markUpdateRead(u.id)),
});

// The number for your own bell: fires on every arrival and every mark-read.
BugCatch.on('unread', ({ count }) => setBadge(count));

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

Prefer summary over building the sentence yourself — the API writes it, so your card, the widget's timeline and the reporter portal all say the same thing.

Their own history, and writing back

Once this browser has filed something, the widget grows a Your reports button listing everything they reported and where each one got to. Opening one opens a conversation: the team's replies and theirs, either side of the quiet status lines, with a box that takes text, files, images and a voice note.

building your own
const { reports } = await BugCatch.myReports();
// [{ id, reference: '3F2A9B01', title, status, priority, createdAt, project, attachments }]

if (BugCatch.hasReports()) showMyReportsLink();   // hide it until it has content

const report = await BugCatch.myReport(id);      // full timeline, both sides

await BugCatch.replyToReport(id, {
  body: 'Still empty on page 3',
  attachments: [{ ...uploaded, name: file.name, size: file.size }],
});

Body or attachments — at least one, or the API refuses it. 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)).

The timeline is stored server-side, so someone who reports a bug on Monday and checks back on Friday sees the triage and the reply, not just "fixed". 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.

Call BugCatch.forgetReports() on sign-out. The subscription lives in localStorage; without this, the next person at a shared machine inherits the previous one's notifications. It takes the unread ones with it.

9. Voice calls

Where the workspace has sphoro.voice configured, a reporter can talk instead of type. Most deployments do not have thiscanCall() and canCallNow() answer false, no button is drawn, and you can skip this section entirely.

There are two different features here, and a deployment offers one or the other:

 Triage agentLive call
Started withstartCall()startLiveCall(bugId)
Who answersSoftware, and it says soA person on your team
RecordedYes — the team reads the transcriptNo. Peer-to-peer over WebRTC; the thread gets a note that it happened
Can go unansweredNoYes — missed after about twenty seconds
Needs an existing reportNo — it files one and calls about itYes
your own call button
// Which kind this deployment offers, or null for neither.
const kind = await BugCatch.callKind();      // 'agent' | 'live' | null

if (await BugCatch.canCallNow()) {
  const call = await BugCatch.startCall();    // files a report, then calls about it
  call.bugId;                                // what it was filed as
}

BugCatch.endCall();                          // hang up, either kind

Three things customers always ask, worth answering in your own UI too:

  • 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, because there is one microphone. destroy() ends it.
  • An incoming call is an offer, not a connection. When the team rings a page, the SDK draws a card naming the caller, the report, and whether the call would be recorded — and nothing opens until Answer is pressed.

Being called requires a proved address. The team's button is refused unless the reporter signed in through the widget or the portal. An address that only arrived through identify() is your app vouching for its own user, which is not a claim anybody outside your app can rely on. A project with reporter sign-in Off therefore has no callable reports.

Set voice: false to opt out entirely. The code stays in the bundle — about 1.5 kB gzipped, most of it a codec browsers do not ship — but nothing runs and no card is ever drawn.

10. Keeping secrets out of reports

Three layers, and the first one you get without doing anything.

Credential scrubbing, always on

Request and response bodies are never read, which leaves the URL as the place credentials actually turn up. Every one of these is replaced with [REDACTED] before the entry enters the ring buffer — so the secret never leaves the page, and is not recoverable from memory either:

what happens to a query string
https://api.example.com/v1/me?api_key=abcd1234efgh5678&page=2
                            
https://api.example.com/v1/me?api_key=[REDACTED]&page=2

Recognised without configuration: parameters named for a credential, JWTs, S3 presigned signatures, OAuth fragments, user:password@host, and the key formats used by AWS, Google, GitHub, GitLab, Stripe, Slack, Shopify, OpenAI and Anthropic. Ordinary parameters — ?page=2, ?sort=created_at — are left alone, 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.

Your own rules, in the browser

beforeSend runs before anything leaves the page
BugCatch.init({
  apiKey, projectId,
  beforeSend: (payload) => {
    payload.logs.network.entries = payload.logs.network.entries
      .filter((e) => !e.url.includes('/internal/'));
    delete payload.metadata.user;
    return payload;          // or `false` to cancel the report entirely
  },
});

Your own rules, on the server

Workspace Settings → Security holds redaction rules that run as well, so a secret is caught even when a client forgets or an old bundle is still cached somewhere. Redaction runs before encryption, so the stored row never contains the secret. The same scrubbing is applied again when a report is read back, which covers reports captured by older SDK versions.

What is captured, in full

GroupContents
Console The last consoleLimit entries with level and timestamp, plus uncaught errors and unhandled promise rejections. Each message truncated to 2,000 characters.
Network The last networkLimit fetch/XHR calls: URL, method, status, duration, and flags for failed or slow. No request or response bodies, and no headers.
Environment User agent, platform, language(s), viewport, screen, DPR, timezone, connection type, CPU cores, device memory, touch points, battery, colour-scheme and reduced-motion preferences, Do Not Track, online state, referrer, page title.
Page URLAt the moment of reporting, scrubbed the same way.

11. Content Security Policy

If your app sends a CSP, these are the directives the SDK needs.

DirectiveAddWhy
connect-src https://api.bugcatch.sphoro.com Filing reports, the update stream, and attachment uploads — which are proxied through the API, so this one host covers all of it.
script-src https://unpkg.com Only if you load the bundle from the CDN. Serve it yourself and you need nothing here.
style-src 'unsafe-inline' The widget builds a <style> element inside its shadow root. There is no nonce option today.
img-src blob: data: Screenshot previews are held as blobs before upload.
media-src blob: Only if you keep screen recording on.

If style-src 'unsafe-inline' is not acceptable in your application, run with widget: false and call report() from your own UI. Capture, uploads and the reporter loop all work without the widget — it is only the rendered panel that needs the stylesheet.

Nothing else is fetched: fonts are the system stack and every icon is inline, so there is no font-src or third-party img-src to add. And there is nothing to allowlist on our side — the /ingest/ routes accept any origin by design.

12. Troubleshooting

Every failure below writes something to the browser console. Open it first — the message usually names the problem outright.

What you seeWhat it isFix
[BugCatch] apiKey is required. init() threw. Nothing is running. The value is undefined at the point you call init() — usually an env variable without the framework's public prefix.
[BugCatch] projectId is required. Same. Check you pasted the project id, not the short project key.
[BugCatch] init() called twice A warning. The second call was ignored. Harmless under React StrictMode. If you meant to reconfigure, destroy() first.
No launcher appears at all Either widget: false, or the script never ran. Check the Network tab for the bundle, and that no earlier error stopped your entry module.
Send fails, request went to localhost:3000 apiBase was left out. Set it to https://api.bugcatch.sphoro.com.
401 on /ingest/bugs The key is wrong, or revoked. Issue a new one under Workspace Settings → API Keys. Keys cannot be recovered, only replaced.
403, "workspace is suspended" The key is valid; the workspace is not ingesting. Retrying will not help. Contact support.
400, "That project does not belong to this API key" The project id is from a different workspace. Take both values from the same workspace.
401, "Sign in to report a bug on this project" The project requires reporter sign-in and this browser has no live session. Either sign the reporter in, or set Reporter sign-in to Off/Optional.
Console and Network tabs are empty on every report The SDK initialised after the interesting activity. Move init() earlier — ideally the first thing your entry module does.
The two capture buttons are missing No getDisplayMedia — an insecure context, or a browser without it. Serve over HTTPS. File attach still works meanwhile.
The widget renders unstyled CSP blocked the shadow-root stylesheet. Allow style-src 'unsafe-inline', or run widget: false.
Reports arrive with no reporter identify() was never called, or was called with no email. See step 5. Nobody gets a resolution mail without it.
An update card shows the previous user's bug forgetReports() was not called on sign-out. Call it alongside identify(null).

Removing it cleanly

teardown
BugCatch.destroy();   // restores console.*, fetch and XMLHttpRequest; ends any call
BugCatch.clear();     // empties the capture buffers without tearing down

destroy() leaves the page exactly as it found it, so a later init() starts fresh rather than stacking a second widget.

Design notes worth knowing

  • The widget renders in a shadow root, so your CSS and its CSS cannot collide.
  • Capture never throws into your page. Every hook is wrapped and the originals are always called, so a bug in the SDK cannot break your app's logging.
  • Uploads use pristine fetch/XHR references taken before the network hooks are installed — so a report never ends up describing its own upload.
  • Both buffers are ring buffers. A page left open all day does not grow memory without bound.
  • The panel traps focus, closes on Escape, restores focus to the launcher, and honours prefers-reduced-motion.
  • Nothing connects until this browser has filed something. A visitor who never reports costs no requests at all.

Still stuck?

Every answer here describes what the product actually does today. If something behaves differently, that is worth telling us about — it means the page is wrong.

Support
support@sphoro.com
Security
info@sphoro.com
Talk to us
Book 20 minutes