@bigbertha/sdk 0.4.1 → 0.5.1

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,31 @@ 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.
160
+
161
+ An app with its own error middleware passes the framework's request as `req` and gets
162
+ the same request, IP and trace:
163
+
164
+ ```ts
165
+ // Koa
166
+ app.on("error", (err, ctx) =>
167
+ captureException(err, { req: ctx, status: err.status ?? 500, mechanism: { handler: "koa", handled: false } }),
168
+ )
169
+ // Express, after the routes
170
+ app.use((err, req, res, next) => {
171
+ captureException(err, { req, status: 500, mechanism: { handler: "express", handled: false } })
172
+ next(err)
173
+ })
174
+ ```
175
+
176
+ `req` takes Node's `IncomingMessage`, Express's `req`, Koa's `ctx` or a Fetch `Request`
177
+ (the edge entry too). `mechanism` says the error escaped the app's own code, so bertha
178
+ doesn't read it as one the app caught. Server events also name the host (Heroku's `DYNO`, else the hostname) and the
179
+ Node and Next.js versions.
109
180
 
110
181
  For bundled Node code, give the output debug ids and upload its maps after the build:
111
182
 
@@ -126,6 +197,7 @@ Every entry exports the same functions:
126
197
 
127
198
  ```ts
128
199
  captureException(err, { level: "warning", tags: { job: "nightly" }, fingerprint: ["nightly-import"] })
200
+ // An error's own fields (code, status…) are sent with it, each as JSON.
129
201
  captureMessage("Payment provider slow", { level: "warning" })
130
202
  addBreadcrumb({ category: "cart", message: "Added 2 items" })
131
203
  setUser({ id: "u_42" }) // hashed on the server, never shown in full
@@ -142,8 +214,12 @@ await flush()
142
214
  | `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
215
  | `sampleRate` | `1` | Share of events to send. |
144
216
  | `beforeSend` | | Change an event, or return `null` to drop it. |
145
- | `scrub` | `true` | Redact emails, bearer tokens and secret query values. |
217
+ | `scrub` | `true` | Redact emails, bearer tokens and secret query values, and drop the error's own fields with credential names (`password`, `token`, `apiKey`…). |
146
218
  | `tags` | | Tags on every event. |
219
+ | `versions` | | More versions on every event, as `{ name: version }`. |
220
+ | `sendIp` | `true` | Browser: store the visitor's IP. |
221
+ | `tracePropagationTargets` | same origin only | Browser: other origins whose requests get a `traceparent`. |
222
+ | `breadcrumbs` | all on | Browser: `fetch`, `xhr`, `navigation`, `click`, `console`. |
147
223
  | `debug` | `false` | Log what the SDK does. |
148
224
 
149
225
  ## Deploys
@@ -156,7 +232,8 @@ npx bertha deploy --env production
156
232
 
157
233
  It sends the release and the last commit's sha, message and author. The release page
158
234
  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.
235
+ unknown release also creates the release, and bertha marks it as only seen, not deployed.
236
+ When the release is a full commit sha, bertha takes it as the commit.
160
237
 
161
238
  ## Development
162
239
 
@@ -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,18 @@ 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;
10
+ /** Reads a framework's request passed as `req`. Server entries only, to keep the browser small. */
11
+ export type ReadRequest = (req: unknown) => CaptureContext;
8
12
  export declare class Client {
9
13
  readonly options: Options;
10
14
  private readonly transport;
11
15
  private readonly sdk;
12
16
  private readonly context;
13
17
  private readonly addContext;
18
+ private readonly enrich;
19
+ private readonly readRequest;
14
20
  private user;
15
21
  private tags;
16
22
  private crumbs;
@@ -21,7 +27,7 @@ export declare class Client {
21
27
  private window;
22
28
  private pausedUntil;
23
29
  private warned;
24
- constructor(options: Options, transport: Transport, sdk: IngestSdk, context?: Context, addContext?: AddContext);
30
+ constructor(options: Options, transport: Transport, sdk: IngestSdk, context?: Context, addContext?: AddContext, enrich?: Enrich, readRequest?: ReadRequest);
25
31
  captureException(error: unknown, ctx?: CaptureContext): string | undefined;
26
32
  captureMessage(message: string, ctx?: CaptureContext): string | undefined;
27
33
  setUser(user: IngestUser | null): void;
@@ -3,18 +3,25 @@
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;
24
+ readRequest;
18
25
  user;
19
26
  tags;
20
27
  crumbs = [];
@@ -25,12 +32,14 @@ export class Client {
25
32
  window = { start: 0, count: 0 };
26
33
  pausedUntil = 0;
27
34
  warned = false;
28
- constructor(options, transport, sdk, context, addContext) {
35
+ constructor(options, transport, sdk, context, addContext, enrich, readRequest) {
29
36
  this.options = options;
30
37
  this.transport = transport;
31
38
  this.sdk = sdk;
32
39
  this.context = context;
33
40
  this.addContext = addContext;
41
+ this.enrich = enrich;
42
+ this.readRequest = readRequest;
34
43
  this.tags = { ...options.tags };
35
44
  }
36
45
  captureException(error, ctx = {}) {
@@ -41,6 +50,8 @@ export class Client {
41
50
  this.seen.add(error);
42
51
  }
43
52
  const exception = toException(error, 0);
53
+ if (ctx.componentStack)
54
+ exception.componentStack = ctx.componentStack;
44
55
  if (this.addContext) {
45
56
  try {
46
57
  for (let x = exception; x; x = x.cause)
@@ -50,10 +61,12 @@ export class Client {
50
61
  this.log("couldn't read source lines", e);
51
62
  }
52
63
  }
53
- return this.capture({ exception, level: ctx.level ?? "error" }, ctx);
64
+ const mechanism = ctx.mechanism ?? { handler: "captureException", handled: true };
65
+ return this.capture({ exception, mechanism, level: ctx.level ?? "error" }, ctx);
54
66
  }
55
67
  captureMessage(message, ctx = {}) {
56
- return this.capture({ message, level: ctx.level ?? "warning" }, ctx);
68
+ const mechanism = ctx.mechanism ?? { handler: "captureMessage", handled: true };
69
+ return this.capture({ message, mechanism, level: ctx.level ?? "warning" }, ctx);
57
70
  }
58
71
  setUser(user) {
59
72
  this.user = user ?? undefined;
@@ -90,6 +103,9 @@ export class Client {
90
103
  if (++this.window.count > PER_MINUTE)
91
104
  return undefined;
92
105
  const id = uuid();
106
+ const fromReq = (ctx.req !== undefined && this.readRequest?.(ctx.req)) || {};
107
+ const request = ctx.request ?? fromReq.request;
108
+ const user = fromReq.user || ctx.user ? { ...fromReq.user, ...ctx.user } : undefined;
93
109
  let event = {
94
110
  ...base,
95
111
  id,
@@ -97,12 +113,19 @@ export class Client {
97
113
  release: o.release ?? builtRelease(),
98
114
  environment: o.environment,
99
115
  breadcrumbs: this.crumbs.slice(),
100
- user: this.user,
101
- context: this.context,
116
+ user: user ? { ...this.user, ...user } : this.user,
117
+ context: { ...this.context, versions: { ...versions(), ...o.versions, ...this.context?.versions } },
102
118
  tags: { ...this.tags, ...ctx.tags },
103
119
  fingerprint: ctx.fingerprint,
104
- request: ctx.request,
120
+ request: request && ctx.status !== undefined ? { ...request, status: ctx.status } : request,
121
+ trace: ctx.trace ?? fromReq.trace,
105
122
  };
123
+ try {
124
+ this.enrich?.(event);
125
+ }
126
+ catch (e) {
127
+ this.log("couldn't read the page's state", e);
128
+ }
106
129
  if (o.scrub !== false)
107
130
  scrubEvent(event);
108
131
  if (event.exception)
@@ -169,10 +192,34 @@ export function toException(error, depth) {
169
192
  message: String(e.message ?? ""),
170
193
  frames: parseStack(e.stack),
171
194
  };
195
+ const data = ownFields(e);
196
+ if (data)
197
+ out.data = data;
172
198
  if (e.cause !== undefined && depth < MAX_CAUSES)
173
199
  out.cause = toException(e.cause, depth + 1);
174
200
  return out;
175
201
  }
202
+ /** The error's own fields (`code`, `status`, a response…), each as JSON. */
203
+ function ownFields(e) {
204
+ let out;
205
+ let n = 0;
206
+ for (const key of Object.keys(e)) {
207
+ if (key === "name" || key === "message" || key === "stack" || key === "cause")
208
+ continue;
209
+ if (n++ >= DATA_FIELDS)
210
+ break;
211
+ let text;
212
+ try {
213
+ text = JSON.stringify(e[key]) ?? String(e[key]);
214
+ }
215
+ catch {
216
+ text = String(e[key]);
217
+ }
218
+ ;
219
+ (out ??= {})[key] = text.length > DATA_CHARS ? `${text.slice(0, DATA_CHARS - 1)}…` : text;
220
+ }
221
+ return out;
222
+ }
176
223
  function isErrorLike(v) {
177
224
  return !!v && typeof v === "object" && "message" in v && "stack" in v;
178
225
  }
@@ -194,11 +241,19 @@ function* files(e) {
194
241
  function scrubEvent(e) {
195
242
  if (e.message)
196
243
  e.message = scrubText(e.message);
197
- for (let x = e.exception; x; x = x.cause)
244
+ for (let x = e.exception; x; x = x.cause) {
198
245
  x.message = scrubText(x.message);
246
+ if (x.data) {
247
+ x.data = Object.fromEntries(Object.entries(x.data)
248
+ .filter(([k]) => !SENSITIVE_KEY.test(k))
249
+ .map(([k, v]) => [k, scrubText(v)]));
250
+ }
251
+ }
199
252
  e.breadcrumbs = e.breadcrumbs?.map((b) => ({ ...b, message: scrubText(b.message) }));
200
253
  if (e.request)
201
254
  e.request = { ...e.request, url: scrubUrl(e.request.url) };
255
+ if (e.context?.referrer)
256
+ e.context = { ...e.context, referrer: scrubUrl(e.context.referrer) };
202
257
  }
203
258
  export function uuid() {
204
259
  // randomUUID is missing on plain-http pages.
@@ -0,0 +1,12 @@
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;
7
+ /**
8
+ * The same from a framework's own request: Node's `IncomingMessage`, Express's `req`,
9
+ * Koa's `ctx` or `ctx.req`, or a Fetch `Request` (edge, Hono's `c.req.raw`). Anything
10
+ * else gives nothing.
11
+ */
12
+ export declare function fromAnyRequest(req: unknown): CaptureContext;
@@ -0,0 +1,54 @@
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
+ }
32
+ /**
33
+ * The same from a framework's own request: Node's `IncomingMessage`, Express's `req`,
34
+ * Koa's `ctx` or `ctx.req`, or a Fetch `Request` (edge, Hono's `c.req.raw`). Anything
35
+ * else gives nothing.
36
+ */
37
+ export function fromAnyRequest(req) {
38
+ const r = req;
39
+ if (!r || typeof r !== "object" || typeof r.method !== "string" || !r.headers || typeof r.headers !== "object")
40
+ return {};
41
+ let headers;
42
+ if (typeof r.headers.forEach === "function") {
43
+ headers = {};
44
+ r.headers.forEach((v, k) => (headers[k] = v));
45
+ }
46
+ else
47
+ headers = r.headers;
48
+ const path = typeof r.originalUrl === "string" ? r.originalUrl : typeof r.url === "string" ? r.url : "/";
49
+ const first = (v) => (typeof v === "string" ? v.split(",")[0]?.trim() : undefined);
50
+ const host = first(headers.host) ?? first(headers.Host);
51
+ const proto = first(headers["x-forwarded-proto"]) ?? "http";
52
+ const url = host && path.startsWith("/") ? `${proto}://${host}${path}` : path;
53
+ return fromRequest(r.method, url, headers, r.socket?.remoteAddress);
54
+ }
@@ -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,29 @@ 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
+ /**
31
+ * The framework's request: Node's `IncomingMessage`, Express's `req`, Koa's `ctx`, or a
32
+ * Fetch `Request`. Its method, URL, headers (never credentials), the client IP and an
33
+ * incoming `traceparent` are sent. `request`, `user` and `trace` win over what it gives.
34
+ */
35
+ req?: unknown;
36
+ /** The response status, sent with the request. */
37
+ status?: number;
38
+ /** Merged over the user from `setUser`. */
39
+ user?: IngestUser;
40
+ /** How the error reached the SDK. Defaults to `captureException`, handled. */
41
+ mechanism?: IngestMechanism;
42
+ trace?: IngestTrace;
43
+ /** React's component stack, from an error boundary. */
44
+ componentStack?: string;
28
45
  }
29
46
  /** What a send came back with; "offline" means it never reached bertha. */
30
47
  export type SendResult = {
@@ -1 +1 @@
1
- export declare const VERSION = "0.4.1";
1
+ export declare const VERSION = "0.5.1";
@@ -1 +1 @@
1
- export const VERSION = "0.4.1";
1
+ export const VERSION = "0.5.1";
@@ -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
+ }
@@ -3,6 +3,7 @@
3
3
  import { Client } from "../core/client.js";
4
4
  import { firstEnv } from "../core/env.js";
5
5
  import { setClient } from "../core/hub.js";
6
+ import { fromAnyRequest } from "../core/request.js";
6
7
  import { fetchTransport, streamGzip } from "../core/transport.js";
7
8
  import { VERSION } from "../core/version.js";
8
9
  export { addBreadcrumb, captureException, captureMessage, flush, getClient, setTag, setUser } from "../core/hub.js";
@@ -19,7 +20,7 @@ export function init(options = {}) {
19
20
  url,
20
21
  release: options.release ?? firstEnv("BERTHA_RELEASE"),
21
22
  environment: options.environment ?? firstEnv("BERTHA_ENVIRONMENT", "VERCEL_ENV", "NODE_ENV"),
22
- }, fetchTransport({ url, key, gzip: streamGzip }), { name: "bertha.edge", version: VERSION }, { runtime: "Edge" });
23
+ }, fetchTransport({ url, key, gzip: streamGzip }), { name: "bertha.edge", version: VERSION }, { runtime: "Edge" }, undefined, undefined, fromAnyRequest);
23
24
  setClient(client);
24
25
  return client;
25
26
  }
@@ -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 { fromAnyRequest } 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, undefined, fromAnyRequest);
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;
@@ -99,7 +106,7 @@ export async function sendHeartbeat(o) {
99
106
  export function wrapHandler(handler) {
100
107
  return function (...args) {
101
108
  const report = (error) => {
102
- getClient()?.captureException(error, requestContext(args[0]));
109
+ getClient()?.captureException(error, { req: args[0], mechanism: { handler: "wrapHandler", handled: false } });
103
110
  throw error;
104
111
  };
105
112
  try {
@@ -111,12 +118,25 @@ export function wrapHandler(handler) {
111
118
  }
112
119
  };
113
120
  }
114
- function requestContext(req) {
115
- const r = req;
116
- if (!r || typeof r.method !== "string")
117
- return {};
118
- const path = r.originalUrl ?? r.url ?? "/";
119
- const host = typeof r.headers?.host === "string" ? r.headers.host : undefined;
120
- 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 } };
121
+ /** Node's version, and Next.js's when the app has it installed. */
122
+ function installedVersions() {
123
+ const out = { node: process.versions.node };
124
+ try {
125
+ const require = createRequire(`${process.cwd()}/`);
126
+ const next = require("next/package.json").version;
127
+ if (next)
128
+ out.next = next;
129
+ }
130
+ catch {
131
+ // Not a Next.js app, or bundled without its package.json.
132
+ }
133
+ return out;
134
+ }
135
+ function safe(f) {
136
+ try {
137
+ return f();
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
122
142
  }
@@ -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.1",
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"