diff --git a/README.md b/README.md index 8f32369..9cac71c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Initialization params: | `context` | object | optional | Any data you want to pass with every message. | | `disableGlobalErrorsHandling` | boolean | optional | Do not initialize global errors handling | | `beforeSend` | function(event) => event | optional | This Method allows you to filter any data you don't want sending to Hawk | +| `breadcrumbs` | `false` or object | optional | Pass `false` to disable. Pass options object to configure (see [Breadcrumbs](#breadcrumbs)). Default: enabled. | ## Usage @@ -152,6 +153,87 @@ Available fields: } ``` +### Breadcrumbs + +Breadcrumbs track events leading up to an error, providing context for debugging. Same API as [@hawk.so/javascript](https://www.npmjs.com/package/@hawk.so/javascript) (add, get, clear); in Node there is no automatic tracking of fetch/navigation/clicks, only manual breadcrumbs. + +#### Default configuration + +By default, breadcrumbs are enabled (custom breadcrumbs only): + +```js +HawkCatcher.init({ + token: 'INTEGRATION_TOKEN' + // breadcrumbs enabled by default +}); +``` + +#### Disabling breadcrumbs + +To disable breadcrumbs entirely: + +```js +HawkCatcher.init({ + token: 'INTEGRATION_TOKEN', + breadcrumbs: false +}); +``` + +#### Custom configuration + +Configure breadcrumbs (same options as JS where applicable): + +```js +HawkCatcher.init({ + token: 'INTEGRATION_TOKEN', + breadcrumbs: { + maxBreadcrumbs: 20, + beforeBreadcrumb: (breadcrumb, hint) => { + if (breadcrumb.category === 'auth' && breadcrumb.data?.userId) { + return null; // Discard + } + return breadcrumb; + } + } +}); +``` + +#### Breadcrumbs options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `maxBreadcrumbs` | `number` | `15` | Maximum number of breadcrumbs to store. When the limit is reached, oldest breadcrumbs are removed (FIFO). | +| `beforeBreadcrumb` | `function` | `undefined` | Hook called before each breadcrumb is stored. Receives `(breadcrumb, hint)` and can return modified breadcrumb, `null` to discard it, or the original breadcrumb. | + +#### Manual breadcrumbs + +Add custom breadcrumbs manually. Breadcrumbs accumulate in a buffer and are attached to every event until explicitly cleared via `HawkCatcher.breadcrumbs.clear()`: + +```js +HawkCatcher.breadcrumbs.add({ + type: 'logic', + category: 'auth', + message: 'User logged in', + level: 'info', + data: { userId: '123' } +}); +``` + +#### Breadcrumb methods + +Same as in JS catcher: + +```js +// Add a breadcrumb +HawkCatcher.breadcrumbs.add(breadcrumb, hint); + +// Get current breadcrumbs +const breadcrumbs = HawkCatcher.breadcrumbs.get(); + +// Clear all breadcrumbs +HawkCatcher.breadcrumbs.clear(); +``` + ### Sensitive data filtering You can filter any data that you don't want to send to Hawk. Use the `beforeSend()` hook for that reason. diff --git a/example/example.js b/example/example.js index 51a6d16..0a224a7 100644 --- a/example/example.js +++ b/example/example.js @@ -16,7 +16,7 @@ * HawkCatcher.send(err); * } */ -const HawkCatcher = require('../dist/src/index').default; +const HawkCatcher = require('../dist/cjs/src/index.js').default; /** * Initialize Hawk catcher diff --git a/package.json b/package.json index 83af9ff..ef3562c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hawk.so/nodejs", - "version": "3.2.0", + "version": "3.3.0", "description": "Node.js catcher for Hawk", "license": "AGPL-3.0-only", "engines": { diff --git a/src/index.ts b/src/index.ts index b09bd89..407c453 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import process from 'process'; import type { EventContext, AffectedUser, + Breadcrumb, EncodedIntegrationToken, DecodedIntegrationToken, EventData, @@ -12,6 +13,7 @@ import type { Json } from '@hawk.so/types'; import EventPayload from './modules/event.js'; +import { BreadcrumbManager, type BreadcrumbInput, type BreadcrumbHint } from './modules/breadcrumbs.js'; import type { AxiosResponse } from 'axios'; import axios from 'axios'; import { VERSION } from './version.js'; @@ -32,6 +34,11 @@ let _instance: Catcher; * @copyright CodeX */ class Catcher { + /** + * Whether breadcrumbs are enabled (false when settings.breadcrumbs === false) + */ + public readonly breadcrumbsEnabled: boolean; + /** * Type is a family name of a catcher */ @@ -76,6 +83,15 @@ class Catcher { this.context = settings.context ?? undefined; this.release = settings.release ?? undefined; this.beforeSend = settings.beforeSend?.bind(undefined); + this.breadcrumbsEnabled = settings.breadcrumbs !== false; + + if (this.breadcrumbsEnabled) { + BreadcrumbManager.getInstance().init( + typeof settings.breadcrumbs === 'object' && settings.breadcrumbs !== null + ? settings.breadcrumbs + : {} + ); + } if (!this.token) { throw new Error('Integration Token is missed. You can get it on https://hawk.so at Project Settings.'); @@ -227,7 +243,9 @@ class Catcher { */ private formatAndSend(err: Error, context?: EventContext, user?: AffectedUser): void { const eventPayload = new EventPayload(err); - let payload: EventData = { + const breadcrumbs = this.breadcrumbsEnabled ? BreadcrumbManager.getInstance().getBreadcrumbs() : []; + + let payload = { title: eventPayload.getTitle(), type: eventPayload.getType(), backtrace: eventPayload.getBacktrace(), @@ -235,7 +253,8 @@ class Catcher { context: this.getContext(context), release: this.release, catcherVersion: Catcher.getVersion(), - }; + breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : null, + } as EventData; /** * Filter sensitive data @@ -319,8 +338,44 @@ export default class HawkCatcher { return _instance.send(error, context, user); } } + + /** + * Breadcrumbs API (same as in JS catcher: add, get, clear). + * No-op when breadcrumbs were disabled (breadcrumbs: false). + */ + public static get breadcrumbs(): BreadcrumbsAPI { + return { + add: (breadcrumb, hint) => { + if (_instance !== undefined && _instance.breadcrumbsEnabled) { + BreadcrumbManager.getInstance().addBreadcrumb(breadcrumb, hint); + } + }, + get: () => + _instance !== undefined && _instance.breadcrumbsEnabled + ? BreadcrumbManager.getInstance().getBreadcrumbs() + : [], + clear: () => { + if (_instance !== undefined && _instance.breadcrumbsEnabled) { + BreadcrumbManager.getInstance().clear(); + } + }, + }; + } +} + +/** + * Breadcrumbs API - same surface as in @hawk.so/javascript (add, get, clear) + */ +export interface BreadcrumbsAPI { + /** Add a breadcrumb to the buffer (attached to every event until cleared) */ + add(breadcrumb: BreadcrumbInput, hint?: BreadcrumbHint): void; + + /** Get current breadcrumbs snapshot (oldest to newest) */ + get(): Breadcrumb[]; + + /** Clear all breadcrumbs */ + clear(): void; } -export type { - HawkNodeJSInitialSettings -}; +export type { BreadcrumbInput, BreadcrumbHint, BreadcrumbsOptions } from './modules/breadcrumbs.js'; +export type { HawkNodeJSInitialSettings } from '../types/index.js'; diff --git a/src/modules/breadcrumbs.ts b/src/modules/breadcrumbs.ts new file mode 100644 index 0000000..07f59fb --- /dev/null +++ b/src/modules/breadcrumbs.ts @@ -0,0 +1,143 @@ +/** + * @file Breadcrumbs module - chronological trail of events before an error. Custom breadcrumbs only (HawkCatcher.breadcrumbs.add()). Possible future auto-capture: outgoing HTTP, unhandledRejection/uncaughtException, console.log/intercept, DB query hooks. + */ +import type { Breadcrumb } from '@hawk.so/types'; + +/** + * Default maximum number of breadcrumbs to store + */ +const DEFAULT_MAX_BREADCRUMBS = 15; + +/** + * Hint object passed to beforeBreadcrumb callback (same concept as in JS catcher; in Node no event/response yet, use for custom context) + */ +export interface BreadcrumbHint { + [key: string]: unknown; +} + +/** + * Configuration options for breadcrumbs (same shape as @hawk.so/javascript; no trackFetch/trackNavigation/trackClicks in Node) + */ +export interface BreadcrumbsOptions { + /** + * Maximum number of breadcrumbs to store (FIFO). When the limit is reached, oldest are removed. + * @default 15 + */ + maxBreadcrumbs?: number; + + /** + * Hook called before each breadcrumb is stored. Return null to discard. Return modified breadcrumb to store it. + * @param breadcrumb - Breadcrumb to store (can be mutated and returned) + * @param hint - Optional context (e.g. for filtering) + * @returns Modified breadcrumb to store, or null to discard + */ + beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | null; +} + +/** + * Breadcrumb input - timestamp optional (auto-generated if omitted). Same as @hawk.so/javascript BreadcrumbInput. + */ +// eslint-disable-next-line jsdoc/require-jsdoc -- type alias documented above +export type BreadcrumbInput = Omit & { timestamp?: Breadcrumb['timestamp'] }; + +/** + * Internal breadcrumbs options (all fields set during init from BreadcrumbsOptions) + */ +interface InternalBreadcrumbsOptions { + /** Maximum number of breadcrumbs to keep (FIFO) */ + maxBreadcrumbs: number; + + /** Optional hook before storing each breadcrumb */ + beforeBreadcrumb?: BreadcrumbsOptions['beforeBreadcrumb']; +} + +/** + * Manages breadcrumb buffer and add/get/clear API + */ +export class BreadcrumbManager { + private static instance: BreadcrumbManager | null = null; + + private readonly breadcrumbs: Breadcrumb[] = []; + + private options: InternalBreadcrumbsOptions = { + maxBreadcrumbs: DEFAULT_MAX_BREADCRUMBS, + }; + + private isInitialized = false; + + /** + * Private constructor for singleton + */ + private constructor() {} + + /** + * Get singleton instance (created on first call). + * @returns The shared BreadcrumbManager + */ + public static getInstance(): BreadcrumbManager { + if (BreadcrumbManager.instance === null) { + BreadcrumbManager.instance = new BreadcrumbManager(); + } + + return BreadcrumbManager.instance; + } + + /** + * Initialize with options. Call once when HawkCatcher.init() runs. + * @param options - Configuration (maxBreadcrumbs, beforeBreadcrumb) + */ + public init(options: BreadcrumbsOptions = {}): void { + if (this.isInitialized) { + return; + } + + this.options = { + maxBreadcrumbs: options.maxBreadcrumbs ?? DEFAULT_MAX_BREADCRUMBS, + beforeBreadcrumb: options.beforeBreadcrumb, + }; + + this.isInitialized = true; + } + + /** + * Add a breadcrumb. Timestamp is set to Date.now() if omitted. + * @param breadcrumb - Breadcrumb data (type, message, category, level, data) + * @param hint - Optional hint for beforeBreadcrumb callback + */ + public addBreadcrumb(breadcrumb: BreadcrumbInput, hint?: BreadcrumbHint): void { + const bc: Breadcrumb = { + ...breadcrumb, + timestamp: breadcrumb.timestamp ?? Date.now(), + }; + + if (this.options.beforeBreadcrumb) { + const result = this.options.beforeBreadcrumb(bc, hint); + + if (result === null) { + return; + } + + Object.assign(bc, result); + } + + this.breadcrumbs.push(bc); + + if (this.breadcrumbs.length > this.options.maxBreadcrumbs) { + this.breadcrumbs.shift(); + } + } + + /** + * Snapshot of current breadcrumbs (oldest to newest) + */ + public getBreadcrumbs(): Breadcrumb[] { + return [...this.breadcrumbs]; + } + + /** + * Clear all breadcrumbs (e.g. after sending an event) + */ + public clear(): void { + this.breadcrumbs.length = 0; + } +} diff --git a/types/index.ts b/types/index.ts index 46c218e..88c5c31 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,4 +1,7 @@ import type { EventData, NodeJSAddons } from '@hawk.so/types'; +import type { BreadcrumbsOptions } from '../src/modules/breadcrumbs.js'; + +export type { BreadcrumbsOptions }; /** * Initial settings object @@ -34,6 +37,12 @@ export interface HawkNodeJSInitialSettings { * This options still allow you send events manually */ disableGlobalErrorsHandling?: boolean; + + /** + * Pass false to disable breadcrumbs. Pass options object to configure maxBreadcrumbs and beforeBreadcrumb hook. + * @default { maxBreadcrumbs: 15 } + */ + breadcrumbs?: false | BreadcrumbsOptions; } /**