Integrate an SDK · Web
Web & React SDK
A framework-independent browser SDK, thin React helpers, and a Playwright runner the CLI launches per replay attempt. Capsules carry sdk.name: @bugcapsule/web and runtime.name: web.
| Package (0.3.0, ESM only) | Entry points | What it is |
|---|---|---|
@bugcapsule/web | ., ./core | The SDK, no dependencies. /core is the environment-neutral model, codec, redaction and request-key core (also used in Node). |
@bugcapsule/react | ., ./react-router | Hooks and components. Peers: @bugcapsule/web 0.3.0 (exact), react ≥18, react-router ≥6.4 (optional) |
@bugcapsule/web-replay | bin bugcapsule-web-runner | Node ≥20 + playwright-core 1.63 runner. Dev dependency, only where you run bugcapsule run|verify |
1. Install
The packages are not published to npm yet (UNLICENSED; publishing happens after review). Build tarballs from the SDK checkout and install those:
cd bug_capsule_web_sdk
npm install && npm run build && npm run pack:all # dist-packages/*.tgz
cd ../your-app
npm install ../bug_capsule_web_sdk/dist-packages/bugcapsule-web-0.3.0.tgz \
../bug_capsule_web_sdk/dist-packages/bugcapsule-react-0.3.0.tgz
npm install -D ../bug_capsule_web_sdk/dist-packages/bugcapsule-web-replay-0.3.0.tgz
# Once published: npm install @bugcapsule/web @bugcapsule/react; npm install -D @bugcapsule/web-replay
2. Initialize before rendering
import { BugCapsule, networkIntegration, historyIntegration, localStorageIntegration } from '@bugcapsule/web';
await BugCapsule.init({
appVersion: '1.4.0',
appBuild: '52',
mode: 'qa', // DEFAULT IS 'production' (10% sampled) — set it
gitSha: import.meta.env.VITE_BUGCAPSULE_GIT_SHA,
backend: { url: 'https://api.bugcapsule.com', ingestKey: 'bci_…' }, // never a bcs_ secret key
integrations: [
networkIntegration({ rules: [{ method: 'POST', path: '/api/checkout', ignoreBodyFields: ['timestamp', 'nonce'] }] }),
historyIntegration(),
localStorageIntegration({ allowPrefixes: ['shop.'] }),
],
allowReplay: import.meta.env.DEV || import.meta.env.VITE_BUGCAPSULE_ALLOW_REPLAY === 'true',
});
renderApp();
- Await
initbefore rendering. In replay it restores storage and state there. - Call it before any code keeps its own reference to
fetch, or that code bypasses capture (and replay's network serving). - A second call throws. Setup has a 5 s budget (
setupTimeoutMs); if it or an integration fails, the app runs without capture. - Leaving out
modemeans'production', which records onlyproductionSampleRate(10%) of sessions. Set'qa'for QA builds.
3. Get your origin allowed
Browsers only let the SDK reach BugCapsule from origins your project allows. Send your BugCapsule team the exact origins (scheme, host and port) your app runs on, for example:
https://app.example.com
https://staging.app.example.com
http://127.0.0.1:4317 # local QA builds
Until an origin is allowed, the browser console shows CORS errors and nothing is uploaded. The SDK never sends cookies to BugCapsule.
4. Make more bugs replayable
| Hook | Why |
|---|---|
onClick={BugCapsule.action('cart.checkout', checkout)} + data-bugcapsule-action="cart.checkout" | Semantic replay by id. The attribute is also the stable selector for click replay. Call action() after init. |
BugCapsule.trackInput('login.email', inputEl) | Typed text is replayed. Password inputs, data-bugcapsule-sensitive, { sensitive: true } and sensitive-looking ids record [REDACTED]. |
BugCapsule.check('cart_total_non_negative', total >= 0) / fail(id) | Wrong behaviour that doesn't throw becomes a checkable failure |
BugCapsule.reportError(e) / <BugCapsuleErrorBoundary> | Errors your code or React catches never reach window.onerror |
BugCapsule.registerState('cart', { serialize, restore }) | App-owned state (Redux, Zustand, a store) in checkpoints, restored in replay |
indexedDbIntegration([{ name, snapshot, restore }]) | IndexedDB stores through adapters you write; the SDK never reads IndexedDB itself |
BugCapsule.now() / date() instead of Date.now() | Replay returns the recorded wall clock |
BugCapsule.random() instead of Math.random() | Seeded per session (mulberry32), same seed in replay |
React
import { useBugCapsuleAction, useTrackedState, useBugCapsuleState,
BugCapsuleErrorBoundary, BugCapsuleCaptureButton } from '@bugcapsule/react';
import { BugCapsuleRouteListener } from '@bugcapsule/react/react-router';
function Cart() {
const [coupon, setCoupon] = useTrackedState('cart.coupon', '');
const [cart, setCart] = useState(emptyCart);
useBugCapsuleState('cart', cart, setCart); // checkpoint + restore
const placeOrder = useBugCapsuleAction('cart.checkout', async () => { … });
return <>
<input value={coupon} onChange={(e) => setCoupon(e.target.value)} />
<button data-bugcapsule-action="cart.checkout" onClick={() => void placeOrder()}>Checkout</button>
</>;
}
<BrowserRouter>
<BugCapsuleRouteListener /> {/* route events with push/pop/replace */}
<BugCapsuleErrorBoundary fallback={<Oops />}><Routes>…</Routes></BugCapsuleErrorBoundary>
<BugCapsuleCaptureButton /> {/* renders nothing in production or replay */}
</BrowserRouter>
| Export | Signature |
|---|---|
useBugCapsuleAction | (id, handler) => handler |
useTrackedInput | (id, { sensitive? }) => ref callback for an uncontrolled input |
useTrackedState | (id, initial: string, { sensitive? }) => [value, setValue] |
useBugCapsuleState | (key, value, restore: (data) => void) |
BugCapsuleErrorBoundary | fallback?: ReactNode | (error, reset) => ReactNode |
BugCapsuleCaptureButton | label?, className? |
BugCapsuleRouteListener | Render inside the router; combine with historyIntegration() for checkpoints |
Integrations
Network: fetch and XMLHttpRequest
networkIntegration({
fetch: true, xhr: true, // or fetchIntegration() / xhrIntegration()
rules: [{ method: 'POST', path: '/api/orders/:id', ignoreBodyFields: ['nonce'] }],
ignoreQuery: ['_', 'ts', 'timestamp', 'nonce', 'cachebust'], // default
maxBinaryBytes: 64 * 1024, maxKeyBodyBytes: 64 * 1024, maxBodyWaitMs: 30_000,
exclude: ({ method, url }) => url.includes('/api/payments'), // keeps method, URL, status, timing only
});
- The SDK reads a
clone()of each response up tomaxBodyBytes(256 KiB text, 64 KiB binary) and cancels the clone; the app consumes the original stream unchanged.text/event-streamisomitted. ReadableStreamrequest bodies,FormDataand XHRBlobbodies are never read: keyed without a body hash.- App wrappers installed after
initsit above the SDK's, so app interceptors still run in replay; only the bytes on the wire come from the capsule.
Storage, history, IndexedDB
localStorageIntegration({ allowPrefixes: ['shop.'], denyPrefixes: ['shop.token'] }) // allowPrefixes is required
sessionStorageIntegration({ allowPrefixes: ['checkout.'] })
historyIntegration({ sanitizeLocation: (l) => l.replace(/\/reset\/[^/]+/, '/reset/:token') })
indexedDbIntegration([{ name: 'offline-cart', snapshot: async () => readCart(), restore: async (d) => writeCart(d) }])
historyIntegration wraps pushState/replaceState and listens to popstate/hashchange. Cookies, DOM content, input values (except tracked inputs) and storage outside the allowlist are never captured.
Configuration reference
| Field | Default | Notes |
|---|---|---|
appVersion, appBuild | required | |
appId, appName, flavor | null | Max 200 chars |
mode | 'production' | 'debug' | 'qa' | 'production' |
enabled | true | false = no capture setup at all |
backend | none | { url, ingestKey }; without it captures are saved locally only |
gitSha, gitDirty | null | Missing SHA or dirty tree → build.gitSha gap |
stateSchemaVersion | 1 | |
integrations | [] | A state integration is always appended |
checkpointIntervalMs / windowMs / maxEvents | 30 000 / 300 000 / 20 000 | |
productionSampleRate | 0.1 | Your project's sample rate can only lower it |
autoCaptureOnFailure / maxCapsulesPerSession | true / 3 | Limit applies to auto-capture |
captureNetworkBodies / maxBodyBytes | true / 256 KiB | |
redactKeys, redactHeaders, redactPatterns | [] | redactPatterns: RegExp[] |
beforeRecord(type, data) | — | After built-in redaction; return null to drop |
beforeCheckpoint(integration, data) | — | Return null/undefined to omit that snapshot |
outboxLimits | 10 / 10 MiB / 7 days | { maxCount, maxBytes, maxAgeMs } |
maxStaleConfigMs | 7 days | |
ignoreError(error) | — | Return true if not a failure |
labels | {} | At most 100 |
capturePointers | true | |
sensitiveSelector | '[data-bugcapsule-sensitive]' | Password inputs are always sensitive |
allowReplay | false | Wire to a dev/test build flag only |
storageName | 'bugcapsule' | IndexedDB database name |
setupTimeoutMs | 5000 |
Methods
BugCapsule.action(id, handler) // wraps handler
BugCapsule.actionWithPayload(id, (payload) => …)
BugCapsule.trackInput(id, el, { sensitive }) // returns dispose()
BugCapsule.trackValue(id, set, { sensitive }) // { changed(value), dispose() } for custom widgets
BugCapsule.fail(id, message?); BugCapsule.check(id, condition, message?); BugCapsule.reportError(e)
BugCapsule.log(message, 'debug' | 'info' | 'warn' | 'error')
BugCapsule.route(location, source?, action?) // explicit route events
BugCapsule.registerState(key, { serialize, restore }) // returns unregister; stateChanged(key)
await BugCapsule.capture({ note }) // CaptureResult | null
BugCapsule.now(); BugCapsule.date(); BugCapsule.random()
await BugCapsule.refreshRemoteConfig(); await BugCapsule.flushUploads()
await BugCapsule.pendingUploads(); await BugCapsule.savedCaptures()
BugCapsule.isInitialized; BugCapsule.isRecording; BugCapsule.isReplaying; BugCapsule.mode; BugCapsule.session
Replay with the CLI
{
"runtime": "web",
"web": {
"url": "http://127.0.0.1:4317/",
"installCommand": "npm ci",
"buildCommand": "npm run build",
"serveCommand": "npm run preview -- --port 4317 --strictPort",
"env": { "VITE_BUGCAPSULE_ALLOW_REPLAY": "true" }
}
}
bugcapsule run <id> -n 3 # builds (on the capsule's commit when pinned), serves, replays
bugcapsule verify <id> -n 3 # builds the working tree
bugcapsule run <id> --app-url http://127.0.0.1:4317/ # against an already running build
The CLI passes BUGCAPSULE_GIT_SHA/BUGCAPSULE_GIT_DIRTY in the build environment; map them to your bundler's env (the shop demo's vite.config.ts maps them to VITE_BUGCAPSULE_GIT_SHA with define). The CLI refuses to start when something already serves web.url (it might be an old build).
Per attempt, bugcapsule-web-runner:
- Fetches
/settingsand/capsulefrom the CLI's loopback server. - Launches Chromium with the recorded viewport, scale factor and locale, service workers blocked, a fresh browser context.
- Blocks every request to an origin other than the app's (and
web.allowOrigins) and reportsnetwork.escaped. - Restores
localStorage/sessionStoragein an init script before any page script, exposes the Playwright bindings, and opens the checkpoint's location. - The app's own SDK (with
allowReplay: true) runs the engine: real pointer input through Playwright at recorded coordinates (hit-tested against the recorded target), semantic actions, tracked inputs, synthetic lifecycle; fetch/XHR served from the capsule. - Posts the report to
/resultand prints aBUGCAPSULE_RESULT::line. Default timeout 120 s.
Production protection. Replay activates only when allowReplay: true and the page was opened by the runner. With allowReplay false nothing (URLs, storage, messages, scripts) can turn replay on. A script already in a page with allowReplay: true could imitate the runner bindings, one more reason to keep such builds out of production.
Uploads and storage
- IndexedDB database
bugcapsule: outbox (10 / 10 MiB / 7 days), newest 20 saved captures, cached remote config. Without IndexedDB the SDK keeps them in memory. - Several tabs share one queue; only one uploads at a time (Web Locks, or a lease record on insecure origins). Queues survive reloads; nothing relies on an upload finishing during unload.
- Failed uploads retry with backoff from 30 s up to 1 h; a capsule BugCapsule rejects is dropped.
- Kill switch: purges queue and saved captures, persists. A rejected ingest key also deletes the queue and saved captures and stops capture for the page session.
Shop demo
examples/shop: React app with four planted bugs (race, legacy cart, discontinued item, coupon) and a local API (api-server.mjs, port 4318). Environment: VITE_BUGCAPSULE_BACKEND_URL, VITE_BUGCAPSULE_INGEST_KEY, VITE_BUGCAPSULE_ALLOW_REPLAY, VITE_BUGCAPSULE_DEMO_FIX (race|legacy|discontinued|coupon), VITE_SHOP_API_URL.
node examples/shop/api-server.mjs # shop API on 127.0.0.1:4318
cd examples/shop
VITE_BUGCAPSULE_BACKEND_URL=https://api.bugcapsule.com VITE_BUGCAPSULE_INGEST_KEY=bci_… \
VITE_BUGCAPSULE_ALLOW_REPLAY=true npx vite build && npx vite preview --port 4317 --strictPort
Browsers and limitations
| Browser | Capture | Replay |
|---|---|---|
| Chromium / Chrome / Edge | tested | tested |
| Firefox, Safari | standard APIs, untested | not implemented |
- Hover-only interactions aren't recorded (moves only while pressed); keyboard input only through tracked inputs.
- WebSockets, EventSource payloads, service workers,
sendBeaconand third-party iframes are not captured. - Synchronous XHR replays without its recorded latency. Cross-origin script errors without CORS headers arrive as
Script error.. - A fix that makes the app send requests the recording never saw verifies as
INCONCLUSIVE, neverFIXED(network.unrecorded). BugCapsuleCaptureButtonclicks are still recorded as pointer events.- No consent, user-identify or breadcrumb API: use
enabled,labels,log(),route().