# bugcatch-sdk

Official JavaScript/TypeScript SDK for [BugCatch](https://admin.bugcatch.app/) - lightweight error tracking for web and Node.js applications.

## Features

- Automatic capture of uncaught errors and unhandled promise rejections
- Manual `captureException` and `captureMessage` APIs
- Structured logs - `captureLog()`, batched delivery, optional `console.*` auto-capture, trace-id correlation
- Tracing - `startTransaction()` / spans, waterfall-ready, auto `http.client` spans on `fetch`/XHR, an Express/Connect `tracingMiddleware()`, auto `db.query`/`cache.command` spans via `instrumentTypeOrm()`/`instrumentIoredis()`
- Session Replay - opt-in `rrweb` DOM recording, server-checked privacy allowlist, configurable text masking
- Breadcrumb trail: clicks, navigation, console calls
- User context and custom tags
- `beforeSend` hook to filter or modify events before sending
- Works in the browser (modern bundlers + CDN) and Node.js
- Zero runtime dependencies - ~15 KB minified

---

## Installation

```bash
npm install bugcatch-sdk
```

---

## Quick Start

```typescript
import BugCatch from 'bugcatch-sdk';

BugCatch.init({
  dsn: 'https://api.bugcatch.app/ingest/<projectId>?key=<sdkKey>',
  release: '1.0.0',
  environment: 'production',
});
```

The DSN is available on your project page in the BugCatch dashboard.

From this point on, all uncaught errors and unhandled promise rejections are captured automatically.

---

## Manual Captures

```typescript
// Capture an Error object
try {
  await processOrder(order);
} catch (err) {
  BugCatch.captureException(err, { orderId: order.id });
}

// Capture a plain message
BugCatch.captureMessage('Quota limit reached', 'warning', { used: 95 });
```

The second argument is "extra" - arbitrary context stored alongside the event - but three keys inside it are special-cased and hoisted to their real fields instead of staying buried under `extra`: `user`, `tags`, and `request`. This matters most on a concurrent Node server, where the global `BugCatch.setUser()` isn't safe per-request - passing `user` here per call (with `ip_address`, which the ingest API prioritizes over its own request-IP detection) is the reliable way to get per-request identity onto an event:

```typescript
BugCatch.captureException(err, {
  user: { id: req.user.id, ip_address: req.ip },
  tags: { route: req.path },
  request: { url: req.originalUrl, method: req.method, headers: { origin: req.headers.origin } },
  orderId: order.id, // anything else stays under `extra`
});
```

---

## User Context

```typescript
// After login
BugCatch.setUser({ id: '42', email: 'jane@example.com', username: 'jane' });

// After logout
BugCatch.clearUser();
```

---

## Tags

```typescript
BugCatch.setTag('plan', 'pro');
BugCatch.setTag('region', 'eu-west-1');
```

---

## Structured Logs

```typescript
BugCatch.captureLog('info', 'Checkout completed', { orderId: '4821', amountCents: 1999 });
BugCatch.captureLog('warn', 'Slow query detected', { durationMs: 1200 });
BugCatch.captureLog('error', 'Payment gateway timeout', { gateway: 'stripe', retries: 3 });
```

Log lines are **queued and batched**, not sent one request per call. They flush
automatically every `logsFlushInterval` ms (default 2000) or once
`logsBatchSize` lines are queued (default 20), whichever comes first. Call
`BugCatch.flushLogsNow()` to force an immediate flush (e.g. before a
short-lived script exits). In the browser, any lines still queued when the
tab closes are sent via `navigator.sendBeacon` on `pagehide`.

Levels: `'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'`.

### Trace correlation

Give every log line from the same request/trace the same `trace_id`, so the
BugCatch dashboard can show them together:

```typescript
// Per-call, safe under concurrency (e.g. inside Express middleware)
BugCatch.captureLog('info', 'Order created', { orderId }, req.headers['x-request-id']);

// Or set it once for everything that follows - simplest in a browser session,
// but a Node server handling concurrent requests should prefer the explicit
// per-call form above instead of this global setter.
BugCatch.setTraceId('req-abc-123');
BugCatch.captureLog('info', 'Step 1 complete');
BugCatch.captureLog('info', 'Step 2 complete');
BugCatch.clearTraceId();
```

### Auto-capturing console output

Set `autoCaptureLogs: true` to forward `console.log/info/warn/error/debug/trace`
to BugCatch as structured logs, in addition to whatever you send manually:

```typescript
BugCatch.init({
  dsn: '...',
  autoCaptureLogs: true,
});

console.log('This now also shows up in the Logs tab');
```

This is independent of `autoCaptureBreadcrumbs` (which records `console.warn`/
`console.error` as breadcrumbs attached to *error events*, not as searchable
log lines) - you can enable either, both, or neither.

### Pino / Winston (Node.js)

There's no bundled transport package - wiring your existing logger into
BugCatch is a few lines using `captureLog()` directly, so you don't take on a
dependency you don't need.

**Pino** (legacy synchronous transport - a plain object with `write()`):

```typescript
import pino from 'pino';
import BugCatch from 'bugcatch-sdk';

const pinoToBugCatch: Record<number, LogLevel> = {
  10: 'trace', 20: 'debug', 30: 'info', 40: 'warn', 50: 'error', 60: 'fatal',
};

const logger = pino({}, {
  write(msg: string) {
    const { level, msg: message, ...attributes } = JSON.parse(msg);
    BugCatch.captureLog(pinoToBugCatch[level] ?? 'info', message, attributes);
  },
});
```

**Winston** (a minimal custom transport - extends the built-in `EventEmitter`,
no `winston-transport` dependency needed for this level of integration):

```typescript
import { EventEmitter } from 'node:events';
import winston from 'winston';
import BugCatch from 'bugcatch-sdk';

class BugCatchTransport extends EventEmitter {
  log(info: { level: string; message: string; [key: string]: unknown }, callback: () => void) {
    const { level, message, ...attributes } = info;
    const mapped = level === 'warning' ? 'warn' : (level as LogLevel);
    BugCatch.captureLog(mapped, message, attributes);
    callback();
  }
}

const logger = winston.createLogger({
  transports: [new winston.transports.Console(), new BugCatchTransport()],
});
```

---

## Tracing

Traces are transactions with nested spans - the same shape you'd see as a
waterfall in the BugCatch dashboard.

```typescript
const txn = BugCatch.startTransaction('GET /api/orders/:id', 'http.server');

const dbSpan = txn.startChild('db.query', 'SELECT * FROM orders WHERE id = ?');
const rows = await db.query(sql);
dbSpan.finish();

txn.setHttpStatus(200);
txn.finish(); // queued for delivery - batched, not one request per transaction
```

Spans nest arbitrarily via `span.startChild(...)`. Nothing is sent until
`.finish()` is called on the transaction - an unfinished transaction is
simply never reported, not a leak.

### Express / Connect middleware

```typescript
import BugCatch from 'bugcatch-sdk';

app.use(BugCatch.tracingMiddleware());

app.get('/api/orders/:id', async (req, res) => {
  // Concurrency-safe: this request's own transaction, not global state
  const span = req.bugcatchTransaction.startChild('db.query', 'SELECT ...');
  const order = await db.query(sql);
  span.finish();
  res.json(order);
});
```

`tracingMiddleware()` starts an `http.server` transaction per request and
finishes it when the response ends. It attaches the transaction to
`req.bugcatchTransaction` - use that (not global state) in a server handling
concurrent requests.

### Auto-instrumented HTTP client spans

If `autoTrackRequests: true` and a transaction is set as "current" (either by
`tracingMiddleware()`, or manually via `BugCatch.setCurrentTransaction(txn)`),
every intercepted `fetch`/`XMLHttpRequest` call automatically gets an
`http.client` child span - no extra code at the call site.

```typescript
BugCatch.init({ dsn: '...', autoTrackRequests: true });

const txn = BugCatch.startTransaction('checkout flow', 'custom');
BugCatch.setCurrentTransaction(txn);

await fetch('/api/payment'); // auto-recorded as a child span
txn.finish();
```

> `setCurrentTransaction` is global mutable state - fine for a browser
> session or a script processing one thing at a time, but a Node server
> handling concurrent requests should prefer `req.bugcatchTransaction`
> (see the middleware above) over relying on this.

### Auto-instrumented database / cache spans

`instrumentTypeOrm()` and `instrumentIoredis()` patch a single choke point
each. TypeORM's `QueryRunner` export is an *interface*, not a class - there's
no `.prototype` to patch directly, and the concrete class is driver-specific
and unexported - so `instrumentTypeOrm()` takes the **`DataSource` instance**
instead, and discovers the shared query-runner prototype itself. ioredis is
simpler: `Redis.prototype.sendCommand()` is a real class every command
(including each call inside a pipeline) funnels through, so
`instrumentIoredis()` takes the **class**, not an instance. Call both once at
startup, after the `DataSource` has been initialized:

```typescript
import BugCatch from 'bugcatch-sdk';
import Redis from 'ioredis';

BugCatch.instrumentTypeOrm(dataSource); // an initialized TypeORM DataSource
BugCatch.instrumentIoredis(Redis);
```

Every query/command run afterward becomes a `db.query`/`cache.command` child
span on `currentTransaction` - no per-call-site code needed. Both are
duck-typed against `{ createQueryRunner }` / `{ prototype: { sendCommand } }`,
so neither `typeorm` nor `ioredis` is a dependency of this package. Each
returns an unpatch function if you need to undo it.

> Same global-mutable-state caveat as `setCurrentTransaction` above - fine for
> most Node processes, but a server handling concurrent requests should still
> set the transaction as "current" per-request (e.g. inside
> `tracingMiddleware()` or your own request-scoped setup) rather than once
> globally at startup.

If you'd rather not patch a class at all, wrap the call yourself the same way
as before:

```typescript
const span = req.bugcatchTransaction?.startChild('db.query', 'orders.findOne');
try {
  return await orderRepository.findOne({ where: { id } });
} finally {
  span?.finish();
}
```

---

## Session Replay

DOM recording via [rrweb](https://github.com/rrweb-io/rrweb), for watching what a
real user did in the dashboard's replay player. Browser-only, privacy-sensitive -
unlike sessions/RUM, **off by default**; a project opts in deliberately.

```typescript
BugCatch.init({
  dsn: '...',
  sessionReplay: true, // start recording immediately on init()
});
```

Or start/stop it manually - e.g. only after a user opts in, or only once you know
who they are:

```typescript
BugCatch.setUser({ id: 'user_123' });
BugCatch.startReplayNow();
// ...
BugCatch.stopReplayNow();
```

Every `captureException()` call while a recording is active is automatically
tagged with the recording's id, so the dashboard can jump straight from an error
to "what led to this."

### Privacy allowlist

A project can restrict recording to specific `user.id` values (BugCatch dashboard
→ project **Settings → Session Replay**). When configured, `startReplayNow()` -
and the `sessionReplay: true` init option - check the allowlist with the server
*before* `rrweb.record()` ever starts, so a user who isn't listed is never
recorded at all, not just dropped after the fact. The check fails *closed*: a
network error is treated as "not allowed." No allowlist configured means
everyone is recorded (the pre-existing default).

```typescript
// Decide yourself before attempting to start, e.g. to show a "recording"
// indicator only when it will actually happen:
if (await BugCatch.isReplayAllowed()) {
  BugCatch.startReplayNow();
}
```

`setUser()` must run before the allowlist check has a user id to look up - that's
why `sessionReplay: true` at `init()` time only actually starts recording once a
user is set; call `startReplayNow()` explicitly after `setUser()` if you need
recording to begin as soon as the user is known.

### Masking

`maskAllInputs` is always on - form inputs are masked unconditionally. It does
**not** cover rendered text: a table of salaries, an SSN shown as plain text,
etc. are recorded as-is unless matched by `replayMaskTextSelector`:

```typescript
BugCatch.init({
  dsn: '...',
  sessionReplay: true,
  replayMaskTextSelector: '[data-sensitive], .salary-cell',
});
```

Passed straight through to rrweb's own `maskTextSelector` option, so its
selector syntax and matching rules apply here too.

---

## Options

```typescript
BugCatch.init({
  // Required
  dsn: string;

  // Optional
  release?: string;               // App version e.g. "1.2.3"
  environment?: string;           // "production" | "staging" | ...
  debug?: boolean;                // Print SDK logs to console (default: false)
  maxBreadcrumbs?: number;        // Max breadcrumbs kept in memory (default: 100)
  autoCaptureErrors?: boolean;    // Auto-attach global error handlers (default: true)
  autoCaptureBreadcrumbs?: boolean; // Auto-capture clicks, nav, console (default: true)
  autoCaptureLogs?: boolean;      // Forward console.* calls as structured logs (default: false)
  logsFlushInterval?: number;     // Log batch flush interval, ms (default: 2000)
  logsBatchSize?: number;         // Flush immediately at this queue size (default: 20)
  tracesFlushInterval?: number;   // Transaction batch flush interval, ms (default: 2000)
  tracesBatchSize?: number;       // Flush immediately at this queue size (default: 10)
  autoTrackRequests?: boolean;    // Auto-track fetch/XHR (metrics + http.client spans) (default: false)
  sessionReplay?: boolean;        // Record DOM via rrweb for the dashboard's replay player (default: false)
  replayFlushInterval?: number;   // Replay chunk flush interval, ms (default: 10000)
  replayRequestTimeout?: number;  // Abort a stuck replay chunk upload after this many ms (default: 15000)
  replayMaskTextSelector?: string; // CSS selector for elements whose rendered text is masked in replay

  // Drop errors whose message matches any of these
  ignoreErrors?: Array<string | RegExp>;

  // Drop errors originating from matching script URLs
  ignoreUrls?: Array<string | RegExp>;

  // Modify or drop an event before it is sent. Return false to discard.
  beforeSend?: (event: EventPayload) => EventPayload | false;
});
```

---

## Server Metrics (Node.js)

`BugCatchServerReporter` periodically reports process memory, CPU and event loop lag to the BugCatch dashboard. Framework-agnostic - wire it into any lifecycle hook.

```typescript
import { BugCatchServerReporter } from 'bugcatch-sdk';

const reporter = new BugCatchServerReporter({
  dsn: process.env.BUGCATCH_DSN!,
});

reporter.start();
// reporter.stop() on shutdown
```

**NestJS:**

```typescript
@Injectable()
export class BugCatchMetricsService implements OnModuleInit, OnModuleDestroy {
  private readonly reporter = new BugCatchServerReporter({
    dsn: process.env.BUGCATCH_DSN!,
  });
  onModuleInit()    { this.reporter.start(); }
  onModuleDestroy() { this.reporter.stop();  }
}
```

**Options:**

```typescript
new BugCatchServerReporter({
  dsn: string;               // Required - same DSN used for error capture

  reportInterval?: number;   // How often to send a snapshot, in ms (default: 30_000)

  // Identifies which server/instance a snapshot came from - shown on the
  // dashboard and in threshold-alert emails. Useful when a project runs
  // multiple instances/pods behind a load balancer.
  instanceId?: string;       // (default: os.hostname())

  debug?: boolean;           // Print SDK logs to console (default: false)
});
```

---

## Framework Examples

### React

```tsx
// src/main.tsx
import BugCatch from 'bugcatch-sdk';

BugCatch.init({
  dsn: import.meta.env.VITE_BUGCATCH_DSN,
  release: import.meta.env.VITE_APP_VERSION,
  environment: import.meta.env.MODE,
});
```

**Error boundary:**

```tsx
class ErrorBoundary extends React.Component {
  componentDidCatch(error: Error) {
    BugCatch.captureException(error);
  }
  render() {
    return this.props.children;
  }
}
```

### Vue

```ts
// src/main.ts
import BugCatch from 'bugcatch-sdk';

BugCatch.init({ dsn: import.meta.env.VITE_BUGCATCH_DSN });

app.config.errorHandler = (err) => {
  BugCatch.captureException(err);
};
```

### Node.js / Express

```typescript
import BugCatch from 'bugcatch-sdk';

BugCatch.init({
  dsn: process.env.BUGCATCH_DSN!,
  release: process.env.npm_package_version,
  environment: process.env.NODE_ENV,
  autoCaptureBreadcrumbs: false, // no DOM in Node.js
});

// Error middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  BugCatch.captureException(err, { path: req.path, method: req.method });
  next(err);
});
```

### CommonJS

```javascript
const { BugCatch } = require('bugcatch-sdk');

BugCatch.init({ dsn: process.env.BUGCATCH_DSN });
```

---

## Breadcrumbs

Breadcrumbs are short records of what happened before an error. The SDK captures them automatically when `autoCaptureBreadcrumbs: true` (default):

| Source | Category | What is recorded |
|--------|----------|-----------------|
| DOM clicks | `ui.click` | Element tag, text, id/class |
| History navigation | `navigation` | URL navigated to |
| `console.warn` / `console.error` | `console` | Message text |

Add breadcrumbs manually:

```typescript
BugCatch.addBreadcrumb({
  timestamp: new Date().toISOString(),
  type: 'user',
  category: 'auth',
  message: 'User logged in',
  data: { method: 'google-oauth' },
});
```

---

## beforeSend

Use `beforeSend` to scrub sensitive data or drop specific events:

```typescript
BugCatch.init({
  dsn: '...',
  beforeSend(event) {
    // Drop network errors
    if (event.exception?.values?.[0]?.type === 'NetworkError') return false;

    // Scrub email from user context
    if (event.user) delete event.user.email;

    return event;
  },
});
```

---

## SPA / Hot-Reload Cleanup

```typescript
BugCatch.destroy(); // removes all listeners, flushes queued logs and transactions, resets singleton
```

---

## License

ISC
