Skip to content

Overview ​

An integration is a small plugin that hooks into the SDK client once, when it's registered. Every built-in (httpServerIntegration, breadcrumbsIntegration, ...) uses the same interface you get — 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. disable(name) looks it up, and it 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. Subscribe to events, patch modules, set context or add a preprocessor here.
  • teardown() — optional. Called by disable(name); undo whatever setup() did.

A minimal integration ​

Tag every event with the deployment it came from, read from environment variables:

ts
import type { Integration } from '@buglapse/node'

export function deploymentIntegration(): Integration {
    return {
        name: 'deployment',
        setup(client) {
            client.setTag('region', process.env.FLY_REGION ?? 'local')
            client.setContext('deployment', {
                id: process.env.DEPLOYMENT_ID,
                commit: process.env.GIT_SHA,
            })
        },
    }
}

Export a factory, not a plain object. deploymentIntegration() returns a fresh object on each call, so per-instance state (listeners, timers, counters) never leaks between clients — and every integration, built-in or yours, is called the same way.

Registering an integration ​

In init() ​

ts
import { init } from '@buglapse/node'
import { deploymentIntegration } from './buglapse/deployment'

init({
    dsn: process.env.BUGLAPSE_DSN,
    integrations: [deploymentIntegration()],
})

The list is merged with the SDK's defaults (currently only uncaughtExceptionIntegration()). An entry whose name matches a default replaces it — an integration named 'uncaught-exception' swaps out the built-in crash handling without a separate disable() call.

With use() ​

ts
import { use } from '@buglapse/node'

use(deploymentIntegration())

use() works before init() (queued, applied right after the defaults) and after (setup() runs immediately). It also accepts a factory plus its config, the way built-ins are registered:

ts
use(slowQueryIntegration, { pool, thresholdMs: 500 })
// same as: use(slowQueryIntegration({ pool, thresholdMs: 500 }))

Unlike init({ integrations }), use() never replaces: if an integration with the same name is already registered, the new one is ignored. To swap one out, disable(name) first.

Disabling ​

ts
import { disable } from '@buglapse/node'

disable('deployment') // calls teardown(), then unregisters it

Before init(), disable(name) only affects the built-in defaults — it keeps them from being registered at all.

What setup() can do ​

setup() receives the same BuglapseClient the flat API talks to:

MethodUse it to
client.setTag(key, value)Add a searchable tag to every future event (process-wide)
client.setContext(key, object)Attach structured context to every future event (process-wide)
client.withScope(fn)Run fn with its own scope, breadcrumbs and tags
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.endpointThe envelope URL — to avoid instrumenting the SDK's own requests
client.debugWhether debug: true was passed to init()

setTag() / setContext() on the client are global to the process. For anything specific to one request or job, use withScope() — see Tags & Context.

Listening to process events ​

Node reports a lot through events on process or on library objects. Subscribe in setup(), unsubscribe in teardown(). This one forwards Node's own runtime warnings (deprecations, MaxListenersExceededWarning, ...) as logs:

ts
import type { Integration } from '@buglapse/node'

export function processWarningsIntegration(): Integration {
    return {
        name: 'process-warnings',
        setup(client) {
            const onWarning = (warning: Error & { code?: string }) => {
                client.log.warn(warning.message, { name: warning.name, code: warning.code })
            }

            process.on('warning', onWarning)

            this.teardown = () => process.off('warning', onWarning)
        },
    }
}

Setting this.teardown inside setup() is the simplest way to reach values that only exist once setup has run (listeners, original functions). It works because the client calls setup() on the integration object itself.

A timer started in setup() should be unref()'d, so it never keeps the process alive on its own — as EventLoopBlockIntegration does.

Wrapping a library ​

To trace or log calls into a library, wrap its method and restore it in teardown(). This one records every query of a pg pool as a db span and logs the slow ones (promise-style pool.query() calls only — the callback form isn't handled):

ts
import type { Integration } from '@buglapse/node'
import type { Pool } from 'pg'

export interface SlowQueryConfig {
    pool: Pool
    thresholdMs?: number
}

export function slowQueryIntegration(config: SlowQueryConfig): Integration {
    const { pool, thresholdMs = 1000 } = config

    return {
        name: 'slow-query',
        setup(client) {
            const originalQuery = pool.query

            pool.query = async function (this: Pool, ...args: any[]) {
                const sql = typeof args[0] === 'string' ? args[0] : args[0]?.text
                const span = client.startInactiveSpan(sql?.split(' ', 1)[0] ?? 'query', { op: 'db' })
                span.setData('db.statement', sql)

                const startedAt = Date.now()

                try {
                    return await originalQuery.apply(this, args)
                } catch (error) {
                    span.setStatus('error')
                    throw error
                } finally {
                    const duration = Date.now() - startedAt
                    span.end()

                    if (duration >= thresholdMs) {
                        client.log.warn('Slow query', { sql, duration })
                    }
                }
            } as typeof pool.query

            this.teardown = () => {
                pool.query = originalQuery
            }
        },
    }
}

When patching:

  • Never change the original behavior. Call through to the original and return (or re-throw) its result unchanged.
  • Use startInactiveSpan() for a call that's awaited while other work runs — it joins the active trace without becoming the active span itself. See Tracing → Leaf spans.
  • Skip the SDK's own traffic if you instrument HTTP: compare the request URL against client.transport.endpoint, or each event delivery reports itself.
  • Several integrations can wrap the same function. Disable them in reverse order of registration, or a later teardown() can restore a stale wrapper.

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, so you can branch on its class:

ts
import type { Integration } from '@buglapse/node'

export function ignoreClientErrorsIntegration(): Integration {
    let enabled = true

    return {
        name: 'ignore-client-errors',
        setup(client) {
            client.addPreprocessor((event, error) => {
                if (!enabled || event.type !== 'exception') return event

                const status = (error as { statusCode?: number } | undefined)?.statusCode
                return status !== undefined && status < 500 ? null : event
            })
        },
        teardown() {
            enabled = false
        },
    }
}

Preprocessors can't be unregistered — gate yours behind a flag that teardown() flips, as above. They run in registration order, and once one returns null the rest are skipped. For a one-off filter, call addPreprocessor(fn) from @buglapse/node directly instead.

Sending other event types ​

For a built-in event type with no dedicated method, there's a lower-level escape hatch:

ts
client.capture('hit', { name: 'export-finished' })

capture(type, payload) stamps the current tags, context and trace onto the payload and sends it through the same preprocessors and transport as everything else. payload must match that type's shape (see Events) — prefer the dedicated methods whenever one exists.

Debugging ​

With debug: true in init(), the client logs every integration registration (Integration "process-warnings" registered), every disable(), every captured event and every event a preprocessor drops. Check client.debug before adding extra logging of your own.