@errorgap/bun 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # @errorgap/bun
2
2
 
3
- Bun notifier for [Errorgap](https://errorgap.com). Errors only in v1.
3
+ Bun notifier for [Errorgap](https://errorgap.com). Captures errors with
4
+ source-aware backtraces, nested `Error.cause` chains, breadcrumbs, structured
5
+ logs, and APM transactions.
6
+
7
+ Because Bun executes TypeScript directly, backtrace frames point at the
8
+ original source, so each frame ships a source excerpt (file, line, function,
9
+ and surrounding lines) read straight from disk — the dashboard renders
10
+ highlighted source with no build step or source maps.
4
11
 
5
12
  Requires Bun 1.0+.
6
13
 
@@ -44,6 +51,48 @@ try {
44
51
  `notify` returns a `DeliveryResult` (`status`, `body`, `error`, `queued`).
45
52
  The SDK never throws.
46
53
 
54
+ Nested errors are captured automatically: pass a chain built with
55
+ `new Error("…", { cause })` and each cause's type/message lands in
56
+ `context.causes` while its frames merge into the backtrace.
57
+
58
+ ## Breadcrumbs
59
+
60
+ ```ts
61
+ Errorgap.addBreadcrumb("received GET /orders", { category: "http" });
62
+ ```
63
+
64
+ Recorded breadcrumbs attach to every notice as `context.breadcrumbs`.
65
+
66
+ ## Structured logs
67
+
68
+ ```ts
69
+ await Errorgap.log("payment gateway timeout", "error", { source: "payments" });
70
+ ```
71
+
72
+ Levels are `trace < debug < info < warn < error < fatal`; anything below
73
+ `minimumLogLevel` is dropped client-side.
74
+
75
+ ## Performance (APM)
76
+
77
+ ```ts
78
+ await Errorgap.trackTransaction(
79
+ { method: "GET", path: "/orders/{orderId}", pathRaw: "/orders/123" },
80
+ async (spans) => {
81
+ spans.database("SELECT * FROM orders WHERE id = 123", 4.2, { function: "OrderRepo.get" });
82
+ spans.external(88, { function: "PaymentGateway.charge" });
83
+ await handleRequest();
84
+ },
85
+ );
86
+
87
+ await Errorgap.trackJob("ReceiptJob", async (spans) => {
88
+ spans.database("INSERT INTO receipts …", 6);
89
+ }, { queue: "mailers" });
90
+ ```
91
+
92
+ `path` is the normalized route template used for grouping; `path_raw` is the
93
+ concrete URL. Both helpers time the callback and deliver on completion. Use
94
+ `Errorgap.notifyTransaction(...)` for a pre-measured transaction.
95
+
47
96
  ## Configuration reference
48
97
 
49
98
  | Option | Default | Notes |
@@ -53,10 +102,16 @@ The SDK never throws.
53
102
  | `projectId` | `ERRORGAP_PROJECT_ID` | |
54
103
  | `apiKey` | `ERRORGAP_API_KEY` | Sent as `x-errorgap-project-key` |
55
104
  | `environment` | `NODE_ENV` or `"production"` | |
56
- | `release` | | |
105
+ | `release` | `ERRORGAP_RELEASE` | |
106
+ | `rootDirectory` | `process.cwd()` | Relativizes backtrace source paths |
57
107
  | `async` | `true` | Fire-and-forget delivery |
58
108
  | `logger` | `console` | Pass `null` to silence |
59
109
  | `filterKeys` | `["password", "token", ...]` | Substring, case-insensitive |
110
+ | `apmEnabled` | `true` | Deliver APM transactions |
111
+ | `apmSampleRate` | `1.0` | Fraction (0..1) of transactions delivered |
112
+ | `logsEnabled` | `true` | Deliver structured logs |
113
+ | `minimumLogLevel` | `info` | Drop logs below this level |
114
+ | `maxBreadcrumbs` | `25` | Breadcrumbs retained per notice |
60
115
  | `captureGlobals` | `true` | Install process error hooks |
61
116
 
62
117
  ## Graceful flush
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@errorgap/bun",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Bun notifier for Errorgap error tracking.",
5
5
  "license": "MIT",
6
6
  "author": "Errorgap",
@@ -9,7 +9,13 @@
9
9
  "type": "git",
10
10
  "url": "https://github.com/errorgaphq/errorgap-bun.git"
11
11
  },
12
- "keywords": ["errorgap", "bun", "error-tracking", "exceptions", "monitoring"],
12
+ "keywords": [
13
+ "errorgap",
14
+ "bun",
15
+ "error-tracking",
16
+ "exceptions",
17
+ "monitoring"
18
+ ],
13
19
  "type": "module",
14
20
  "main": "./src/index.ts",
15
21
  "module": "./src/index.ts",
@@ -21,7 +27,11 @@
21
27
  },
22
28
  "./package.json": "./package.json"
23
29
  },
24
- "files": ["src", "README.md", "LICENSE"],
30
+ "files": [
31
+ "src",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
25
35
  "engines": {
26
36
  "bun": ">=1.0.0"
27
37
  },
package/src/apm.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { Configuration } from "./configuration.js";
2
+
3
+ export interface Span {
4
+ kind: string;
5
+ sql?: string;
6
+ file?: string;
7
+ line?: number;
8
+ function?: string;
9
+ durationMs: number;
10
+ }
11
+
12
+ export interface SpanLocation {
13
+ file?: string;
14
+ line?: number;
15
+ function?: string;
16
+ }
17
+
18
+ export function databaseSpan(sql: string, durationMs: number, location: SpanLocation = {}): Span {
19
+ return { kind: "db", sql: normalizeSql(sql), durationMs, ...location };
20
+ }
21
+
22
+ export function externalSpan(durationMs: number, location: SpanLocation = {}): Span {
23
+ return { kind: "http", durationMs, ...location };
24
+ }
25
+
26
+ /** Collects spans recorded while a transaction or job is in flight. */
27
+ export class SpanCollector {
28
+ private spans: Span[] = [];
29
+
30
+ add(span: Span): void {
31
+ this.spans.push(span);
32
+ }
33
+
34
+ database(sql: string, durationMs: number, location: SpanLocation = {}): void {
35
+ this.add(databaseSpan(sql, durationMs, location));
36
+ }
37
+
38
+ external(durationMs: number, location: SpanLocation = {}): void {
39
+ this.add(externalSpan(durationMs, location));
40
+ }
41
+
42
+ snapshot(): Span[] {
43
+ return [...this.spans];
44
+ }
45
+ }
46
+
47
+ export interface Transaction {
48
+ /** "web" for HTTP interactions, "job" for background work. */
49
+ kind?: string;
50
+ method?: string;
51
+ /** Normalized route template used for grouping, e.g. `/orders/{id}`. */
52
+ path?: string;
53
+ /** Concrete path for a single request, e.g. `/orders/123`. */
54
+ pathRaw?: string;
55
+ statusCode?: number;
56
+ durationMs: number;
57
+ environment?: string;
58
+ /** ISO-8601. Defaults to now. */
59
+ occurredAt?: string;
60
+ spans?: Span[];
61
+ jobClass?: string;
62
+ queue?: string;
63
+ }
64
+
65
+ export function transactionPayload(
66
+ transaction: Transaction,
67
+ configuration: Configuration,
68
+ ): Record<string, unknown> {
69
+ const payload: Record<string, unknown> = {
70
+ kind: transaction.kind ?? "web",
71
+ duration_ms: transaction.durationMs,
72
+ environment: transaction.environment ?? configuration.environment,
73
+ occurred_at: transaction.occurredAt ?? new Date().toISOString(),
74
+ spans: (transaction.spans ?? []).map(spanPayload),
75
+ };
76
+ if (transaction.method !== undefined) payload.method = transaction.method;
77
+ if (transaction.path !== undefined) payload.path = transaction.path;
78
+ if (transaction.pathRaw !== undefined) payload.path_raw = transaction.pathRaw;
79
+ if (transaction.statusCode !== undefined) payload.status_code = transaction.statusCode;
80
+ if (transaction.jobClass !== undefined) payload.job_class = transaction.jobClass;
81
+ if (transaction.queue !== undefined) payload.queue = transaction.queue;
82
+ return payload;
83
+ }
84
+
85
+ function spanPayload(span: Span): Record<string, unknown> {
86
+ const payload: Record<string, unknown> = {
87
+ kind: span.kind,
88
+ duration_ms: span.durationMs,
89
+ };
90
+ if (span.sql !== undefined) payload.sql = span.sql;
91
+ if (span.file !== undefined) payload.file = span.file;
92
+ if (span.line !== undefined) payload.line = span.line;
93
+ if (span.function !== undefined) payload.fn_name = span.function;
94
+ return payload;
95
+ }
96
+
97
+ /** Strip literals so query shapes aggregate: '…' and numbers become ?. */
98
+ export function normalizeSql(sql: string): string {
99
+ return sql
100
+ .replace(/'(?:''|[^'])*'/g, "?")
101
+ .replace(/\b\d+(?:\.\d+)?\b/g, "?")
102
+ .replace(/\s+/g, " ")
103
+ .trim();
104
+ }
package/src/backtrace.ts CHANGED
@@ -1,19 +1,44 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+
4
+ export interface SourceExcerpt {
5
+ start_line: number;
6
+ lines: string[];
7
+ }
8
+
1
9
  export interface BacktraceFrame {
2
10
  file?: string;
3
11
  line?: number;
12
+ column?: number;
4
13
  function?: string;
5
14
  in_app?: boolean;
6
15
  index: number;
16
+ source?: SourceExcerpt;
7
17
  }
8
18
 
9
19
  const V8_AT = /^\s*at\s+(?:(.*?)\s+\()?(.+?)(?::(\d+))?(?::(\d+))?\)?$/;
10
20
 
11
- export function parseBacktrace(error: Error): BacktraceFrame[] {
21
+ const SOURCE_CONTEXT_RADIUS = 6;
22
+ const MAX_SOURCE_LINE_CHARS = 400;
23
+ const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024;
24
+
25
+ /** Cache of file line arrays, keyed by resolved path, for one notice build. */
26
+ const fileCache = new Map<string, string[] | null>();
27
+
28
+ /**
29
+ * Parse a V8-style `Error.stack` into Errorgap frames. Because Bun executes
30
+ * TypeScript directly, frame locations point at the original source files, so
31
+ * each frame's source excerpt is read straight from disk.
32
+ */
33
+ export function parseBacktrace(error: Error, rootDirectory?: string): BacktraceFrame[] {
12
34
  const stack = typeof error.stack === "string" ? error.stack : "";
13
35
  if (!stack) return [];
36
+ fileCache.clear();
37
+
14
38
  const lines = stack.split("\n");
15
39
  const frames: BacktraceFrame[] = [];
16
40
  let index = 0;
41
+
17
42
  for (const raw of lines) {
18
43
  const trimmed = raw.trim();
19
44
  if (!trimmed.startsWith("at ")) continue;
@@ -21,15 +46,25 @@ export function parseBacktrace(error: Error): BacktraceFrame[] {
21
46
  if (!m) continue;
22
47
  const fnName = m[1];
23
48
  const location = (m[2] ?? "").replace(/^file:\/\//, "");
24
- const lineNumber = m[3] ? Number(m[3]) : undefined;
25
- frames.push({
26
- file: location,
27
- line: lineNumber,
49
+ const line = m[3] ? Number(m[3]) : undefined;
50
+ const column = m[4] ? Number(m[4]) : undefined;
51
+
52
+ const frame: BacktraceFrame = {
53
+ file: displayPath(location, rootDirectory),
54
+ line,
55
+ column,
28
56
  function: fnName || undefined,
29
57
  in_app: isInApp(location),
30
58
  index: index++,
31
- });
59
+ };
60
+
61
+ const source = sourceExcerpt(location, line, rootDirectory);
62
+ if (source) frame.source = source;
63
+
64
+ frames.push(frame);
32
65
  }
66
+
67
+ fileCache.clear();
33
68
  return frames;
34
69
  }
35
70
 
@@ -40,3 +75,53 @@ function isInApp(file: string): boolean {
40
75
  if (file.startsWith("bun:")) return false; // Bun internal modules
41
76
  return true;
42
77
  }
78
+
79
+ /** Strip the app root prefix for a cleaner in-app display path. */
80
+ function displayPath(file: string, root?: string): string {
81
+ if (!file) return file;
82
+ if (root) {
83
+ const normalized = root.endsWith("/") ? root : root + "/";
84
+ if (file.startsWith(normalized)) return file.slice(normalized.length);
85
+ }
86
+ return file;
87
+ }
88
+
89
+ function sourceExcerpt(
90
+ file: string,
91
+ line: number | undefined,
92
+ root?: string,
93
+ ): SourceExcerpt | undefined {
94
+ if (!file || !line || line < 1) return undefined;
95
+ if (file.startsWith("node:") || file.startsWith("bun:")) return undefined;
96
+
97
+ const contents = readSourceLines(resolvePath(file, root));
98
+ if (!contents || line > contents.length) return undefined;
99
+
100
+ const startLine = Math.max(1, line - SOURCE_CONTEXT_RADIUS);
101
+ const endLine = Math.min(contents.length, line + SOURCE_CONTEXT_RADIUS);
102
+ return {
103
+ start_line: startLine,
104
+ lines: contents.slice(startLine - 1, endLine).map((l) => l.slice(0, MAX_SOURCE_LINE_CHARS)),
105
+ };
106
+ }
107
+
108
+ function resolvePath(file: string, root?: string): string {
109
+ if (isAbsolute(file)) return file;
110
+ if (root) return join(root, file);
111
+ return file;
112
+ }
113
+
114
+ function readSourceLines(path: string): string[] | null {
115
+ if (fileCache.has(path)) return fileCache.get(path) ?? null;
116
+ let lines: string[] | null = null;
117
+ try {
118
+ const stat = statSync(path);
119
+ if (stat.isFile() && stat.size <= MAX_SOURCE_FILE_BYTES) {
120
+ lines = readFileSync(path, "utf8").split(/\r?\n/);
121
+ }
122
+ } catch {
123
+ lines = null;
124
+ }
125
+ fileCache.set(path, lines);
126
+ return lines;
127
+ }
@@ -0,0 +1,42 @@
1
+ export interface Breadcrumb {
2
+ message: string;
3
+ category?: string;
4
+ metadata?: Record<string, unknown>;
5
+ timestamp: string;
6
+ }
7
+
8
+ export interface BreadcrumbInput {
9
+ category?: string;
10
+ metadata?: Record<string, unknown>;
11
+ }
12
+
13
+ /**
14
+ * Fixed-size ring of recent app events (requests, queries, jobs) attached to
15
+ * every notice as `context.breadcrumbs`.
16
+ */
17
+ export class BreadcrumbBuffer {
18
+ private crumbs: Breadcrumb[] = [];
19
+
20
+ constructor(private capacity: number) {}
21
+
22
+ add(message: string, input: BreadcrumbInput = {}): void {
23
+ if (this.capacity <= 0) return;
24
+ this.crumbs.push({
25
+ message,
26
+ ...(input.category !== undefined ? { category: input.category } : {}),
27
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
28
+ timestamp: new Date().toISOString(),
29
+ });
30
+ if (this.crumbs.length > this.capacity) {
31
+ this.crumbs.splice(0, this.crumbs.length - this.capacity);
32
+ }
33
+ }
34
+
35
+ clear(): void {
36
+ this.crumbs = [];
37
+ }
38
+
39
+ snapshot(): Breadcrumb[] {
40
+ return [...this.crumbs];
41
+ }
42
+ }
package/src/client.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Configuration } from "./configuration.js";
2
2
  import { buildNotice, type NoticeContext, type NoticePayload } from "./notice.js";
3
+ import { transactionPayload, type Transaction } from "./apm.js";
4
+ import { logLevelRank, normalizeLogLevel } from "./logs.js";
3
5
  import { VERSION } from "./version.js";
4
6
 
5
7
  export interface DeliveryResult {
@@ -9,6 +11,13 @@ export interface DeliveryResult {
9
11
  queued?: boolean;
10
12
  }
11
13
 
14
+ export interface LogOptions {
15
+ source?: string;
16
+ environment?: string;
17
+ occurredAt?: string;
18
+ sync?: boolean;
19
+ }
20
+
12
21
  export class Client {
13
22
  private pending = new Set<Promise<unknown>>();
14
23
 
@@ -26,23 +35,80 @@ export class Client {
26
35
  this.configuration.validate();
27
36
  const err = coerceError(error);
28
37
  const notice = buildNotice(err, this.configuration, options);
38
+ return this.submit("notices", notice, options.sync);
39
+ } catch (exception) {
40
+ this.log(exception);
41
+ return { error: exception };
42
+ }
43
+ }
29
44
 
30
- if (options.sync || !this.configuration.async) {
31
- const p = this.deliver(notice);
32
- this.track(p);
33
- return await p;
34
- }
45
+ /** Deliver an APM transaction (HTTP interaction or background job). */
46
+ async notifyTransaction(
47
+ transaction: Transaction,
48
+ options: { sync?: boolean } = {},
49
+ ): Promise<DeliveryResult> {
50
+ try {
51
+ this.configuration.validate();
52
+ } catch (exception) {
53
+ this.log(exception);
54
+ return { error: exception };
55
+ }
56
+ if (!this.configuration.apmEnabled) {
57
+ return { status: 204 };
58
+ }
59
+ const rate = this.configuration.apmSampleRate;
60
+ if (!(rate >= 1 || (rate > 0 && Math.random() < rate))) {
61
+ return { status: 204 };
62
+ }
63
+ return this.submit("transactions", transactionPayload(transaction, this.configuration), options.sync);
64
+ }
35
65
 
36
- this.track(this.deliver(notice));
37
- return { queued: true, status: 202 };
66
+ /** Deliver a structured log line. */
67
+ async notifyLog(
68
+ message: string,
69
+ level = "info",
70
+ options: LogOptions = {},
71
+ ): Promise<DeliveryResult> {
72
+ try {
73
+ this.configuration.validate();
38
74
  } catch (exception) {
39
75
  this.log(exception);
40
76
  return { error: exception };
41
77
  }
78
+ const normalizedLevel = normalizeLogLevel(level);
79
+ if (
80
+ !this.configuration.logsEnabled ||
81
+ logLevelRank(normalizedLevel) <
82
+ logLevelRank(normalizeLogLevel(this.configuration.minimumLogLevel))
83
+ ) {
84
+ return { status: 204 };
85
+ }
86
+ const payload: Record<string, unknown> = {
87
+ message,
88
+ level: normalizedLevel,
89
+ environment: options.environment ?? this.configuration.environment,
90
+ occurred_at: options.occurredAt ?? new Date().toISOString(),
91
+ };
92
+ if (options.source) payload.source = options.source;
93
+ return this.submit("logs", payload, options.sync);
94
+ }
95
+
96
+ private async submit(
97
+ resource: string,
98
+ payload: unknown,
99
+ sync?: boolean,
100
+ ): Promise<DeliveryResult> {
101
+ if (sync || !this.configuration.async) {
102
+ const p = this.deliver(resource, payload);
103
+ this.track(p);
104
+ return await p;
105
+ }
106
+ this.track(this.deliver(resource, payload));
107
+ return { queued: true, status: 202 };
42
108
  }
43
109
 
44
- async deliver(notice: NoticePayload): Promise<DeliveryResult> {
45
- const url = noticesUrl(this.configuration);
110
+ async deliver(resource: string, payload: unknown): Promise<DeliveryResult> {
111
+ const url = resourceUrl(this.configuration, resource);
46
112
  const headers: Record<string, string> = {
47
113
  "content-type": "application/json",
48
114
  "user-agent": `errorgap-bun/${VERSION}`,
@@ -54,7 +120,7 @@ export class Client {
54
120
  const response = await fetch(url, {
55
121
  method: "POST",
56
122
  headers,
57
- body: JSON.stringify(notice),
123
+ body: JSON.stringify(payload),
58
124
  });
59
125
  const body = await safeBody(response);
60
126
  return { status: response.status, body };
@@ -79,18 +145,19 @@ export class Client {
79
145
  private log(exception: unknown): void {
80
146
  const logger = this.configuration.logger;
81
147
  if (!logger) return;
82
- const message = exception instanceof Error
83
- ? `${exception.name}: ${exception.message}`
84
- : String(exception);
148
+ const message =
149
+ exception instanceof Error
150
+ ? `${exception.name}: ${exception.message}`
151
+ : String(exception);
85
152
  logger.warn(`[errorgap] ${message}`);
86
153
  }
87
154
  }
88
155
 
89
- function noticesUrl(configuration: Configuration): string {
156
+ function resourceUrl(configuration: Configuration, resource: string): string {
90
157
  const base = configuration.endpoint.endsWith("/")
91
158
  ? configuration.endpoint.slice(0, -1)
92
159
  : configuration.endpoint;
93
- return `${base}/api/projects/${configuration.projectSlug}/notices`;
160
+ return `${base}/api/projects/${configuration.projectSlug}/${resource}`;
94
161
  }
95
162
 
96
163
  async function safeBody(response: Response): Promise<string> {
@@ -12,6 +12,18 @@ export interface ConfigurationInput {
12
12
  async?: boolean;
13
13
  logger?: Logger | null;
14
14
  filterKeys?: string[];
15
+ /** Root directory used to relativize backtrace source paths. Default cwd. */
16
+ rootDirectory?: string;
17
+ /** Enable APM transaction delivery. Default true. */
18
+ apmEnabled?: boolean;
19
+ /** Fraction (0..1) of transactions to deliver. Default 1. */
20
+ apmSampleRate?: number;
21
+ /** Enable structured log delivery. Default true. */
22
+ logsEnabled?: boolean;
23
+ /** Drop logs below this level (trace<debug<info<warn<error<fatal). Default "info". */
24
+ minimumLogLevel?: string;
25
+ /** Number of breadcrumbs retained and attached to notices. Default 25. */
26
+ maxBreadcrumbs?: number;
15
27
  }
16
28
 
17
29
  const DEFAULT_FILTER_KEYS = [
@@ -34,6 +46,12 @@ export class Configuration {
34
46
  async: boolean;
35
47
  logger: Logger | null;
36
48
  filterKeys: string[];
49
+ rootDirectory: string;
50
+ apmEnabled: boolean;
51
+ apmSampleRate: number;
52
+ logsEnabled: boolean;
53
+ minimumLogLevel: string;
54
+ maxBreadcrumbs: number;
37
55
 
38
56
  constructor(input: ConfigurationInput = {}) {
39
57
  this.endpoint = input.endpoint ?? process.env.ERRORGAP_ENDPOINT ?? "http://127.0.0.1:3030";
@@ -41,10 +59,16 @@ export class Configuration {
41
59
  this.projectId = input.projectId ?? process.env.ERRORGAP_PROJECT_ID;
42
60
  this.apiKey = input.apiKey ?? process.env.ERRORGAP_API_KEY;
43
61
  this.environment = input.environment ?? process.env.NODE_ENV ?? "production";
44
- this.release = input.release;
62
+ this.release = input.release ?? process.env.ERRORGAP_RELEASE;
45
63
  this.async = input.async ?? true;
46
64
  this.logger = input.logger === undefined ? console : input.logger;
47
65
  this.filterKeys = input.filterKeys ?? [...DEFAULT_FILTER_KEYS];
66
+ this.rootDirectory = input.rootDirectory ?? process.cwd();
67
+ this.apmEnabled = input.apmEnabled ?? true;
68
+ this.apmSampleRate = clampRate(input.apmSampleRate ?? 1);
69
+ this.logsEnabled = input.logsEnabled ?? true;
70
+ this.minimumLogLevel = input.minimumLogLevel ?? "info";
71
+ this.maxBreadcrumbs = Math.max(0, Math.trunc(input.maxBreadcrumbs ?? 25));
48
72
  }
49
73
 
50
74
  validate(): void {
@@ -56,3 +80,8 @@ export class Configuration {
56
80
  }
57
81
  }
58
82
  }
83
+
84
+ function clampRate(rate: number): number {
85
+ if (!Number.isFinite(rate)) return 1;
86
+ return Math.min(1, Math.max(0, rate));
87
+ }
package/src/handlers.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Client } from "./client.js";
2
+ import type { BreadcrumbBuffer } from "./breadcrumbs.js";
2
3
 
3
4
  let installed = false;
4
5
  let uncaughtHandler: ((err: Error) => void) | null = null;
@@ -8,19 +9,23 @@ let rejectionHandler: ((reason: unknown) => void) | null = null;
8
9
  * Hook Bun's `uncaughtException` and `unhandledRejection` events on the
9
10
  * Node-compatible `process` global.
10
11
  */
11
- export function installProcessHandlers(client: Client): void {
12
+ export function installProcessHandlers(client: Client, breadcrumbs?: BreadcrumbBuffer): void {
12
13
  if (installed) return;
13
14
  installed = true;
15
+ const snapshot = () => breadcrumbs?.snapshot() ?? [];
16
+
14
17
  uncaughtHandler = (err: Error) => {
15
18
  void client.notify(err, {
16
19
  sync: true,
17
20
  context: { source: "uncaughtException" },
21
+ breadcrumbs: snapshot(),
18
22
  });
19
23
  };
20
24
  rejectionHandler = (reason: unknown) => {
21
25
  void client.notify(reason, {
22
26
  sync: true,
23
27
  context: { source: "unhandledRejection" },
28
+ breadcrumbs: snapshot(),
24
29
  });
25
30
  };
26
31
  process.on("uncaughtException", uncaughtHandler);
package/src/index.ts CHANGED
@@ -1,19 +1,26 @@
1
- import { Client, type DeliveryResult } from "./client.js";
1
+ import { Client, type DeliveryResult, type LogOptions } from "./client.js";
2
2
  import { Configuration, type ConfigurationInput } from "./configuration.js";
3
3
  import { installProcessHandlers, uninstallProcessHandlers } from "./handlers.js";
4
+ import { BreadcrumbBuffer, type BreadcrumbInput } from "./breadcrumbs.js";
5
+ import { SpanCollector, type Transaction } from "./apm.js";
4
6
  import type { NoticeContext } from "./notice.js";
5
7
  import { VERSION } from "./version.js";
6
8
 
7
9
  export type { ConfigurationInput, Logger } from "./configuration.js";
8
- export type { BacktraceFrame } from "./backtrace.js";
9
- export type { NoticeContext, NoticePayload } from "./notice.js";
10
- export type { DeliveryResult } from "./client.js";
10
+ export type { BacktraceFrame, SourceExcerpt } from "./backtrace.js";
11
+ export type { NoticeContext, NoticePayload, NoticeCause } from "./notice.js";
12
+ export type { DeliveryResult, LogOptions } from "./client.js";
13
+ export type { Breadcrumb, BreadcrumbInput } from "./breadcrumbs.js";
14
+ export type { Span, SpanLocation, Transaction } from "./apm.js";
11
15
  export { Client } from "./client.js";
12
16
  export { Configuration } from "./configuration.js";
17
+ export { SpanCollector, databaseSpan, externalSpan, normalizeSql } from "./apm.js";
18
+ export { BreadcrumbBuffer } from "./breadcrumbs.js";
13
19
  export { VERSION };
14
20
 
15
21
  let configuration = new Configuration();
16
22
  let client = new Client(configuration);
23
+ let breadcrumbs = new BreadcrumbBuffer(configuration.maxBreadcrumbs);
17
24
 
18
25
  export interface InitOptions extends ConfigurationInput {
19
26
  /** Install process.uncaughtException / unhandledRejection handlers. */
@@ -24,8 +31,9 @@ function init(options: InitOptions = {}): void {
24
31
  const { captureGlobals = true, ...rest } = options;
25
32
  configuration = new Configuration(rest);
26
33
  client.configure(configuration);
34
+ breadcrumbs = new BreadcrumbBuffer(configuration.maxBreadcrumbs);
27
35
  if (captureGlobals) {
28
- installProcessHandlers(client);
36
+ installProcessHandlers(client, breadcrumbs);
29
37
  } else {
30
38
  uninstallProcessHandlers();
31
39
  }
@@ -35,7 +43,80 @@ function notify(
35
43
  error: unknown,
36
44
  options: NoticeContext & { sync?: boolean } = {},
37
45
  ): Promise<DeliveryResult> {
38
- return client.notify(error, options);
46
+ return client.notify(error, { breadcrumbs: breadcrumbs.snapshot(), ...options });
47
+ }
48
+
49
+ /** Record a diagnostic breadcrumb attached to subsequent notices. */
50
+ function addBreadcrumb(message: string, input: BreadcrumbInput = {}): void {
51
+ breadcrumbs.add(message, input);
52
+ }
53
+
54
+ function clearBreadcrumbs(): void {
55
+ breadcrumbs.clear();
56
+ }
57
+
58
+ /** Deliver a structured log line at the given level. */
59
+ function log(message: string, level = "info", options: LogOptions = {}): Promise<DeliveryResult> {
60
+ return client.notifyLog(message, level, options);
61
+ }
62
+
63
+ /** Deliver an APM transaction (HTTP interaction or background job). */
64
+ function notifyTransaction(
65
+ transaction: Transaction,
66
+ options: { sync?: boolean } = {},
67
+ ): Promise<DeliveryResult> {
68
+ return client.notifyTransaction(transaction, options);
69
+ }
70
+
71
+ /**
72
+ * Time an HTTP interaction and deliver it as a transaction. The callback
73
+ * receives a `SpanCollector` for recording DB/HTTP spans.
74
+ */
75
+ async function trackTransaction<T>(
76
+ meta: Omit<Transaction, "durationMs" | "spans" | "kind"> & { kind?: string },
77
+ operation: (spans: SpanCollector) => Promise<T> | T,
78
+ ): Promise<T> {
79
+ const spans = new SpanCollector();
80
+ const startedAt = new Date().toISOString();
81
+ const start = Date.now();
82
+ try {
83
+ return await operation(spans);
84
+ } finally {
85
+ void notifyTransaction({
86
+ kind: meta.kind ?? "web",
87
+ ...meta,
88
+ occurredAt: meta.occurredAt ?? startedAt,
89
+ durationMs: Date.now() - start,
90
+ spans: spans.snapshot(),
91
+ });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Time a background job and deliver it as a `job` transaction. The callback
97
+ * receives a `SpanCollector` for recording DB/HTTP spans.
98
+ */
99
+ async function trackJob<T>(
100
+ jobClass: string,
101
+ operation: (spans: SpanCollector) => Promise<T> | T,
102
+ meta: { queue?: string; environment?: string } = {},
103
+ ): Promise<T> {
104
+ const spans = new SpanCollector();
105
+ const startedAt = new Date().toISOString();
106
+ const start = Date.now();
107
+ try {
108
+ return await operation(spans);
109
+ } finally {
110
+ void notifyTransaction({
111
+ kind: "job",
112
+ jobClass,
113
+ queue: meta.queue ?? "default",
114
+ environment: meta.environment,
115
+ occurredAt: startedAt,
116
+ durationMs: Date.now() - start,
117
+ spans: spans.snapshot(),
118
+ });
119
+ }
39
120
  }
40
121
 
41
122
  function flush(): Promise<void> {
@@ -53,10 +134,26 @@ function getClient(): Client {
53
134
  export const Errorgap = {
54
135
  init,
55
136
  notify,
137
+ addBreadcrumb,
138
+ clearBreadcrumbs,
139
+ log,
140
+ notifyTransaction,
141
+ trackTransaction,
142
+ trackJob,
56
143
  flush,
57
144
  configuration: getConfiguration,
58
145
  client: getClient,
59
146
  VERSION,
60
147
  };
61
148
 
62
- export { init, notify, flush };
149
+ export {
150
+ init,
151
+ notify,
152
+ addBreadcrumb,
153
+ clearBreadcrumbs,
154
+ log,
155
+ notifyTransaction,
156
+ trackTransaction,
157
+ trackJob,
158
+ flush,
159
+ };
package/src/logs.ts ADDED
@@ -0,0 +1,44 @@
1
+ export function normalizeLogLevel(level: string): string {
2
+ switch (level.trim().toLowerCase()) {
3
+ case "warning":
4
+ case "warn":
5
+ return "warn";
6
+ case "err":
7
+ case "severe":
8
+ case "critical":
9
+ return "error";
10
+ case "notice":
11
+ return "info";
12
+ case "fine":
13
+ case "finer":
14
+ case "finest":
15
+ return "debug";
16
+ case "trace":
17
+ case "debug":
18
+ case "info":
19
+ case "error":
20
+ case "fatal":
21
+ return level.trim().toLowerCase();
22
+ default:
23
+ return "info";
24
+ }
25
+ }
26
+
27
+ export function logLevelRank(level: string): number {
28
+ switch (level) {
29
+ case "trace":
30
+ return 0;
31
+ case "debug":
32
+ return 10;
33
+ case "info":
34
+ return 20;
35
+ case "warn":
36
+ return 30;
37
+ case "error":
38
+ return 40;
39
+ case "fatal":
40
+ return 50;
41
+ default:
42
+ return 20;
43
+ }
44
+ }
package/src/notice.ts CHANGED
@@ -1,13 +1,22 @@
1
1
  import type { Configuration } from "./configuration.js";
2
2
  import { parseBacktrace, type BacktraceFrame } from "./backtrace.js";
3
+ import type { Breadcrumb } from "./breadcrumbs.js";
3
4
  import { filterParams } from "./filter.js";
4
5
  import { VERSION } from "./version.js";
5
6
 
7
+ const MAX_CAUSE_DEPTH = 10;
8
+
6
9
  export interface NoticeContext {
7
10
  context?: Record<string, unknown>;
8
11
  environment?: Record<string, unknown>;
9
12
  session?: Record<string, unknown>;
10
13
  params?: Record<string, unknown>;
14
+ breadcrumbs?: Breadcrumb[];
15
+ }
16
+
17
+ export interface NoticeCause {
18
+ type: string;
19
+ message: string;
11
20
  }
12
21
 
13
22
  export interface NoticePayload {
@@ -29,6 +38,9 @@ export function buildNotice(
29
38
  configuration: Configuration,
30
39
  options: NoticeContext = {},
31
40
  ): NoticePayload {
41
+ const causes = collectCauses(error);
42
+ const breadcrumbs = options.breadcrumbs ?? [];
43
+
32
44
  return {
33
45
  project_id: configuration.projectId,
34
46
  received_at: new Date().toISOString(),
@@ -36,7 +48,7 @@ export function buildNotice(
36
48
  {
37
49
  type: errorType(error),
38
50
  message: String(error.message ?? ""),
39
- backtrace: parseBacktrace(error),
51
+ backtrace: flattenBacktrace(error, configuration.rootDirectory),
40
52
  },
41
53
  ],
42
54
  context: {
@@ -46,6 +58,8 @@ export function buildNotice(
46
58
  release: configuration.release,
47
59
  runtime: "bun",
48
60
  runtime_version: tryBunVersion(),
61
+ ...(causes.length > 0 ? { causes } : {}),
62
+ ...(breadcrumbs.length > 0 ? { breadcrumbs } : {}),
49
63
  ...(options.context ?? {}),
50
64
  },
51
65
  environment: options.environment ?? {},
@@ -54,6 +68,39 @@ export function buildNotice(
54
68
  };
55
69
  }
56
70
 
71
+ /**
72
+ * Walk `error.cause` (ES2022) and merge each cause's frames into a single
73
+ * backtrace, re-indexing so the dashboard renders the full chain in one view.
74
+ */
75
+ function flattenBacktrace(error: Error, rootDirectory: string): BacktraceFrame[] {
76
+ const frames: BacktraceFrame[] = [];
77
+ let index = 0;
78
+ for (const link of errorChain(error)) {
79
+ for (const frame of parseBacktrace(link, rootDirectory)) {
80
+ frames.push({ ...frame, index: index++ });
81
+ }
82
+ }
83
+ return frames;
84
+ }
85
+
86
+ function collectCauses(error: Error): NoticeCause[] {
87
+ return errorChain(error)
88
+ .slice(1)
89
+ .map((link) => ({ type: errorType(link), message: String(link.message ?? "") }));
90
+ }
91
+
92
+ function errorChain(error: Error): Error[] {
93
+ const chain: Error[] = [];
94
+ let current: unknown = error;
95
+ const seen = new Set<unknown>();
96
+ while (current instanceof Error && !seen.has(current) && chain.length < MAX_CAUSE_DEPTH) {
97
+ seen.add(current);
98
+ chain.push(current);
99
+ current = (current as { cause?: unknown }).cause;
100
+ }
101
+ return chain;
102
+ }
103
+
57
104
  function tryBunVersion(): string | undefined {
58
105
  try {
59
106
  // @ts-ignore — Bun global is defined when running under Bun.
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.0";
1
+ export const VERSION = "0.2.0";