npm package: @lfreiredev/eazycrumb
Description: Production-ready, dependency-free TypeScript library for in-memory breadcrumb tracking with a ring buffer
Latest version: 0.1.0

# EazyCrumb

Production-ready, dependency-free TypeScript library for in-memory breadcrumb tracking with a ring buffer.

## Features

- **Zero Dependencies**: No runtime dependencies, minimal footprint
- **TypeScript First**: Fully typed with comprehensive type definitions
- **SSR Safe**: DOM hooks gracefully degrade in Node.js/SSR environments
- **Auto-Capture**: Automatically track clicks, navigation, network requests, and errors
- **Ring Buffer**: Bounded memory usage with automatic overflow handling
- **Context & User Management**: Set context and user data once, automatically included in all events
- **Error Tracking**: Structured error breadcrumbs with `addError()`
- **Hooks**: Enrich and redact events with custom logic
- **Freeze/Unfreeze**: Preserve breadcrumb context when errors occur
- **Flexible**: Generic RingBuffer for any use case
- **Well Tested**: Comprehensive test suite

## Installation

```bash
npm install eazycrumb
```

## Quick Start

```typescript
import { createTracker, httpSink } from 'eazycrumb'

const tracker = createTracker({
  maxBreadcrumbs: 100,
  sinks: [httpSink({ endpoint: '/ingest' })],
})

// Set context and user (automatically added to all events)
tracker.setContext({ sessionId: 'abc123', environment: 'prod' })
tracker.setUser({ id: 456, email: 'user@example.com' })

// Enable auto-capture (clicks, navigation, network, errors)
const detach = tracker.attachDOM({
  clicks: true,
  navigation: true,
  network: true,
  errors: true
})

// Manual breadcrumbs (optional, auto-capture handles most cases)
tracker.add({ message: 'User logged in' })

// Get all breadcrumbs (includes auto-captured + manual)
const breadcrumbs = tracker.get()

// Cleanup when done
detach()
```

## Demo

A framework-free demo is available in `demo/`. Build the library and run a static server from the repo root:

```bash
npm run build
python3 -m http.server
```

Then open `http://localhost:8000/demo/`.

## API Reference

### Tracker

The main breadcrumb tracker with freeze/unfreeze capabilities and hook support.

### createTracker

Factory helper that wires sinks at construction time.

```typescript
import { createTracker, httpSink } from 'eazycrumb'

const tracker = createTracker({
  sinks: [httpSink({ endpoint: '/ingest' })],
})
```

#### Constructor

```typescript
const tracker = new Tracker(options?: TrackerOptions)
```

**Options:**
- `maxBreadcrumbs` (number, default: 100) - Maximum number of breadcrumbs to store
- `now` (function, default: Date.now) - Custom timestamp function
- `enrich` (function) - Hook to enrich events with additional data
- `redact` (function) - Hook to redact or filter events
- `strictMode` (boolean, default: false) - If true, errors in hooks will propagate

#### Methods

##### `add(input: BreadcrumbInput): void`

Adds a breadcrumb to the tracker. Does nothing if frozen.

```typescript
tracker.add({ message: 'Button clicked' })
tracker.add({
  message: 'Form submitted',
  data: { formId: 'registration' }
})
```

The `ts` timestamp is auto-injected if not provided.

##### `get(): BreadcrumbEvent[]`

Returns all breadcrumbs in chronological order (oldest to newest).

```typescript
const events = tracker.get()
```

##### `flush(): BreadcrumbEvent[]`

Returns all breadcrumbs and clears the buffer.

```typescript
const events = tracker.flush()
// Buffer is now empty
```

##### `clear(): void`

Clears all breadcrumbs from the buffer.

```typescript
tracker.clear()
```

##### `freeze(): void`

Freezes the tracker, preventing new breadcrumbs from being added. Useful for preserving error context.

```typescript
tracker.freeze()
tracker.add({ message: 'This will be ignored' })
```

##### `unfreeze(): void`

Unfreezes the tracker, allowing new breadcrumbs to be added.

```typescript
tracker.unfreeze()
tracker.add({ message: 'This will be recorded' })
```

##### `isFrozen(): boolean`

Returns whether the tracker is currently frozen.

```typescript
if (tracker.isFrozen()) {
  console.log('Tracker is frozen')
}
```

##### `setContext(context: Record<string, unknown>): void`

Sets or merges context data that will be included in all future breadcrumbs. Context is automatically added to `event.data.context`.

```typescript
tracker.setContext({ sessionId: 'abc123', environment: 'prod' })
tracker.add({ message: 'Button click' })
// Event will include { data: { context: { sessionId: 'abc123', environment: 'prod' } } }
```

##### `setUser(user: Record<string, unknown>): void`

Sets or merges user data that will be included in all future breadcrumbs. User data is automatically added to `event.data.user`.

```typescript
tracker.setUser({ id: 123, email: 'user@example.com' })
tracker.add({ message: 'API call' })
// Event will include { data: { user: { id: 123, email: 'user@example.com' } } }
```

##### `clearContext(): void`

Clears all context data. Future breadcrumbs will not include context unless set again.

```typescript
tracker.clearContext()
```

##### `clearUser(): void`

Clears all user data. Future breadcrumbs will not include user data unless set again.

```typescript
tracker.clearUser()
```

##### `addError(error: Error, extra?: Record<string, unknown>): void`

Adds a structured error breadcrumb to the tracker. The error is automatically formatted with name, message, and stack trace.

```typescript
try {
  riskyOperation()
} catch (error) {
  tracker.addError(error, { context: 'checkout-flow', step: 3 })
}
// Creates: {
//   message: 'Error: ...',
//   data: {
//     error: { name, message, stack },
//     context: 'checkout-flow',
//     step: 3
//   }
// }
```

##### `debug(enabled: boolean = true): void`

Enable or disable real-time debug logging for each breadcrumb.

```typescript
tracker.debug(true)
tracker.add({ message: 'Will log to console' })

tracker.debug(false)
tracker.add({ message: 'Will not log' })
```

##### `exportPayload(options?: ExportPayloadOptions): ExportPayload`

Export a JSON-ready payload for support tickets or debugging. Optionally enforce a max byte size.

```typescript
const payload = tracker.exportPayload({ maxBytes: 8_000 })
// { breadcrumbs: [...], meta: { generatedAt, truncated, sizeBytes } }
```

##### `attachDOM(options: DOMHooksOptions): DetachFunction`

Attaches DOM event listeners to automatically capture breadcrumbs. Returns a cleanup function.

```typescript
const detach = tracker.attachDOM({
  clicks: true,
  navigation: true,
  network: true,
  errors: true
})

// Later, cleanup:
detach()
```

See [DOM Auto-Capture](#dom-auto-capture) for full documentation.

### Network Instrumentation

#### `instrumentFetch(tracker, options): { uninstall(): void }`

Wraps `globalThis.fetch` to capture request outcomes as breadcrumbs. Captures method, sanitized URL, status/error, and duration. No bodies or headers are collected.

```typescript
import { Tracker, instrumentFetch } from 'eazycrumb'

const tracker = new Tracker()
const { uninstall } = instrumentFetch(tracker, { sampleRate: 1 })

// Later, to restore the original fetch:
uninstall()
```

**Options:**
- `sanitizeUrl(url)` - customize URL sanitization (default strips query/hash and credentials)
- `ignore(url, init)` - predicate to skip URLs
- `ignoreUrls` - list of strings/regex to skip
- `sampleRate` - 0..1 sampling per request
- `maxStringLength` - cap captured string fields (default: 200)
- `captureStack` - include error stacks (default: false)

### Sinks (Long-term Storage)

Sinks receive breadcrumbs and optionally flush them elsewhere. They are opt-in and bounded.

#### `httpSink(options)`

Batches breadcrumbs and POSTs them to an endpoint with retries and backoff. Uses `sendBeacon`/`keepalive` on pagehide/visibility changes.

```typescript
import { createTracker, httpSink } from 'eazycrumb'

const tracker = createTracker({
  sinks: [httpSink({ endpoint: '/ingest' })],
})
```

**Options:**
- `endpoint` - destination URL
- `flushIntervalMs` - periodic flush interval (default: 5000)
- `maxBatchSize` - max events per batch (default: 25)
- `maxQueueSize` - max queued events (default: 1000)
- `maxRetries` - retry attempts with backoff (default: 3)
- `timeoutMs` - request timeout (default: 8000)
- `headers` - request headers (default: JSON content-type)
- `onError` - callback on send failure with retry info
- `onDrop` - callback when events are dropped

#### Properties

- `length` (number) - Current number of breadcrumbs in the buffer
- `capacity` (number) - Maximum capacity of the buffer

### RingBuffer

Generic circular buffer implementation. Used internally by Tracker but also exported for custom use cases.

```typescript
import { RingBuffer } from 'eazycrumb'

const buffer = new RingBuffer<string>(5)

buffer.push('a')
buffer.push('b')
buffer.push('c')

const items = buffer.toArray() // ['a', 'b', 'c']

buffer.clear()
buffer.resize(10)
```

## DOM Auto-Capture

EazyCrumb can automatically capture user interactions, navigation, network requests, and errors without manual instrumentation.

### attachDOM(options)

Attaches DOM event listeners to automatically capture breadcrumbs. Only works in browser environments (gracefully degrades in Node.js/SSR).

```typescript
const detach = tracker.attachDOM({
  clicks: true,       // Track clicks on interactive elements
  navigation: true,   // Track route/page changes
  network: true,      // Track fetch/XHR requests
  errors: true        // Track uncaught errors
})

// Later, cleanup:
detach()
```

**Options:**
- `clicks` (boolean, default: false) - Track clicks on buttons, links, inputs, etc.
- `navigation` (boolean, default: false) - Track history.pushState, popstate, hashchange
- `network` (boolean, default: false) - Track fetch and XMLHttpRequest calls
- `errors` (boolean, default: false) - Track uncaught errors and unhandled promise rejections

### What Gets Captured

**Clicks:**
```
"Click [button.submit-btn] Submit Order"
"Click [a#nav-home] Home"
```
- Smart filtering: only tracks interactive elements (buttons, links, inputs)
- Captures: element selector, text content, tag name
- Uses event delegation for performance

**Navigation:**
```
"Navigation /cart → /checkout"
"Navigation /home → /dashboard"
```
- Captures: from/to paths, navigation type (push/pop/replace/hash)
- Works with SPAs (React Router, Vue Router, etc.)

**Network:**
```
"POST /api/orders → 201 (234ms)"
"GET /api/user → 500 (1432ms)"
"DELETE /api/items/123 → Failed (45ms)"
```
- Captures: method, URL, status code, duration, success/failure
- Works with both fetch and XMLHttpRequest

**Errors:**
```
"Error: Cannot read property 'id' of undefined"
"Error: Network timeout"
```
- Captures: error name, message, stack trace, filename, line/column
- Automatically calls `tracker.addError()`
- Handles both sync errors and unhandled promise rejections

### Complete Example

```typescript
import { Tracker } from 'eazycrumb'

// Initialize tracker
const tracker = new Tracker({ maxBreadcrumbs: 50 })

// Set context
tracker.setContext({
  sessionId: crypto.randomUUID(),
  appVersion: '2.0.0'
})

// Enable auto-capture
const detach = tracker.attachDOM({
  clicks: true,
  navigation: true,
  network: true,
  errors: true
})

// User interacts with app:
// - Clicks button → breadcrumb: "Click [button.login] Login"
// - Navigates → breadcrumb: "Navigation / → /dashboard"
// - API call → breadcrumb: "GET /api/user → 200 (123ms)"
// - Error occurs → breadcrumb: "Error: ..."

// When error occurs, freeze and send breadcrumbs
window.addEventListener('error', () => {
  tracker.freeze()

  sendToErrorService({
    breadcrumbs: tracker.get(),
    timestamp: Date.now()
  })
})

// Cleanup on unmount
detach()
```

### SSR Safety

DOM hooks are completely SSR-safe and will gracefully no-op in Node.js environments:

```typescript
// Works in both browser and Node.js
const tracker = new Tracker()
const detach = tracker.attachDOM({ clicks: true })
// In browser: attaches listeners
// In Node.js: returns no-op cleanup function
```

## Usage Examples

### Set Context and User (Recommended)

The easiest way to add session and user data to all breadcrumbs:

```typescript
const tracker = new Tracker()

// Set context once - it's automatically included in all events
tracker.setContext({
  sessionId: 'sess_abc123',
  environment: 'production',
  version: '1.2.3'
})

// Set user data when user logs in
tracker.setUser({
  id: 456,
  email: 'user@example.com',
  plan: 'premium'
})

// All breadcrumbs automatically include context and user
tracker.add({ message: 'Button click' })
tracker.add({ message: 'API call', data: { endpoint: '/api/data' } })

// Events will have structure:
// {
//   ts: 1234567890,
//   message: 'Button click',
//   data: {
//     context: { sessionId: 'sess_abc123', environment: 'production', version: '1.2.3' },
//     user: { id: 456, email: 'user@example.com', plan: 'premium' }
//   }
// }
```

### Track Errors

Use `addError()` for structured error tracking:

```typescript
const tracker = new Tracker()

tracker.setContext({ feature: 'checkout' })
tracker.setUser({ id: 123 })

try {
  await processPayment()
} catch (error) {
  // Error is automatically formatted with name, message, and stack
  tracker.addError(error, {
    step: 'payment-processing',
    amount: 99.99
  })
}

// Error breadcrumb includes:
// - Structured error object
// - Extra data (step, amount)
// - Automatic context and user data
```

### Update Context Dynamically

Context can be updated throughout the session:

```typescript
const tracker = new Tracker()

// Initial context
tracker.setContext({ sessionId: 'abc', page: '/home' })
tracker.add({ message: 'Home page loaded' })

// Navigate to new page - update context
tracker.setContext({ page: '/checkout' })
tracker.add({ message: 'Checkout page loaded' })

// Context is merged, not replaced
// Both events have sessionId, but different page values
```

### Handle User Logout

Clear user data when users log out:

```typescript
const tracker = new Tracker()

// User logs in
tracker.setUser({ id: 123, email: 'user@example.com' })
tracker.add({ message: 'User logged in' })

// User logs out
tracker.clearUser()
tracker.add({ message: 'User logged out' })

// New user logs in
tracker.setUser({ id: 456, email: 'other@example.com' })
tracker.add({ message: 'New user logged in' })
```

### Enrich Events with Session Context (Advanced)

For more complex enrichment logic, use the `enrich` hook:

```typescript
const tracker = new Tracker({
  enrich: (event) => ({
    ...event,
    data: {
      ...(event.data || {}),
      sessionId: 'sess_abc123',
      userId: getCurrentUserId(),
      environment: 'production'
    }
  })
})

tracker.add({ message: 'Page view' })
// Event will include sessionId, userId, and environment
```

### Redact Sensitive Data

```typescript
const tracker = new Tracker({
  redact: (event) => {
    // Drop events containing passwords
    if (event.message.toLowerCase().includes('password')) {
      return null
    }

    // Redact email addresses
    if (event.data?.email) {
      return {
        ...event,
        data: { ...event.data, email: '[REDACTED]' }
      }
    }

    return event
  }
})

tracker.add({ message: 'User login', data: { email: 'user@example.com' } })
// Email will be redacted

tracker.add({ message: 'Password reset initiated' })
// Event will be dropped
```

### Preserve Error Context

```typescript
const tracker = new Tracker({ maxBreadcrumbs: 50 })

// Track user actions
tracker.add({ message: 'App loaded' })
tracker.add({ message: 'User clicked button' })

// Error occurs - freeze to preserve context
try {
  riskyOperation()
} catch (error) {
  tracker.add({ message: `Error: ${error.message}` })
  tracker.freeze()

  // Send breadcrumbs to error tracking service
  sendToErrorTracker({
    error,
    breadcrumbs: tracker.get()
  })

  // Clean up and resume
  tracker.clear()
  tracker.unfreeze()
}
```

### Custom Timestamp for Testing

```typescript
let mockTime = 1000

const tracker = new Tracker({
  now: () => mockTime++
})

tracker.add({ message: 'Event 1' }) // ts: 1000
tracker.add({ message: 'Event 2' }) // ts: 1001
tracker.add({ message: 'Event 3' }) // ts: 1002
```

### Hook Execution Order

Hooks and internal enrichment are executed in the following order:

1. Timestamp injection (if `ts` not provided)
2. **Internal enrichment** (merge context/user from `setContext`/`setUser`)
3. User's enrich hook (transforms the event)
4. User's redact hook (returns event or null to drop)
5. Push to buffer (if not dropped)

```typescript
const tracker = new Tracker({
  now: () => Date.now(),
  enrich: (event) => {
    // This runs AFTER context/user are merged
    // event.data.context and event.data.user are already present
    return { ...event, data: { ...event.data, customField: 'value' } }
  },
  redact: (event) => {
    // Filter sensitive events
    if (event.data?.sensitive) return null
    return event
  }
})

tracker.setContext({ sessionId: 'abc' })
tracker.setUser({ id: 123 })
tracker.add({ message: 'Event' })
// Event will have context, user, and customField
```

### Error Handling

By default, errors in hooks are caught silently and the event is dropped. This prevents breadcrumb tracking from crashing your application.

```typescript
// Non-strict mode (default) - errors are silent
const tracker = new Tracker({
  enrich: (event) => {
    throw new Error('Oops!')
  }
})

tracker.add({ message: 'Test' })
// Event is silently dropped, no error thrown
```

Enable strict mode for debugging:

```typescript
// Strict mode - errors propagate
const tracker = new Tracker({
  strictMode: true,
  enrich: (event) => {
    throw new Error('Oops!')
  }
})

tracker.add({ message: 'Test' })
// Throws: Error: Oops!
```

### Buffer Overflow

When the buffer is full, the oldest breadcrumbs are automatically overwritten.

```typescript
const tracker = new Tracker({ maxBreadcrumbs: 3 })

tracker.add({ message: 'Event 1' })
tracker.add({ message: 'Event 2' })
tracker.add({ message: 'Event 3' })
tracker.add({ message: 'Event 4' }) // Overwrites Event 1

const events = tracker.get()
// ['Event 2', 'Event 3', 'Event 4']
```

### Using the RingBuffer Directly

The `RingBuffer` is exported for custom use cases:

```typescript
import { RingBuffer } from 'eazycrumb'

// Track last 10 API requests
const apiLog = new RingBuffer<{ url: string; status: number }>(10)

apiLog.push({ url: '/api/users', status: 200 })
apiLog.push({ url: '/api/posts', status: 404 })

const requests = apiLog.toArray()

// Resize the buffer
apiLog.resize(20) // Larger capacity
apiLog.resize(5)  // Smaller capacity (keeps newest 5 items)

// Clear all items
apiLog.clear()
```

## Types

### BreadcrumbEvent

```typescript
interface BreadcrumbEvent {
  ts: number        // Unix timestamp in milliseconds
  message: string   // Human-readable description
  data?: unknown    // Optional structured data
}
```

### BreadcrumbInput

```typescript
type BreadcrumbInput = Omit<BreadcrumbEvent, 'ts'> & { ts?: number }
```

Input type for `add()`. The `ts` field is optional and will be auto-injected if not provided.

### TrackerOptions

```typescript
interface TrackerOptions {
  maxBreadcrumbs?: number     // Default: 100
  now?: () => number          // Default: Date.now
  enrich?: (event: BreadcrumbEvent) => BreadcrumbEvent
  redact?: (event: BreadcrumbEvent) => BreadcrumbEvent | null
  strictMode?: boolean        // Default: false
}
```

## Best Practices

1. **Set appropriate `maxBreadcrumbs`**: Choose a value based on your memory constraints and use case. 100 is a good default.

2. **Use enrich for context**: Add session IDs, user IDs, and other context that helps debug issues.

3. **Use redact for PII**: Remove sensitive data like passwords, credit cards, and personal information.

4. **Freeze on errors**: When an error occurs, freeze the tracker to preserve the breadcrumb trail leading up to the error.

5. **Don't track everything**: Be selective about what you track. Too many breadcrumbs create noise.

6. **Custom `now()` for testing**: Use a custom timestamp function in tests for deterministic results.

7. **Use strict mode for debugging**: Enable strict mode during development to catch hook errors early.

## License

MIT

## Contributing

Contributions are welcome! Please open an issue or submit a pull request.

## Development

```bash
# Install dependencies
npm install

# Run tests
npm test

# Run tests in watch mode
npm run test:watch

# Type check
npm run typecheck

# Build
npm run build
```