@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 +66 -8
- package/dist/browser/index.d.ts +11 -1
- package/dist/browser/index.js +104 -20
- package/dist/build/deploy.d.ts +18 -0
- package/dist/build/deploy.js +32 -0
- package/dist/build/index.d.ts +1 -0
- package/dist/build/index.js +1 -0
- package/dist/cli.js +7 -23
- package/dist/core/client.d.ts +4 -1
- package/dist/core/client.js +56 -6
- package/dist/core/request.d.ts +6 -0
- package/dist/core/request.js +31 -0
- package/dist/core/stack.js +3 -1
- package/dist/core/trace.d.ts +6 -0
- package/dist/core/trace.js +25 -0
- package/dist/core/types.d.ts +11 -2
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/dist/core/versions.d.ts +2 -0
- package/dist/core/versions.js +12 -0
- package/dist/generated/ingest.d.ts +79 -0
- package/dist/next/config.d.ts +5 -0
- package/dist/next/config.js +16 -5
- package/dist/next/index.js +4 -1
- package/dist/node/index.js +34 -4
- package/dist/react/index.d.ts +30 -0
- package/dist/react/index.js +33 -0
- package/package.json +19 -3
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
|
|
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
|
|
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,
|
|
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
|
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/browser/index.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
// @bigbertha/sdk/browser: uncaught errors and rejections, with
|
|
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), {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/build/index.d.ts
CHANGED
package/dist/build/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
52
|
+
const environment = v.env ?? "production";
|
|
54
53
|
// Without a checkout (a host's build step), the host still names the commit.
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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];
|
package/dist/core/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/core/stack.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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 = {
|
package/dist/core/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.5.0";
|
package/dist/core/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.5.0";
|
|
@@ -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;
|
package/dist/next/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/next/config.js
CHANGED
|
@@ -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
|
|
34
|
-
uploadKey
|
|
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
|
};
|
package/dist/next/index.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/node/index.js
CHANGED
|
@@ -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 {
|
|
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 }, {
|
|
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
|
-
|
|
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
|
-
"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"
|