Overview
An integration is a small plugin that hooks into the SDK client once, at registration time. Every built-in (globalErrorIntegration, breadcrumbsIntegration, httpErrorsIntegration, ...) is built on exactly the same interface you get — there's no private API reserved for them, so anything a built-in does, your own integration can do too.
ts
interface Integration {
name: string
setup(client: BuglapseClient): void
teardown?(): void
}name— a unique id. It's the keydisable(name)looks up, and the key that decides whether a second registration is ignored or replaces an existing one (see Registering).setup(client)— called once, when the integration is registered on a live client. This is where you subscribe to events, patch globals, stamp context, or add a preprocessor.teardown()— optional. Called bydisable(name); undo whateversetup()did (remove listeners, restore patched functions).
A minimal integration
Stamp the current page's color scheme onto every event:
ts
import type { Integration } from '@buglapse/browser'
export function colorSchemeIntegration(): Integration {
return {
name: 'color-scheme',
setup(client) {
if (typeof window === 'undefined') return
const query = window.matchMedia('(prefers-color-scheme: dark)')
const apply = () => client.setTag('color_scheme', query.matches ? 'dark' : 'light')
apply()
query.addEventListener('change', apply)
this.teardown = () => query.removeEventListener('change', apply)
},
}
}Two conventions the built-ins follow, worth copying:
- Export a factory, not a plain object.
colorSchemeIntegration()returns a fresh object each call, so per-instance state (listeners, counters, the last-seen value) never leaks between clients, and every integration — built-in or yours — is called the same way. - Guard browser globals. Check
typeof window/document/navigatorbefore touching them, so the integration is a no-op instead of a crash when your bundle is evaluated during SSR or in a test runner.
Registering an integration
In init()
ts
import { init } from '@buglapse/browser'
import { colorSchemeIntegration } from './buglapse/color-scheme'
init({
dsn: '{dsn}',
integrations: [colorSchemeIntegration()],
})The list is merged with the SDK's defaults (currently only globalErrorIntegration()). An entry whose name matches a default replaces that default — so an integration named 'global-error' swaps out the built-in error capture without a separate disable() call.
With use()
ts
import { use } from '@buglapse/browser'
use(colorSchemeIntegration())use() works both before and after init(): called earlier, the registration is queued and applied right after the defaults; called later, setup() runs immediately. On the script-tag install it's Buglapse.use(...) on the global.
use() also accepts a factory plus its config, the same way the built-ins are registered:
ts
use(slowRequestIntegration, { thresholdMs: 2000 })
// same as: use(slowRequestIntegration({ thresholdMs: 2000 }))Unlike init({ integrations }), use() never replaces — if an integration with the same name is already registered, the new one is silently ignored and its setup() never runs. To swap one out after init(), disable(name) first, then use() the replacement.
Disabling
ts
import { disable } from '@buglapse/browser'
disable('color-scheme') // calls teardown(), then unregisters itCalled before init(), disable(name) only affects the built-in defaults (it stops them from being registered at all).
What setup() can do
setup() receives the same BuglapseClient the public API talks to. The useful surface:
| Method | Use it to |
|---|---|
client.setTag(key, value) | Add a searchable tag to every future event |
client.setContext(key, object) | Attach structured context to every future event |
client.setUser(user) | Identify the current user |
client.addBreadcrumb({ type, level, message, data }) | Record something that leads up to the next exception |
client.captureException(error) | Report an error |
client.log.info(message, attributes) (and log/warn/error/debug) | Send a log event |
client.span() / client.asyncSpan() / client.startInactiveSpan() | Instrument an operation as a span |
client.addPreprocessor(fn) | Rewrite or drop events before they're sent |
client.flush() | Push out any batched events right away |
client.transport.endpoint | The envelope URL — to avoid instrumenting the SDK's own requests |
client.debug | Whether debug: true was passed to init() |
Context, tags and breadcrumbs are covered in Context and Breadcrumbs; spans in Tracing.
Filtering and rewriting events
A preprocessor sees every event before it's sent. Return the (possibly modified) event to keep it, or null to drop it. For exception events the second argument is the original Error object, so you can branch on instanceof:
ts
import type { Integration } from '@buglapse/browser'
class AbortedByUserError extends Error {}
export function ignoreAbortsIntegration(): Integration {
let enabled = true
return {
name: 'ignore-aborts',
setup(client) {
client.addPreprocessor((event, rawError) => {
if (!enabled || event.type !== 'exception') return event
if (rawError instanceof AbortedByUserError) return null
if (rawError?.name === 'AbortError') return null
return event
})
},
teardown() {
enabled = false
},
}
}Preprocessors can't be unregistered — addPreprocessor() has no counterpart. If your integration should be disable-able, gate the preprocessor behind a flag that teardown() flips, as above. Preprocessors run in registration order, and once one returns null the rest are skipped.
If you only need a one-off filter rather than a reusable, named plugin, call addPreprocessor(fn) directly on the SDK instead of wrapping it in an integration.
Patching globals
Integrations that observe the page — network requests, history navigation, DOM events — usually wrap a browser API. Keep a reference to the original, and restore it in teardown(). This one reports any fetch slower than a threshold as a warning log:
ts
import type { BuglapseClient, Integration } from '@buglapse/browser'
export interface SlowRequestConfig {
thresholdMs?: number
}
// The SDK sends its own batches with fetch — never report (or re-instrument) those, or a slow
// /api/envelope would feed itself an endless stream of "slow request" logs.
function isOwnRequest(client: BuglapseClient, url: string): boolean {
const endpoint = client.transport.endpoint
if (!endpoint) return false
const target = new URL(url, window.location.href)
const own = new URL(endpoint)
return target.origin === own.origin && target.pathname === own.pathname
}
export function slowRequestIntegration(config: SlowRequestConfig = {}): Integration {
const thresholdMs = config.thresholdMs ?? 3000
return {
name: 'slow-request',
setup(client) {
if (typeof window === 'undefined' || typeof window.fetch !== 'function') return
const originalFetch = window.fetch.bind(window)
window.fetch = async (...args: Parameters<typeof fetch>) => {
const [input, init] = args
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
if (isOwnRequest(client, url)) return originalFetch(...args)
const startedAt = performance.now()
const response = await originalFetch(...args)
const duration = Math.round(performance.now() - startedAt)
if (duration >= thresholdMs) {
client.log.warn(`Slow request: ${init?.method ?? 'GET'} ${url} took ${duration}ms`, {
url,
method: init?.method ?? 'GET',
status: response.status,
duration,
})
}
return response
}
this.teardown = () => {
window.fetch = originalFetch
}
},
}
}Things to keep in mind when patching:
- Never swallow the original behavior. Always call through to the original and return (or re-throw) its result unchanged — your integration must be invisible to the app.
- Skip the SDK's own traffic. Compare against
client.transport.endpoint, as above. Without it, instrumentingfetch/XMLHttpRequestcan report the SDK's own requests — and a failing envelope endpoint turns into a feedback loop. - Setting
this.teardowninsidesetup()is the simplest way to capture the values (originalFetch, listener references) that only exist once setup has run. It works because the client callssetup()on the integration object itself. - Multiple integrations can wrap the same API. Built-ins like
breadcrumbsIntegrationandhttpErrorsIntegrationeach wrapfetchindependently; yours just becomes another layer. Disable integrations in the reverse order you registered them if they all patch the same function, or a laterteardown()can restore a stale wrapper.
Sending other event types
client.captureException(), client.log.* and the span helpers cover almost everything. For a built-in event type with no dedicated method, there's a lower-level escape hatch:
ts
client.capture('hit', { sessionId, name: 'checkout-opened' })capture(type, payload) stamps the current tags/contexts/trace onto the payload and sends it through the same preprocessor pipeline and transport as everything else. type must be one of the types /api/envelope accepts (exception, log, trace, span, replay, hit) and payload must match that type's shape — prefer the dedicated methods whenever one exists.
Debugging
Pass debug: true to init() and the client logs every integration registration (Integration "color-scheme" registered), every disable(), every captured event and every event a preprocessor drops. Inside your own integration, check client.debug before adding extra logging of your own.
Script-tag install
On the CDN script tag, the Settings → Loader Script toggles only cover the built-ins. Custom integrations are registered from your own code through the global:
html
<script src="https://app.buglapse.com/cdn/{project-token}/buglapse.min.js"></script>
<script>
Buglapse.use({
name: 'color-scheme',
setup(client) {
client.setTag('color_scheme', matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
},
})
</script>The script tag here is loaded without async, so window.Buglapse is guaranteed to exist when the second script runs. If you keep async, register from a load listener on the SDK's <script> element instead.