@bigbertha/sdk 0.4.1 → 0.5.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,12 +1,15 @@
1
1
  # @bigbertha/sdk
2
2
 
3
- Error reporting for bertha. Browser, Node, edge and Next.js, with no dependencies and
4
- source maps matched by debug id rather than by file path.
3
+ Error reporting for bertha. Browser, React, Node, edge and Next.js, with no dependencies
4
+ and source maps matched by debug id rather than by file path.
5
5
 
6
- - About 4.4 kB gzipped in the browser (`npm run size` enforces the budget).
6
+ - About 5.8 kB gzipped in the browser (`npm run size` enforces the budget).
7
7
  - Batches events, backs off when bertha says so, and sends what's queued as the page or
8
8
  process goes away.
9
9
  - Scrubs emails, bearer tokens and secret-looking query values by default.
10
+ - Each event says how it surfaced (an uncaught error, a rejection, a boundary, a call),
11
+ where (the page or request) and in what state, and links the page's requests to the
12
+ server errors they caused by W3C `traceparent`.
10
13
  - The build plugins give each bundle a debug id, upload its map and delete the `.map`
11
14
  files, so maps never ship to browsers.
12
15
 
@@ -55,11 +58,13 @@ import { withBertha } from "@bigbertha/sdk/next/config"
55
58
  export default withBertha({ /* your config */ })
56
59
  ```
57
60
 
58
- `withBertha` options: `release`, `keepMaps`, `disableUpload`. It removes the maps under
61
+ `withBertha` options: `release`, `keepMaps`, `disableUpload`, `recordDeploy`. It removes the maps under
59
62
  `.next/static` (the ones browsers can fetch) and keeps the server's, which aren't served
60
63
  and which Vercel's build output can need. `keepMaps` takes `true`, `false` or a function
61
64
  of the path inside `.next`. It also stamps the environment, so a Vercel preview reports
62
- as staging.
65
+ as staging. On Vercel it records the deploy after the upload, with Vercel's commit sha,
66
+ message and author, so the release page knows what went out (`recordDeploy: false`
67
+ turns that off).
63
68
 
64
69
  For your own options (`beforeSend`, `tags`, `sampleRate`), call `init` from
65
70
  `@bigbertha/sdk/browser` in `instrumentation-client.ts` instead of importing
@@ -82,10 +87,52 @@ import { bertha } from "@bigbertha/sdk/vite"
82
87
  export default defineConfig({ plugins: [bertha()] })
83
88
  ```
84
89
 
90
+ ### What a browser event carries
91
+
92
+ - **The page:** its URL as the event's request, scrubbed like any URL.
93
+ - **How it surfaced:** `onerror` or `onunhandledrejection`, neither caught by the app.
94
+ - **Page state:** viewport and pixel ratio, online, tab visible, how the page was opened
95
+ (navigate, reload, back/forward, prerender), how long it had been open, whether the
96
+ browser is automated (`navigator.webdriver`), the referrer, language, timezone and
97
+ connection type, and the Next.js version when there is one.
98
+ - **The visitor's IP**, which bertha reads from the request and stores in full.
99
+ `sendIp: false` turns it off; an IP given to `setUser` is used instead.
100
+ - **Breadcrumbs:** the page load with its navigation type, restores from the
101
+ back-forward cache, the tab hiding and showing, route changes, clicks, fetch and XHR
102
+ with status and duration, and `console.log`, `info`, `warn` and `error`. Turn any off
103
+ with `breadcrumbs: { console: false }` and so on.
104
+ - **A trace id**, one per page load. Same-origin fetch and XHR requests carry a
105
+ `traceparent` header with it, so a server error a request caused lists the page's
106
+ error beside it. For an API on another origin, list it in `tracePropagationTargets`
107
+ (URL prefixes or patterns) and allow the `traceparent` header in its CORS:
108
+
109
+ ```ts
110
+ init({ key, url, tracePropagationTargets: ["https://api.example.com"] })
111
+ ```
112
+
113
+ Frames in browser extensions and in scripts Safari hides (`webkit-masked-url://`) are
114
+ sent as not the app's code, so bertha can say when an error probably isn't yours.
115
+
85
116
  The plugin turns on hidden source maps if the build has none. It only uploads when
86
117
  `BERTHA_UPLOAD_KEY` is set, so local builds stay offline and keep their maps.
87
118
  Options: `release`, `url`, `uploadKey`, `keepMaps`.
88
119
 
120
+ ## React
121
+
122
+ ```tsx
123
+ import { ErrorBoundary } from "@bigbertha/sdk/react"
124
+
125
+ <ErrorBoundary fallback={({ reset }) => <button onClick={reset}>Try again</button>}>
126
+ <App />
127
+ </ErrorBoundary>
128
+ ```
129
+
130
+ `ErrorBoundary` reports what it catches with React's component stack, then renders
131
+ `fallback`: a node, or a function of `{ error, eventId, reset }`. It also takes
132
+ `onError(error, componentStack, eventId)` and `tags`. Importing the entry adds React's
133
+ version to every event. React is an optional peer dependency; nothing else in the SDK
134
+ needs it.
135
+
89
136
  ## Node
90
137
 
91
138
  ```ts
@@ -105,7 +152,12 @@ frame in your own files from disk and sends them with the event (`contextLines:
105
152
  turns that off). It also raises V8's default stack limit from 10 frames to 50, so a
106
153
  deep framework call doesn't hide your code. A limit you set yourself is left alone.
107
154
 
108
- `wrapHandler(fn)` reports what a request handler throws and rethrows it.
155
+ `wrapHandler(fn)` reports what a request handler throws and rethrows it, with the
156
+ request: its URL, its headers (never `authorization`, `proxy-authorization`, `cookie`,
157
+ `set-cookie` or `x-api-key`), the client IP from the first `x-forwarded-for` hop or the
158
+ socket, and the trace from an incoming `traceparent`. Next.js's `onRequestError` sends
159
+ the same. Server events also name the host (Heroku's `DYNO`, else the hostname) and the
160
+ Node and Next.js versions.
109
161
 
110
162
  For bundled Node code, give the output debug ids and upload its maps after the build:
111
163
 
@@ -126,6 +178,7 @@ Every entry exports the same functions:
126
178
 
127
179
  ```ts
128
180
  captureException(err, { level: "warning", tags: { job: "nightly" }, fingerprint: ["nightly-import"] })
181
+ // An error's own fields (code, status…) are sent with it, each as JSON.
129
182
  captureMessage("Payment provider slow", { level: "warning" })
130
183
  addBreadcrumb({ category: "cart", message: "Added 2 items" })
131
184
  setUser({ id: "u_42" }) // hashed on the server, never shown in full
@@ -142,8 +195,12 @@ await flush()
142
195
  | `environment` | `BERTHA_ENVIRONMENT`, `VERCEL_ENV` or `NODE_ENV` (Node, edge; `withBertha` stamps it for the browser), else production | Free text is mapped to production, staging or development. |
143
196
  | `sampleRate` | `1` | Share of events to send. |
144
197
  | `beforeSend` | | Change an event, or return `null` to drop it. |
145
- | `scrub` | `true` | Redact emails, bearer tokens and secret query values. |
198
+ | `scrub` | `true` | Redact emails, bearer tokens and secret query values, and drop the error's own fields with credential names (`password`, `token`, `apiKey`…). |
146
199
  | `tags` | | Tags on every event. |
200
+ | `versions` | | More versions on every event, as `{ name: version }`. |
201
+ | `sendIp` | `true` | Browser: store the visitor's IP. |
202
+ | `tracePropagationTargets` | same origin only | Browser: other origins whose requests get a `traceparent`. |
203
+ | `breadcrumbs` | all on | Browser: `fetch`, `xhr`, `navigation`, `click`, `console`. |
147
204
  | `debug` | `false` | Log what the SDK does. |
148
205
 
149
206
  ## Deploys
@@ -156,7 +213,8 @@ npx bertha deploy --env production
156
213
 
157
214
  It sends the release and the last commit's sha, message and author. The release page
158
215
  then shows what the deploy brought in and what went quiet after it. A first event from an
159
- unknown release also creates the release, without the commit details.
216
+ unknown release also creates the release, and bertha marks it as only seen, not deployed.
217
+ When the release is a full commit sha, bertha takes it as the commit.
160
218
 
161
219
  ## Development
162
220
 
@@ -1,9 +1,19 @@
1
1
  import { Client } from "../core/client.ts";
2
- import type { Options } from "../core/types.ts";
2
+ import type { IngestEvent, Options } from "../core/types.ts";
3
3
  export { addBreadcrumb, captureException, captureMessage, flush, getClient, setTag, setUser } from "../core/hub.ts";
4
4
  export type { CaptureContext, IngestEvent, Options } from "../core/types.ts";
5
5
  export interface BrowserOptions extends Options {
6
6
  /** Which breadcrumbs to record automatically. All on by default. */
7
7
  breadcrumbs?: Partial<Record<"fetch" | "xhr" | "navigation" | "click" | "console", boolean>>;
8
+ /** Store the visitor's IP address with each event (bertha reads it from the request). On by default. */
9
+ sendIp?: boolean;
10
+ /**
11
+ * Other origins whose requests get a `traceparent` header, so their errors link to this
12
+ * page's: URL prefixes or patterns. Same-origin requests always get one. The server
13
+ * must allow the header in CORS.
14
+ */
15
+ tracePropagationTargets?: (string | RegExp)[];
8
16
  }
9
17
  export declare function init(options: BrowserOptions): Client | undefined;
18
+ /** What the page looked like when it failed. Called for every event. */
19
+ export declare function enrich(event: IngestEvent, sendIp: boolean): void;
@@ -1,13 +1,16 @@
1
- // @bigbertha/sdk/browser: uncaught errors and rejections, with breadcrumbs from
2
- // fetch, XHR, navigation, clicks and the console.
1
+ // @bigbertha/sdk/browser: uncaught errors and rejections, with the page they happened
2
+ // on and its state, and breadcrumbs from fetch, XHR, navigation, clicks and the console.
3
3
  import { Client } from "../core/client.js";
4
4
  import { setClient } from "../core/hub.js";
5
5
  import { scrubUrl } from "../core/scrub.js";
6
+ import { randomHex, traceparent } from "../core/trace.js";
6
7
  import { fetchTransport, streamGzip } from "../core/transport.js";
7
8
  import { VERSION } from "../core/version.js";
8
9
  import { userAgent } from "./user-agent.js";
9
10
  export { addBreadcrumb, captureException, captureMessage, flush, getClient, setTag, setUser } from "../core/hub.js";
10
11
  let installed = false;
12
+ /** One trace per page load: every error and request from it shares this id. */
13
+ const traceId = randomHex(32);
11
14
  export function init(options) {
12
15
  if (typeof window === "undefined")
13
16
  return undefined;
@@ -23,7 +26,7 @@ export function init(options) {
23
26
  gzip: streamGzip,
24
27
  fetch: nativeFetch,
25
28
  beacon: (url, body) => !!navigator.sendBeacon?.(url, new Blob([body], { type: "text/plain" })),
26
- }), { name: "bertha.browser", version: VERSION }, { ...userAgent(navigator.userAgent), userAgent: navigator.userAgent });
29
+ }), { name: "bertha.browser", version: VERSION }, { ...userAgent(navigator.userAgent), userAgent: navigator.userAgent }, undefined, (event) => enrich(event, options.sendIp !== false));
27
30
  setClient(client);
28
31
  if (installed)
29
32
  return client;
@@ -32,21 +35,28 @@ export function init(options) {
32
35
  // Resource load errors (img, script) bubble here too, without an error object.
33
36
  if (!e.error && !e.message)
34
37
  return;
35
- client.captureException(e.error ?? new ErrorFromEvent(e), { level: "error" });
38
+ client.captureException(e.error ?? new ErrorFromEvent(e), {
39
+ level: "error",
40
+ mechanism: { handler: "onerror", handled: false },
41
+ });
36
42
  });
37
- window.addEventListener("unhandledrejection", (e) => client.captureException(e.reason));
43
+ window.addEventListener("unhandledrejection", (e) => client.captureException(e.reason, { mechanism: { handler: "onunhandledrejection", handled: false } }));
38
44
  addEventListener("pagehide", () => void client.flush(true));
45
+ const on = { fetch: true, xhr: true, navigation: true, click: true, console: true, ...options.breadcrumbs };
46
+ const base = options.url.replace(/\/+$/, "");
47
+ const crumb = (category, message, level, timestamp) => client.addBreadcrumb({ category, message, ...(level ? { level } : {}), ...(timestamp ? { timestamp } : {}) });
39
48
  document.addEventListener("visibilitychange", () => {
40
- if (document.visibilityState === "hidden")
49
+ const hidden = document.visibilityState === "hidden";
50
+ if (on.navigation)
51
+ crumb("navigation", hidden ? "Page hidden" : "Page visible");
52
+ if (hidden)
41
53
  void client.flush(true);
42
54
  });
43
- const on = { fetch: true, xhr: true, navigation: true, click: true, console: true, ...options.breadcrumbs };
44
- const base = options.url.replace(/\/+$/, "");
45
- const crumb = (category, message, level) => client.addBreadcrumb(level ? { category, message, level } : { category, message });
55
+ const propagate = propagateTo(options.tracePropagationTargets ?? []);
46
56
  if (on.fetch)
47
- instrumentFetch(base, crumb);
57
+ instrumentFetch(base, crumb, propagate);
48
58
  if (on.xhr)
49
- instrumentXhr(base, crumb);
59
+ instrumentXhr(base, crumb, propagate);
50
60
  if (on.navigation)
51
61
  instrumentHistory(crumb);
52
62
  if (on.click)
@@ -55,6 +65,51 @@ export function init(options) {
55
65
  instrumentConsole(crumb);
56
66
  return client;
57
67
  }
68
+ /** What the page looked like when it failed. Called for every event. */
69
+ export function enrich(event, sendIp) {
70
+ event.request ??= { method: "GET", url: scrubUrl(location.href) };
71
+ if (sendIp && !event.user?.ip)
72
+ event.user = { ...event.user, ip: "auto" };
73
+ event.trace ??= { traceId };
74
+ const nav = performance.getEntriesByType?.("navigation")[0];
75
+ const connection = navigator.connection;
76
+ let timezone;
77
+ try {
78
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
79
+ }
80
+ catch {
81
+ // Old engines without Intl.
82
+ }
83
+ const next = window.next?.version;
84
+ event.context = {
85
+ ...event.context,
86
+ viewport: `${innerWidth}×${innerHeight} @${Math.round(devicePixelRatio * 100) / 100}x`,
87
+ online: navigator.onLine,
88
+ visible: document.visibilityState === "visible",
89
+ navigation: nav?.type,
90
+ automated: !!navigator.webdriver,
91
+ pageAgeMs: Math.round(performance.now()),
92
+ referrer: document.referrer || undefined,
93
+ language: navigator.language,
94
+ timezone,
95
+ connection: connection?.effectiveType && `${connection.effectiveType}${connection.saveData ? ", save data" : ""}`,
96
+ versions: { ...(next ? { next } : {}), ...event.context?.versions },
97
+ };
98
+ }
99
+ /** Same-origin URLs, and those the app listed, get a new span. */
100
+ function propagateTo(targets) {
101
+ return (url) => {
102
+ let abs;
103
+ try {
104
+ abs = new URL(url, location.href);
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ const listed = targets.some((t) => (typeof t === "string" ? abs.href.startsWith(t) : t.test(abs.href)));
110
+ return abs.origin === location.origin || listed ? randomHex(16) : undefined;
111
+ };
112
+ }
58
113
  class ErrorFromEvent extends Error {
59
114
  constructor(e) {
60
115
  super(e.message);
@@ -62,28 +117,38 @@ class ErrorFromEvent extends Error {
62
117
  this.stack = `${e.message}\n at ${e.filename}:${e.lineno}:${e.colno}`;
63
118
  }
64
119
  }
65
- function requestLine(method, url, status) {
66
- return `${method.toUpperCase()} ${scrubUrl(url)} ${status}`;
120
+ /** `GET /x 200 in 34ms, span 00f067aa0ba902b7` */
121
+ function requestLine(method, url, status, start, span) {
122
+ const ms = Math.round(performance.now() - start);
123
+ return `${method.toUpperCase()} ${scrubUrl(url)} ${status} in ${ms}ms${span ? `, span ${span}` : ""}`;
67
124
  }
68
- function instrumentFetch(base, crumb) {
125
+ function instrumentFetch(base, crumb, propagate) {
69
126
  const original = window.fetch;
70
127
  window.fetch = async function (input, init) {
71
128
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
72
129
  const method = init?.method ?? (input instanceof Request ? input.method : "GET");
73
130
  if (url.startsWith(base))
74
131
  return original.call(window, input, init);
132
+ const span = propagate(url);
133
+ if (span) {
134
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
135
+ if (!headers.has("traceparent"))
136
+ headers.set("traceparent", traceparent(traceId, span));
137
+ init = { ...init, headers };
138
+ }
139
+ const start = performance.now();
75
140
  try {
76
141
  const res = await original.call(window, input, init);
77
- crumb("fetch", requestLine(method, url, res.status), res.status >= 400 ? "error" : undefined);
142
+ crumb("fetch", requestLine(method, url, res.status, start, span), res.status >= 400 ? "error" : undefined);
78
143
  return res;
79
144
  }
80
145
  catch (e) {
81
- crumb("fetch", requestLine(method, url, "failed"), "error");
146
+ crumb("fetch", requestLine(method, url, "failed", start, span), "error");
82
147
  throw e;
83
148
  }
84
149
  };
85
150
  }
86
- function instrumentXhr(base, crumb) {
151
+ function instrumentXhr(base, crumb, propagate) {
87
152
  const proto = XMLHttpRequest.prototype;
88
153
  const open = proto.open;
89
154
  const send = proto.send;
@@ -95,12 +160,30 @@ function instrumentXhr(base, crumb) {
95
160
  proto.send = function (body) {
96
161
  const m = meta.get(this);
97
162
  if (m && !m[1].startsWith(base)) {
98
- this.addEventListener("loadend", () => crumb("fetch", requestLine(m[0], m[1], this.status || "failed"), !this.status || this.status >= 400 ? "error" : undefined));
163
+ const span = propagate(m[1]);
164
+ // Throws once the request was sent, or when the app set the header itself.
165
+ if (span) {
166
+ try {
167
+ this.setRequestHeader("traceparent", traceparent(traceId, span));
168
+ }
169
+ catch {
170
+ // Leave the request as the app made it.
171
+ }
172
+ }
173
+ const start = performance.now();
174
+ this.addEventListener("loadend", () => crumb("fetch", requestLine(m[0], m[1], this.status || "failed", start, span), !this.status || this.status >= 400 ? "error" : undefined));
99
175
  }
100
176
  return send.call(this, body);
101
177
  };
102
178
  }
103
179
  function instrumentHistory(crumb) {
180
+ const nav = performance.getEntriesByType?.("navigation")[0];
181
+ const loaded = new Date(performance.timeOrigin || Date.now()).toISOString();
182
+ crumb("navigation", `Loaded ${scrubUrl(path(location.href))}${nav ? ` (${nav.type})` : ""}`, undefined, loaded);
183
+ addEventListener("pageshow", (e) => {
184
+ if (e.persisted)
185
+ crumb("navigation", `Restored ${scrubUrl(path(location.href))} from the back-forward cache`);
186
+ });
104
187
  let last = location.href;
105
188
  const record = () => {
106
189
  const to = location.href;
@@ -143,13 +226,14 @@ function describeElement(el) {
143
226
  const id = el.id ? `#${el.id}` : "";
144
227
  return label ? `Clicked ${tag}${id} "${label}"` : `Clicked ${tag}${id}`;
145
228
  }
229
+ const CONSOLE_LEVELS = { log: "info", info: "info", warn: "warning", error: "error" };
146
230
  function instrumentConsole(crumb) {
147
- for (const level of ["warn", "error"]) {
231
+ for (const level of ["log", "info", "warn", "error"]) {
148
232
  const original = console[level];
149
233
  console[level] = (...args) => {
150
234
  const text = args.map((a) => (typeof a === "string" ? a : a instanceof Error ? a.message : safeJson(a))).join(" ");
151
235
  if (!text.startsWith("[bertha]"))
152
- crumb("console", text.slice(0, 500), level === "warn" ? "warning" : "error");
236
+ crumb("console", text.slice(0, 500), CONSOLE_LEVELS[level]);
153
237
  original.apply(console, args);
154
238
  };
155
239
  }
@@ -0,0 +1,18 @@
1
+ import { type Commit } from "./release.ts";
2
+ export interface DeployOptions {
3
+ url: string;
4
+ uploadKey: string;
5
+ release: string;
6
+ environment: string;
7
+ commit?: Commit;
8
+ }
9
+ export type DeployResult = {
10
+ ok: true;
11
+ releaseId: string;
12
+ } | {
13
+ ok: false;
14
+ message: string;
15
+ };
16
+ /** The commit being deployed: the checkout's, else what the host says (Vercel names it in full). */
17
+ export declare function deployCommit(cwd?: string): Commit | undefined;
18
+ export declare function recordDeploy(o: DeployOptions): Promise<DeployResult>;
@@ -0,0 +1,32 @@
1
+ // Records a deploy with bertha: `bertha deploy` in CI, and withBertha on Vercel.
2
+ import { env, envCommit } from "../core/env.js";
3
+ import { lastCommit } from "./release.js";
4
+ import { reason } from "./index.js";
5
+ /** The commit being deployed: the checkout's, else what the host says (Vercel names it in full). */
6
+ export function deployCommit(cwd) {
7
+ const vercel = env("VERCEL_GIT_COMMIT_SHA");
8
+ if (vercel) {
9
+ return { sha: vercel, message: env("VERCEL_GIT_COMMIT_MESSAGE") ?? "", author: env("VERCEL_GIT_COMMIT_AUTHOR_NAME") ?? "" };
10
+ }
11
+ const sha = envCommit();
12
+ return lastCommit(cwd) ?? (sha ? { sha, message: "", author: "" } : undefined);
13
+ }
14
+ export async function recordDeploy(o) {
15
+ const body = { release: o.release, environment: o.environment };
16
+ if (o.commit)
17
+ body.commit = o.commit;
18
+ try {
19
+ const res = await fetch(`${o.url.replace(/\/+$/, "")}/ingest/v1/deploys`, {
20
+ method: "POST",
21
+ headers: { authorization: `Bearer ${o.uploadKey}`, "content-type": "application/json" },
22
+ body: JSON.stringify(body),
23
+ signal: AbortSignal.timeout(30_000),
24
+ });
25
+ if (!res.ok)
26
+ return { ok: false, message: `bertha didn't record the deploy: ${await reason(res)}` };
27
+ return { ok: true, releaseId: (await res.json()).releaseId };
28
+ }
29
+ catch (e) {
30
+ return { ok: false, message: `Couldn't reach bertha at ${o.url}: ${e.message}` };
31
+ }
32
+ }
@@ -1,3 +1,4 @@
1
+ export { type DeployOptions, type DeployResult, deployCommit, recordDeploy } from "./deploy.ts";
1
2
  export { detectRelease, lastCommit } from "./release.ts";
2
3
  export interface ProcessOptions {
3
4
  /** The build output. */
@@ -11,6 +11,7 @@ import { existsSync } from "node:fs";
11
11
  import { readdir, readFile, rm, writeFile } from "node:fs/promises";
12
12
  import { dirname, join, relative, resolve, sep } from "node:path";
13
13
  import { gzipSync } from "node:zlib";
14
+ export { deployCommit, recordDeploy } from "./deploy.js";
14
15
  export { detectRelease, lastCommit } from "./release.js";
15
16
  const JS = /\.(?:m|c)?js$/;
16
17
  const ORPHAN_MAP = /\.(?:(?:m|c)?js|css)\.map$/;
package/dist/cli.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // bertha deploy | bertha sourcemaps upload <dir>
2
2
  import { parseArgs } from "node:util";
3
- import { detectRelease, lastCommit, processDirectory, reason } from "./build/index.js";
4
- import { envCommit } from "./core/env.js";
3
+ import { deployCommit, detectRelease, processDirectory, recordDeploy } from "./build/index.js";
5
4
  import { VERSION } from "./core/version.js";
6
5
  const HELP = `bertha ${VERSION}
7
6
 
@@ -50,28 +49,13 @@ export async function main(argv, out = console.log) {
50
49
  return out("Set BERTHA_URL and BERTHA_UPLOAD_KEY, or pass --url and --key."), 1;
51
50
  if (!release)
52
51
  return out("No release found. Pass --release or set BERTHA_RELEASE."), 1;
53
- const body = { release, environment: v.env ?? "production" };
52
+ const environment = v.env ?? "production";
54
53
  // Without a checkout (a host's build step), the host still names the commit.
55
- const sha = envCommit();
56
- const commit = lastCommit() ?? (sha ? { sha, message: "", author: "" } : undefined);
57
- if (commit)
58
- body.commit = commit;
59
- try {
60
- const res = await fetch(`${url.replace(/\/+$/, "")}/ingest/v1/deploys`, {
61
- method: "POST",
62
- headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
63
- body: JSON.stringify(body),
64
- signal: AbortSignal.timeout(30_000),
65
- });
66
- if (!res.ok)
67
- return out(`bertha didn't record the deploy: ${await reason(res)}`), 1;
68
- const recorded = (await res.json());
69
- out(`Recorded ${release} in ${body.environment} (${recorded.releaseId}).`);
70
- return 0;
71
- }
72
- catch (e) {
73
- return out(`Couldn't reach bertha at ${url}: ${e.message}`), 1;
74
- }
54
+ const r = await recordDeploy({ url, uploadKey: key, release, environment, commit: deployCommit() });
55
+ if (!r.ok)
56
+ return out(r.message), 1;
57
+ out(`Recorded ${release} in ${environment} (${r.releaseId}).`);
58
+ return 0;
75
59
  }
76
60
  if (command === "sourcemaps" && rest[0] === "upload") {
77
61
  const dir = rest[1];
@@ -5,12 +5,15 @@ export declare const BATCH = 100;
5
5
  type Context = IngestEvent["context"];
6
6
  /** Adds source lines to frames, where the runtime can read the files. */
7
7
  export type AddContext = (frames: IngestFrame[]) => void;
8
+ /** Adds what the runtime knows at capture time (the page, its state). Runs before scrubbing. */
9
+ export type Enrich = (event: IngestEvent) => void;
8
10
  export declare class Client {
9
11
  readonly options: Options;
10
12
  private readonly transport;
11
13
  private readonly sdk;
12
14
  private readonly context;
13
15
  private readonly addContext;
16
+ private readonly enrich;
14
17
  private user;
15
18
  private tags;
16
19
  private crumbs;
@@ -21,7 +24,7 @@ export declare class Client {
21
24
  private window;
22
25
  private pausedUntil;
23
26
  private warned;
24
- constructor(options: Options, transport: Transport, sdk: IngestSdk, context?: Context, addContext?: AddContext);
27
+ constructor(options: Options, transport: Transport, sdk: IngestSdk, context?: Context, addContext?: AddContext, enrich?: Enrich);
25
28
  captureException(error: unknown, ctx?: CaptureContext): string | undefined;
26
29
  captureMessage(message: string, ctx?: CaptureContext): string | undefined;
27
30
  setUser(user: IngestUser | null): void;
@@ -3,18 +3,24 @@
3
3
  import { builtRelease, debugIdsFor } from "./debug-ids.js";
4
4
  import { scrubText, scrubUrl } from "./scrub.js";
5
5
  import { parseStack } from "./stack.js";
6
+ import { versions } from "./versions.js";
6
7
  export const MAX_BREADCRUMBS = 100;
7
8
  export const BATCH = 100;
8
9
  const DEBOUNCE_MS = 1000;
9
10
  const PER_MINUTE = 100;
10
11
  const MAX_CAUSES = 5;
11
12
  const RETRIES = 3;
13
+ const DATA_FIELDS = 20;
14
+ const DATA_CHARS = 500;
15
+ /** Error fields dropped while scrubbing. */
16
+ const SENSITIVE_KEY = /password|passwd|secret|token|authorization|cookie|api_?key|credential|session/i;
12
17
  export class Client {
13
18
  options;
14
19
  transport;
15
20
  sdk;
16
21
  context;
17
22
  addContext;
23
+ enrich;
18
24
  user;
19
25
  tags;
20
26
  crumbs = [];
@@ -25,12 +31,13 @@ export class Client {
25
31
  window = { start: 0, count: 0 };
26
32
  pausedUntil = 0;
27
33
  warned = false;
28
- constructor(options, transport, sdk, context, addContext) {
34
+ constructor(options, transport, sdk, context, addContext, enrich) {
29
35
  this.options = options;
30
36
  this.transport = transport;
31
37
  this.sdk = sdk;
32
38
  this.context = context;
33
39
  this.addContext = addContext;
40
+ this.enrich = enrich;
34
41
  this.tags = { ...options.tags };
35
42
  }
36
43
  captureException(error, ctx = {}) {
@@ -41,6 +48,8 @@ export class Client {
41
48
  this.seen.add(error);
42
49
  }
43
50
  const exception = toException(error, 0);
51
+ if (ctx.componentStack)
52
+ exception.componentStack = ctx.componentStack;
44
53
  if (this.addContext) {
45
54
  try {
46
55
  for (let x = exception; x; x = x.cause)
@@ -50,10 +59,12 @@ export class Client {
50
59
  this.log("couldn't read source lines", e);
51
60
  }
52
61
  }
53
- return this.capture({ exception, level: ctx.level ?? "error" }, ctx);
62
+ const mechanism = ctx.mechanism ?? { handler: "captureException", handled: true };
63
+ return this.capture({ exception, mechanism, level: ctx.level ?? "error" }, ctx);
54
64
  }
55
65
  captureMessage(message, ctx = {}) {
56
- return this.capture({ message, level: ctx.level ?? "warning" }, ctx);
66
+ const mechanism = ctx.mechanism ?? { handler: "captureMessage", handled: true };
67
+ return this.capture({ message, mechanism, level: ctx.level ?? "warning" }, ctx);
57
68
  }
58
69
  setUser(user) {
59
70
  this.user = user ?? undefined;
@@ -97,12 +108,19 @@ export class Client {
97
108
  release: o.release ?? builtRelease(),
98
109
  environment: o.environment,
99
110
  breadcrumbs: this.crumbs.slice(),
100
- user: this.user,
101
- context: this.context,
111
+ user: ctx.user ? { ...this.user, ...ctx.user } : this.user,
112
+ context: { ...this.context, versions: { ...versions(), ...o.versions, ...this.context?.versions } },
102
113
  tags: { ...this.tags, ...ctx.tags },
103
114
  fingerprint: ctx.fingerprint,
104
115
  request: ctx.request,
116
+ trace: ctx.trace,
105
117
  };
118
+ try {
119
+ this.enrich?.(event);
120
+ }
121
+ catch (e) {
122
+ this.log("couldn't read the page's state", e);
123
+ }
106
124
  if (o.scrub !== false)
107
125
  scrubEvent(event);
108
126
  if (event.exception)
@@ -169,10 +187,34 @@ export function toException(error, depth) {
169
187
  message: String(e.message ?? ""),
170
188
  frames: parseStack(e.stack),
171
189
  };
190
+ const data = ownFields(e);
191
+ if (data)
192
+ out.data = data;
172
193
  if (e.cause !== undefined && depth < MAX_CAUSES)
173
194
  out.cause = toException(e.cause, depth + 1);
174
195
  return out;
175
196
  }
197
+ /** The error's own fields (`code`, `status`, a response…), each as JSON. */
198
+ function ownFields(e) {
199
+ let out;
200
+ let n = 0;
201
+ for (const key of Object.keys(e)) {
202
+ if (key === "name" || key === "message" || key === "stack" || key === "cause")
203
+ continue;
204
+ if (n++ >= DATA_FIELDS)
205
+ break;
206
+ let text;
207
+ try {
208
+ text = JSON.stringify(e[key]) ?? String(e[key]);
209
+ }
210
+ catch {
211
+ text = String(e[key]);
212
+ }
213
+ ;
214
+ (out ??= {})[key] = text.length > DATA_CHARS ? `${text.slice(0, DATA_CHARS - 1)}…` : text;
215
+ }
216
+ return out;
217
+ }
176
218
  function isErrorLike(v) {
177
219
  return !!v && typeof v === "object" && "message" in v && "stack" in v;
178
220
  }
@@ -194,11 +236,19 @@ function* files(e) {
194
236
  function scrubEvent(e) {
195
237
  if (e.message)
196
238
  e.message = scrubText(e.message);
197
- for (let x = e.exception; x; x = x.cause)
239
+ for (let x = e.exception; x; x = x.cause) {
198
240
  x.message = scrubText(x.message);
241
+ if (x.data) {
242
+ x.data = Object.fromEntries(Object.entries(x.data)
243
+ .filter(([k]) => !SENSITIVE_KEY.test(k))
244
+ .map(([k, v]) => [k, scrubText(v)]));
245
+ }
246
+ }
199
247
  e.breadcrumbs = e.breadcrumbs?.map((b) => ({ ...b, message: scrubText(b.message) }));
200
248
  if (e.request)
201
249
  e.request = { ...e.request, url: scrubUrl(e.request.url) };
250
+ if (e.context?.referrer)
251
+ e.context = { ...e.context, referrer: scrubUrl(e.context.referrer) };
202
252
  }
203
253
  export function uuid() {
204
254
  // randomUUID is missing on plain-http pages.
@@ -0,0 +1,6 @@
1
+ import type { CaptureContext } from "./types.ts";
2
+ /**
3
+ * The request an error happened in: its headers without credentials, the client's IP
4
+ * (the first proxy hop, else the socket) and the trace from an incoming `traceparent`.
5
+ */
6
+ export declare function fromRequest(method: string, url: string, headers: Record<string, unknown>, remoteAddress?: string): CaptureContext;
@@ -0,0 +1,31 @@
1
+ // The request a server-side error happened in, from its method, URL and headers.
2
+ import { parseTraceparent } from "./trace.js";
3
+ /** Never sent: they'd let anyone reading the event act as the user. */
4
+ const CREDENTIAL_HEADERS = new Set(["authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key"]);
5
+ const HEADERS = 30;
6
+ const HEADER_CHARS = 512;
7
+ /**
8
+ * The request an error happened in: its headers without credentials, the client's IP
9
+ * (the first proxy hop, else the socket) and the trace from an incoming `traceparent`.
10
+ */
11
+ export function fromRequest(method, url, headers, remoteAddress) {
12
+ const kept = {};
13
+ let n = 0;
14
+ for (const [k, raw] of Object.entries(headers)) {
15
+ const key = k.toLowerCase();
16
+ const value = Array.isArray(raw) ? raw.join(", ") : typeof raw === "string" ? raw : undefined;
17
+ if (value === undefined || CREDENTIAL_HEADERS.has(key))
18
+ continue;
19
+ if (n++ >= HEADERS)
20
+ break;
21
+ kept[key] = value.length > HEADER_CHARS ? `${value.slice(0, HEADER_CHARS - 1)}…` : value;
22
+ }
23
+ const ip = kept["x-forwarded-for"]?.split(",")[0]?.trim() || remoteAddress;
24
+ const ctx = { request: { method, url, headers: kept } };
25
+ if (ip)
26
+ ctx.user = { ip };
27
+ const trace = parseTraceparent(kept.traceparent);
28
+ if (trace)
29
+ ctx.trace = trace;
30
+ return ctx;
31
+ }
@@ -40,7 +40,9 @@ function cleanFunction(fn) {
40
40
  return undefined;
41
41
  return name;
42
42
  }
43
+ /** Safari masks extension and injected scripts as `webkit-masked-url://hidden/`. */
44
+ const EXTENSION = /^(?:webkit-masked-url|safari-(?:web-)?extension|chrome-extension|moz-extension|ms-browser-extension):\/\//;
43
45
  /** Library code: the server decides the rest from the path. */
44
46
  export function isLibrary(file) {
45
- return file.includes("/node_modules/") || file.startsWith("node:") || file.startsWith("internal/");
47
+ return file.includes("/node_modules/") || file.startsWith("node:") || file.startsWith("internal/") || EXTENSION.test(file);
46
48
  }
@@ -0,0 +1,6 @@
1
+ import type { IngestTrace } from "../generated/ingest.ts";
2
+ /** `n` random lowercase hex characters, never all zeros (the spec's invalid id). */
3
+ export declare function randomHex(n: number): string;
4
+ export declare function traceparent(traceId: string, spanId: string): string;
5
+ /** The incoming request's trace: its trace id, and the caller's span. */
6
+ export declare function parseTraceparent(header: string | undefined): IngestTrace | undefined;
@@ -0,0 +1,25 @@
1
+ // W3C trace context (`traceparent: 00-<trace id>-<span id>-<flags>`), so a page's
2
+ // requests and the server errors they cause share a trace id.
3
+ const TRACEPARENT = /^[\da-f]{2}-([\da-f]{32})-([\da-f]{16})-[\da-f]{2}$/;
4
+ /** `n` random lowercase hex characters, never all zeros (the spec's invalid id). */
5
+ export function randomHex(n) {
6
+ const bytes = new Uint8Array(n / 2);
7
+ const c = globalThis.crypto;
8
+ if (c?.getRandomValues)
9
+ c.getRandomValues(bytes);
10
+ else
11
+ for (let i = 0; i < bytes.length; i++)
12
+ bytes[i] = Math.floor(Math.random() * 256);
13
+ bytes[0] ||= 1;
14
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
15
+ }
16
+ export function traceparent(traceId, spanId) {
17
+ return `00-${traceId}-${spanId}-01`;
18
+ }
19
+ /** The incoming request's trace: its trace id, and the caller's span. */
20
+ export function parseTraceparent(header) {
21
+ const m = header && TRACEPARENT.exec(header.trim().toLowerCase());
22
+ if (!m || /^0+$/.test(m[1]) || /^0+$/.test(m[2]))
23
+ return undefined;
24
+ return { traceId: m[1], spanId: m[2] };
25
+ }
@@ -1,5 +1,5 @@
1
- import type { IngestBreadcrumb, IngestEvent, IngestLevel, IngestUser } from "../generated/ingest.ts";
2
- export type { IngestBreadcrumb, IngestEvent, IngestLevel, IngestUser };
1
+ import type { IngestBreadcrumb, IngestEvent, IngestLevel, IngestMechanism, IngestTrace, IngestUser } from "../generated/ingest.ts";
2
+ export type { IngestBreadcrumb, IngestEvent, IngestLevel, IngestMechanism, IngestTrace, IngestUser };
3
3
  export interface Options {
4
4
  /** The project's ingest key. Public: safe to ship to browsers. */
5
5
  key: string;
@@ -19,12 +19,21 @@ export interface Options {
19
19
  tags?: Record<string, string>;
20
20
  /** Log what the SDK does to the console. */
21
21
  debug?: boolean;
22
+ /** Framework versions on every event, e.g. `{ vue: "3.5.0" }`. React and Next.js are found by themselves. */
23
+ versions?: Record<string, string>;
22
24
  }
23
25
  export interface CaptureContext {
24
26
  level?: IngestLevel;
25
27
  tags?: Record<string, string>;
26
28
  fingerprint?: string[];
27
29
  request?: IngestEvent["request"];
30
+ /** Merged over the user from `setUser`. */
31
+ user?: IngestUser;
32
+ /** How the error reached the SDK. Defaults to `captureException`, handled. */
33
+ mechanism?: IngestMechanism;
34
+ trace?: IngestTrace;
35
+ /** React's component stack, from an error boundary. */
36
+ componentStack?: string;
28
37
  }
29
38
  /** What a send came back with; "offline" means it never reached bertha. */
30
39
  export type SendResult = {
@@ -1 +1 @@
1
- export declare const VERSION = "0.4.1";
1
+ export declare const VERSION = "0.5.0";
@@ -1 +1 @@
1
- export const VERSION = "0.4.1";
1
+ export const VERSION = "0.5.0";
@@ -0,0 +1,2 @@
1
+ export declare function registerVersion(name: string, version: string | undefined): void;
2
+ export declare function versions(): Record<string, string>;
@@ -0,0 +1,12 @@
1
+ // Framework versions found at runtime (React from its entry, Next.js from the page),
2
+ // shared through globalThis so a second copy of the SDK in the bundle sees them too.
3
+ const KEY = "__berthaVersions";
4
+ export function registerVersion(name, version) {
5
+ if (!version)
6
+ return;
7
+ const g = globalThis;
8
+ (g[KEY] ??= {})[name] = version;
9
+ }
10
+ export function versions() {
11
+ return { ...globalThis[KEY] };
12
+ }
@@ -27,6 +27,41 @@ export type IngestContext = {
27
27
  os?: string;
28
28
  runtime?: string;
29
29
  userAgent?: string;
30
+ /**
31
+ * e.g. "1194×702 @2x".
32
+ */
33
+ viewport?: string;
34
+ online?: boolean;
35
+ /**
36
+ * Whether the page was visible.
37
+ */
38
+ visible?: boolean;
39
+ /**
40
+ * `PerformanceNavigationTiming.type`: navigate, reload, back_forward or prerender.
41
+ */
42
+ navigation?: string;
43
+ /**
44
+ * `navigator.webdriver`.
45
+ */
46
+ automated?: boolean;
47
+ /**
48
+ * How long the page had been open.
49
+ */
50
+ pageAgeMs?: number;
51
+ referrer?: string;
52
+ language?: string;
53
+ timezone?: string;
54
+ connection?: string;
55
+ /**
56
+ * The server host or dyno.
57
+ */
58
+ host?: string;
59
+ /**
60
+ * Framework and runtime versions, e.g. `{ "react": "18.3.1" }`.
61
+ */
62
+ versions?: {
63
+ [key in string]: string;
64
+ };
30
65
  };
31
66
  /**
32
67
  * `POST /ingest/v1/deploys`, from CI after a deploy.
@@ -89,6 +124,14 @@ export type IngestEvent = {
89
124
  debugIds?: {
90
125
  [key in string]: string;
91
126
  };
127
+ /**
128
+ * How the error reached the SDK.
129
+ */
130
+ mechanism?: IngestMechanism;
131
+ /**
132
+ * The W3C trace this happened in: the page load, or the incoming `traceparent`.
133
+ */
134
+ trace?: IngestTrace;
92
135
  };
93
136
  export type IngestException = {
94
137
  type: string;
@@ -98,6 +141,16 @@ export type IngestException = {
98
141
  */
99
142
  frames: Array<IngestFrame>;
100
143
  cause?: IngestException;
144
+ /**
145
+ * The error's own fields (`code`, `status`…), stringified.
146
+ */
147
+ data?: {
148
+ [key in string]: string;
149
+ };
150
+ /**
151
+ * React's component stack, from an error boundary.
152
+ */
153
+ componentStack?: string;
101
154
  };
102
155
  export type IngestFrame = {
103
156
  file: string;
@@ -135,10 +188,26 @@ export type IngestHeartbeat = {
135
188
  commit?: string;
136
189
  };
137
190
  export type IngestLevel = "fatal" | "error" | "warning";
191
+ export type IngestMechanism = {
192
+ /**
193
+ * The hook: `onerror`, `onunhandledrejection`, `uncaughtException`, `ErrorBoundary`…
194
+ */
195
+ handler: string;
196
+ /**
197
+ * False when nothing in the app caught it.
198
+ */
199
+ handled: boolean;
200
+ };
138
201
  export type IngestRequest = {
139
202
  method: string;
140
203
  url: string;
141
204
  status?: number;
205
+ /**
206
+ * The incoming request's headers. bertha drops credential headers itself too.
207
+ */
208
+ headers?: {
209
+ [key in string]: string;
210
+ };
142
211
  };
143
212
  export type IngestResponse = {
144
213
  accepted: number;
@@ -152,6 +221,16 @@ export type IngestSdk = {
152
221
  name: string;
153
222
  version: string;
154
223
  };
224
+ export type IngestTrace = {
225
+ /**
226
+ * 32 lowercase hex characters.
227
+ */
228
+ traceId: string;
229
+ /**
230
+ * 16 lowercase hex characters: the request's span, when there was one.
231
+ */
232
+ spanId?: string;
233
+ };
155
234
  export type IngestUser = {
156
235
  id?: string;
157
236
  email?: string;
@@ -8,6 +8,11 @@ export interface BerthaNextOptions {
8
8
  keepMaps?: boolean | ((file: string) => boolean);
9
9
  /** Skip the post-build step entirely (the release is still stamped). */
10
10
  disableUpload?: boolean;
11
+ /**
12
+ * On Vercel, record a deploy after the upload, in VERCEL_ENV's environment. On by
13
+ * default; turn it off when CI already runs `bertha deploy`.
14
+ */
15
+ recordDeploy?: boolean;
11
16
  }
12
17
  export declare const keepServerMaps: (file: string) => boolean;
13
18
  export declare function withBertha<C extends object>(config?: C, options?: BerthaNextOptions): C;
@@ -2,10 +2,11 @@
2
2
  // export default withBertha(nextConfig)
3
3
  // Turns on source maps for production builds, stamps the release and environment
4
4
  // (BERTHA_ENVIRONMENT, else Vercel's production or preview), and after each
5
- // build gives bundles debug ids, uploads the maps and removes them from `.next`.
5
+ // build gives bundles debug ids, uploads the maps and removes them from `.next`. On
6
+ // Vercel it then records the deploy, with the commit Vercel names.
6
7
  import { join } from "node:path";
7
- import { detectRelease, processDirectory } from "../build/index.js";
8
- import { firstEnv } from "../core/env.js";
8
+ import { deployCommit, detectRelease, processDirectory, recordDeploy } from "../build/index.js";
9
+ import { env, firstEnv } from "../core/env.js";
9
10
  export const keepServerMaps = (file) => !file.startsWith("static/");
10
11
  export function withBertha(config = {}, options = {}) {
11
12
  const c = config;
@@ -27,15 +28,25 @@ export function withBertha(config = {}, options = {}) {
27
28
  await previous?.(meta);
28
29
  if (options.disableUpload)
29
30
  return;
31
+ const url = firstEnv("BERTHA_URL", "NEXT_PUBLIC_BERTHA_URL");
32
+ const uploadKey = firstEnv("BERTHA_UPLOAD_KEY");
30
33
  await processDirectory({
31
34
  dir: meta.distDir ?? join(meta.projectDir, ".next"),
32
35
  release,
33
- url: firstEnv("BERTHA_URL", "NEXT_PUBLIC_BERTHA_URL"),
34
- uploadKey: firstEnv("BERTHA_UPLOAD_KEY"),
36
+ url,
37
+ uploadKey,
35
38
  keepMaps: options.keepMaps ?? keepServerMaps,
36
39
  skip: ["cache", "standalone", "types"],
37
40
  log: (m) => console.log(`[bertha] ${m}`),
38
41
  }).catch((e) => console.warn(`[bertha] Source maps weren't processed: ${String(e)}`));
42
+ if (env("VERCEL") !== "1" || options.recordDeploy === false || !url || !uploadKey || !release)
43
+ return;
44
+ const deployedTo = environment ?? "production";
45
+ const r = await recordDeploy({ url, uploadKey, release, environment: deployedTo, commit: deployCommit(meta.projectDir) });
46
+ if (r.ok)
47
+ console.log(`[bertha] Recorded ${release} in ${deployedTo} (${r.releaseId}).`);
48
+ else
49
+ console.warn(`[bertha] ${r.message}`);
39
50
  },
40
51
  },
41
52
  };
@@ -1,6 +1,7 @@
1
1
  // @bigbertha/sdk/next, for instrumentation.ts:
2
2
  // export { register, onRequestError } from "@bigbertha/sdk/next"
3
3
  import { captureException, flush } from "../core/hub.js";
4
+ import { fromRequest } from "../core/request.js";
4
5
  export async function register() {
5
6
  if (process.env.NEXT_RUNTIME === "nodejs")
6
7
  (await import("../node/index.js")).init();
@@ -11,9 +12,11 @@ export async function register() {
11
12
  export async function onRequestError(error, request, context) {
12
13
  const host = header(request.headers.host);
13
14
  const proto = header(request.headers["x-forwarded-proto"]) ?? "https";
15
+ const url = host ? `${proto}://${host}${request.path}` : request.path;
14
16
  captureException(error, {
15
- request: { method: request.method, url: host ? `${proto}://${host}${request.path}` : request.path },
17
+ ...fromRequest(request.method, url, request.headers),
16
18
  tags: { route: context.routePath, routeType: context.routeType },
19
+ mechanism: { handler: "onRequestError", handled: false },
17
20
  });
18
21
  await flush();
19
22
  }
@@ -1,11 +1,13 @@
1
1
  // @bigbertha/sdk/node: uncaught exceptions and unhandled rejections, source lines from
2
2
  // disk, a heartbeat so quiet hours read as "no errors" rather than "no data", and a
3
3
  // handler wrapper.
4
- import { arch, platform } from "node:os";
4
+ import { createRequire } from "node:module";
5
+ import { arch, hostname, platform } from "node:os";
5
6
  import { gzipSync } from "node:zlib";
6
7
  import { Client } from "../core/client.js";
7
8
  import { envCommit, firstEnv } from "../core/env.js";
8
9
  import { getClient, setClient } from "../core/hub.js";
10
+ import { fromRequest } from "../core/request.js";
9
11
  import { endpoint, fetchTransport } from "../core/transport.js";
10
12
  import { VERSION } from "../core/version.js";
11
13
  import { addContext } from "./context.js";
@@ -32,7 +34,12 @@ export function init(options = {}) {
32
34
  release: options.release ?? firstEnv("BERTHA_RELEASE"),
33
35
  environment: options.environment ?? firstEnv("BERTHA_ENVIRONMENT", "VERCEL_ENV", "NODE_ENV"),
34
36
  };
35
- const client = new Client(opts, fetchTransport({ url, key, gzip: async (body) => new Uint8Array(gzipSync(body)) }), { name: "bertha.node", version: VERSION }, { runtime: `Node ${process.versions.node}`, os: `${platform()} ${arch()}` }, options.contextLines === false ? undefined : addContext);
37
+ const client = new Client(opts, fetchTransport({ url, key, gzip: async (body) => new Uint8Array(gzipSync(body)) }), { name: "bertha.node", version: VERSION }, {
38
+ runtime: `Node ${process.versions.node}`,
39
+ os: `${platform()} ${arch()}`,
40
+ host: process.env.DYNO ?? safe(hostname),
41
+ versions: installedVersions(),
42
+ }, options.contextLines === false ? undefined : addContext);
36
43
  setClient(client);
37
44
  // Only V8's default: a limit the app set itself was a choice.
38
45
  if (Error.stackTraceLimit === V8_DEFAULT_FRAMES)
@@ -71,7 +78,7 @@ function appHandles(event) {
71
78
  /** Reports, flushes and then, if the app isn't handling it, exits as Node would have. */
72
79
  async function crash(event, error) {
73
80
  const client = getClient();
74
- client?.captureException(error, { level: "fatal" });
81
+ client?.captureException(error, { level: "fatal", mechanism: { handler: event, handled: false } });
75
82
  await Promise.race([client?.flush(), new Promise((r) => setTimeout(r, EXIT_FLUSH_MS).unref())]);
76
83
  if (appHandles(event))
77
84
  return;
@@ -118,5 +125,28 @@ function requestContext(req) {
118
125
  const path = r.originalUrl ?? r.url ?? "/";
119
126
  const host = typeof r.headers?.host === "string" ? r.headers.host : undefined;
120
127
  const proto = typeof r.headers?.["x-forwarded-proto"] === "string" ? r.headers["x-forwarded-proto"] : "http";
121
- return { request: { method: r.method, url: host && path.startsWith("/") ? `${proto}://${host}${path}` : path } };
128
+ const url = host && path.startsWith("/") ? `${proto}://${host}${path}` : path;
129
+ return fromRequest(r.method, url, r.headers ?? {}, r.socket?.remoteAddress);
130
+ }
131
+ /** Node's version, and Next.js's when the app has it installed. */
132
+ function installedVersions() {
133
+ const out = { node: process.versions.node };
134
+ try {
135
+ const require = createRequire(`${process.cwd()}/`);
136
+ const next = require("next/package.json").version;
137
+ if (next)
138
+ out.next = next;
139
+ }
140
+ catch {
141
+ // Not a Next.js app, or bundled without its package.json.
142
+ }
143
+ return out;
144
+ }
145
+ function safe(f) {
146
+ try {
147
+ return f();
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
122
152
  }
@@ -0,0 +1,30 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from "react";
2
+ export interface FallbackProps {
3
+ error: unknown;
4
+ /** The reported event's id, or undefined when it wasn't sent. */
5
+ eventId: string | undefined;
6
+ /** Renders the children again. */
7
+ reset: () => void;
8
+ }
9
+ export interface ErrorBoundaryProps {
10
+ children?: ReactNode;
11
+ /** Shown in place of the children after an error. Nothing by default. */
12
+ fallback?: ReactNode | ((props: FallbackProps) => ReactNode);
13
+ /** Called after the error is reported. */
14
+ onError?: (error: unknown, componentStack: string | undefined, eventId: string | undefined) => void;
15
+ /** Tags on the reported event, e.g. which part of the page this boundary wraps. */
16
+ tags?: Record<string, string>;
17
+ }
18
+ interface State {
19
+ caught: boolean;
20
+ error: unknown;
21
+ eventId: string | undefined;
22
+ }
23
+ export declare class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
24
+ state: State;
25
+ static getDerivedStateFromError(error: unknown): Partial<State>;
26
+ componentDidCatch(error: unknown, info: ErrorInfo): void;
27
+ reset: () => void;
28
+ render(): ReactNode;
29
+ }
30
+ export {};
@@ -0,0 +1,33 @@
1
+ // @bigbertha/sdk/react: an error boundary that reports what it catches with React's
2
+ // component stack. Importing it also puts React's version on every event.
3
+ // <ErrorBoundary fallback={<p>Something went wrong.</p>}><App /></ErrorBoundary>
4
+ import { Component, version } from "react";
5
+ import { captureException } from "../core/hub.js";
6
+ import { registerVersion } from "../core/versions.js";
7
+ registerVersion("react", version);
8
+ const CLEAR = { caught: false, error: undefined, eventId: undefined };
9
+ export class ErrorBoundary extends Component {
10
+ state = CLEAR;
11
+ static getDerivedStateFromError(error) {
12
+ return { caught: true, error };
13
+ }
14
+ componentDidCatch(error, info) {
15
+ const componentStack = info.componentStack ?? undefined;
16
+ const eventId = captureException(error, {
17
+ mechanism: { handler: "ErrorBoundary", handled: true },
18
+ componentStack,
19
+ tags: this.props.tags,
20
+ });
21
+ this.setState({ eventId });
22
+ this.props.onError?.(error, componentStack, eventId);
23
+ }
24
+ reset = () => this.setState(CLEAR);
25
+ render() {
26
+ if (!this.state.caught)
27
+ return this.props.children;
28
+ const f = this.props.fallback;
29
+ if (typeof f === "function")
30
+ return f({ error: this.state.error, eventId: this.state.eventId, reset: this.reset });
31
+ return f ?? null;
32
+ }
33
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bigbertha/sdk",
3
- "version": "0.4.1",
4
- "description": "Error reporting for bertha: browser, Node, edge and Next.js, with source maps by debug id.",
3
+ "version": "0.5.0",
4
+ "description": "Error reporting for bertha: browser, React, Node, edge and Next.js, with source maps by debug id.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -12,6 +12,7 @@
12
12
  "keywords": [
13
13
  "error-tracking",
14
14
  "bertha",
15
+ "react",
15
16
  "nextjs",
16
17
  "vite",
17
18
  "source-maps"
@@ -21,7 +22,8 @@
21
22
  },
22
23
  "type": "module",
23
24
  "sideEffects": [
24
- "./dist/next/client.js"
25
+ "./dist/next/client.js",
26
+ "./dist/react/index.js"
25
27
  ],
26
28
  "files": [
27
29
  "dist"
@@ -31,6 +33,7 @@
31
33
  },
32
34
  "exports": {
33
35
  "./browser": "./dist/browser/index.js",
36
+ "./react": "./dist/react/index.js",
34
37
  "./node": "./dist/node/index.js",
35
38
  "./edge": "./dist/edge/index.js",
36
39
  "./next": "./dist/next/index.js",
@@ -45,6 +48,9 @@
45
48
  "browser": [
46
49
  "dist/browser/index.d.ts"
47
50
  ],
51
+ "react": [
52
+ "dist/react/index.d.ts"
53
+ ],
48
54
  "node": [
49
55
  "dist/node/index.d.ts"
50
56
  ],
@@ -71,6 +77,14 @@
71
77
  "engines": {
72
78
  "node": ">=20"
73
79
  },
80
+ "peerDependencies": {
81
+ "react": ">=17"
82
+ },
83
+ "peerDependenciesMeta": {
84
+ "react": {
85
+ "optional": true
86
+ }
87
+ },
74
88
  "scripts": {
75
89
  "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/bin.js",
76
90
  "check": "tsc -p tsconfig.json",
@@ -80,7 +94,9 @@
80
94
  },
81
95
  "devDependencies": {
82
96
  "@types/node": "^24.13.3",
97
+ "@types/react": "^19.3.0",
83
98
  "esbuild": "^0.27.0",
99
+ "react": "^19.3.1",
84
100
  "typescript": "~6.0.2",
85
101
  "vite": "^8.3.4",
86
102
  "vitest": "^4.0.0"