@bigbertha/sdk 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +153 -2
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/browser/index.d.ts +9 -0
- package/dist/browser/index.js +164 -0
- package/dist/browser/user-agent.d.ts +4 -0
- package/dist/browser/user-agent.js +38 -0
- package/dist/build/index.d.ts +39 -0
- package/dist/build/index.js +177 -0
- package/dist/build/release.d.ts +8 -0
- package/dist/build/release.js +36 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +89 -0
- package/dist/core/client.d.ts +39 -0
- package/dist/core/client.js +211 -0
- package/dist/core/debug-ids.d.ts +3 -0
- package/dist/core/debug-ids.js +35 -0
- package/dist/core/env.d.ts +2 -0
- package/dist/core/env.js +18 -0
- package/dist/core/hub.d.ts +12 -0
- package/dist/core/hub.js +27 -0
- package/dist/core/scrub.d.ts +2 -0
- package/dist/core/scrub.js +11 -0
- package/dist/core/stack.d.ts +5 -0
- package/dist/core/stack.js +45 -0
- package/dist/core/transport.d.ts +15 -0
- package/dist/core/transport.js +48 -0
- package/dist/core/types.d.ts +37 -0
- package/dist/core/types.js +1 -0
- package/dist/core/version.d.ts +1 -0
- package/dist/core/version.js +1 -0
- package/dist/edge/index.d.ts +5 -0
- package/dist/edge/index.js +25 -0
- package/dist/generated/ingest.d.ts +153 -0
- package/dist/generated/ingest.js +2 -0
- package/dist/next/client.d.ts +2 -0
- package/dist/next/client.js +34 -0
- package/dist/next/config.d.ts +8 -0
- package/dist/next/config.js +36 -0
- package/dist/next/index.d.ts +16 -0
- package/dist/next/index.js +22 -0
- package/dist/node/index.d.ts +19 -0
- package/dist/node/index.js +98 -0
- package/dist/vite/index.d.ts +44 -0
- package/dist/vite/index.js +63 -0
- package/package.json +58 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// bertha deploy | bertha sourcemaps upload <dir>
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { detectRelease, lastCommit, processDirectory, reason } from "./build/index.js";
|
|
4
|
+
import { VERSION } from "./core/version.js";
|
|
5
|
+
const HELP = `bertha ${VERSION}
|
|
6
|
+
|
|
7
|
+
Usage
|
|
8
|
+
bertha deploy [--release <r>] [--env <environment>]
|
|
9
|
+
Record a deploy, with the last commit's details.
|
|
10
|
+
bertha sourcemaps upload <dir> [--release <r>] [--keep-maps]
|
|
11
|
+
Give bundles in <dir> debug ids, upload their maps and remove the maps.
|
|
12
|
+
|
|
13
|
+
Options
|
|
14
|
+
--url <url> bertha's URL. Default: BERTHA_URL, NEXT_PUBLIC_BERTHA_URL or VITE_BERTHA_URL.
|
|
15
|
+
--key <key> The project's upload key. Default: BERTHA_UPLOAD_KEY.
|
|
16
|
+
|
|
17
|
+
The release defaults to BERTHA_RELEASE, the CI's commit sha, or git HEAD.`;
|
|
18
|
+
export async function main(argv, out = console.log) {
|
|
19
|
+
let args;
|
|
20
|
+
try {
|
|
21
|
+
args = parseArgs({
|
|
22
|
+
args: argv,
|
|
23
|
+
allowPositionals: true,
|
|
24
|
+
options: {
|
|
25
|
+
release: { type: "string" },
|
|
26
|
+
env: { type: "string" },
|
|
27
|
+
url: { type: "string" },
|
|
28
|
+
key: { type: "string" },
|
|
29
|
+
"keep-maps": { type: "boolean" },
|
|
30
|
+
help: { type: "boolean", short: "h" },
|
|
31
|
+
version: { type: "boolean", short: "v" },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
out(`${e.message}\n\n${HELP}`);
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
const { values: v, positionals: [command, ...rest] } = args;
|
|
40
|
+
if (v.version)
|
|
41
|
+
return out(VERSION), 0;
|
|
42
|
+
if (v.help || !command)
|
|
43
|
+
return out(HELP), command || v.help ? 0 : 2;
|
|
44
|
+
const url = v.url ?? process.env.BERTHA_URL ?? process.env.NEXT_PUBLIC_BERTHA_URL ?? process.env.VITE_BERTHA_URL;
|
|
45
|
+
const key = v.key ?? process.env.BERTHA_UPLOAD_KEY;
|
|
46
|
+
const release = v.release ?? detectRelease();
|
|
47
|
+
if (command === "deploy") {
|
|
48
|
+
if (!url || !key)
|
|
49
|
+
return out("Set BERTHA_URL and BERTHA_UPLOAD_KEY, or pass --url and --key."), 1;
|
|
50
|
+
if (!release)
|
|
51
|
+
return out("No release found. Pass --release or set BERTHA_RELEASE."), 1;
|
|
52
|
+
const body = { release, environment: v.env ?? "production" };
|
|
53
|
+
const commit = lastCommit();
|
|
54
|
+
if (commit)
|
|
55
|
+
body.commit = commit;
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(`${url.replace(/\/+$/, "")}/ingest/v1/deploys`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
signal: AbortSignal.timeout(30_000),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok)
|
|
64
|
+
return out(`bertha didn't record the deploy: ${await reason(res)}`), 1;
|
|
65
|
+
const recorded = (await res.json());
|
|
66
|
+
out(`Recorded ${release} in ${body.environment} (${recorded.releaseId}).`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
return out(`Couldn't reach bertha at ${url}: ${e.message}`), 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (command === "sourcemaps" && rest[0] === "upload") {
|
|
74
|
+
const dir = rest[1];
|
|
75
|
+
if (!dir)
|
|
76
|
+
return out("Which directory? bertha sourcemaps upload <dir>"), 2;
|
|
77
|
+
if (!url || !key)
|
|
78
|
+
return out("Set BERTHA_URL and BERTHA_UPLOAD_KEY, or pass --url and --key."), 1;
|
|
79
|
+
try {
|
|
80
|
+
const report = await processDirectory({ dir, release, url, uploadKey: key, keepMaps: v["keep-maps"], log: out });
|
|
81
|
+
return report.failed > 0 ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return out(`Couldn't process ${dir}: ${e.message}`), 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
out(`Unknown command: ${[command, ...rest].join(" ")}\n\n${HELP}`);
|
|
88
|
+
return 2;
|
|
89
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { IngestException, IngestSdk } from "../generated/ingest.ts";
|
|
2
|
+
import type { CaptureContext, IngestBreadcrumb, IngestEvent, IngestUser, Options, Transport } from "./types.ts";
|
|
3
|
+
export declare const MAX_BREADCRUMBS = 100;
|
|
4
|
+
export declare const BATCH = 100;
|
|
5
|
+
type Context = IngestEvent["context"];
|
|
6
|
+
export declare class Client {
|
|
7
|
+
readonly options: Options;
|
|
8
|
+
private readonly transport;
|
|
9
|
+
private readonly sdk;
|
|
10
|
+
private readonly context;
|
|
11
|
+
private user;
|
|
12
|
+
private tags;
|
|
13
|
+
private crumbs;
|
|
14
|
+
private queue;
|
|
15
|
+
private timer;
|
|
16
|
+
private sending;
|
|
17
|
+
private seen;
|
|
18
|
+
private window;
|
|
19
|
+
private pausedUntil;
|
|
20
|
+
private warned;
|
|
21
|
+
constructor(options: Options, transport: Transport, sdk: IngestSdk, context?: Context);
|
|
22
|
+
captureException(error: unknown, ctx?: CaptureContext): string | undefined;
|
|
23
|
+
captureMessage(message: string, ctx?: CaptureContext): string | undefined;
|
|
24
|
+
setUser(user: IngestUser | null): void;
|
|
25
|
+
setTag(key: string, value: string | null): void;
|
|
26
|
+
addBreadcrumb(crumb: Omit<IngestBreadcrumb, "timestamp"> & {
|
|
27
|
+
timestamp?: string;
|
|
28
|
+
}): void;
|
|
29
|
+
/** Sends everything queued. `final` when the page or process is going away. */
|
|
30
|
+
flush(final?: boolean): Promise<void>;
|
|
31
|
+
private capture;
|
|
32
|
+
private drain;
|
|
33
|
+
private log;
|
|
34
|
+
}
|
|
35
|
+
export declare function toException(error: unknown, depth: number): IngestException;
|
|
36
|
+
export declare function uuid(): string;
|
|
37
|
+
/** Seconds from a 429 body or Retry-After header. */
|
|
38
|
+
export declare function retryAfter(res: Response): Promise<number | undefined>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// One client per app: builds events, scrubs them, batches them and sends them,
|
|
2
|
+
// backing off when bertha asks it to.
|
|
3
|
+
import { builtRelease, debugIdsFor } from "./debug-ids.js";
|
|
4
|
+
import { scrubText, scrubUrl } from "./scrub.js";
|
|
5
|
+
import { parseStack } from "./stack.js";
|
|
6
|
+
export const MAX_BREADCRUMBS = 100;
|
|
7
|
+
export const BATCH = 100;
|
|
8
|
+
const DEBOUNCE_MS = 1000;
|
|
9
|
+
const PER_MINUTE = 100;
|
|
10
|
+
const MAX_CAUSES = 5;
|
|
11
|
+
const RETRIES = 3;
|
|
12
|
+
export class Client {
|
|
13
|
+
options;
|
|
14
|
+
transport;
|
|
15
|
+
sdk;
|
|
16
|
+
context;
|
|
17
|
+
user;
|
|
18
|
+
tags;
|
|
19
|
+
crumbs = [];
|
|
20
|
+
queue = [];
|
|
21
|
+
timer;
|
|
22
|
+
sending;
|
|
23
|
+
seen = new WeakSet();
|
|
24
|
+
window = { start: 0, count: 0 };
|
|
25
|
+
pausedUntil = 0;
|
|
26
|
+
warned = false;
|
|
27
|
+
constructor(options, transport, sdk, context) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.transport = transport;
|
|
30
|
+
this.sdk = sdk;
|
|
31
|
+
this.context = context;
|
|
32
|
+
this.tags = { ...options.tags };
|
|
33
|
+
}
|
|
34
|
+
captureException(error, ctx = {}) {
|
|
35
|
+
if (error && typeof error === "object") {
|
|
36
|
+
// The same error object often arrives twice (a boundary, then the global handler).
|
|
37
|
+
if (this.seen.has(error))
|
|
38
|
+
return undefined;
|
|
39
|
+
this.seen.add(error);
|
|
40
|
+
}
|
|
41
|
+
return this.capture({ exception: toException(error, 0), level: ctx.level ?? "error" }, ctx);
|
|
42
|
+
}
|
|
43
|
+
captureMessage(message, ctx = {}) {
|
|
44
|
+
return this.capture({ message, level: ctx.level ?? "warning" }, ctx);
|
|
45
|
+
}
|
|
46
|
+
setUser(user) {
|
|
47
|
+
this.user = user ?? undefined;
|
|
48
|
+
}
|
|
49
|
+
setTag(key, value) {
|
|
50
|
+
if (value === null)
|
|
51
|
+
delete this.tags[key];
|
|
52
|
+
else
|
|
53
|
+
this.tags[key] = value;
|
|
54
|
+
}
|
|
55
|
+
addBreadcrumb(crumb) {
|
|
56
|
+
this.crumbs.push({ ...crumb, timestamp: crumb.timestamp ?? new Date().toISOString() });
|
|
57
|
+
if (this.crumbs.length > MAX_BREADCRUMBS)
|
|
58
|
+
this.crumbs.shift();
|
|
59
|
+
}
|
|
60
|
+
/** Sends everything queued. `final` when the page or process is going away. */
|
|
61
|
+
async flush(final = false) {
|
|
62
|
+
clearTimeout(this.timer);
|
|
63
|
+
this.timer = undefined;
|
|
64
|
+
while (this.sending)
|
|
65
|
+
await this.sending;
|
|
66
|
+
if (!this.queue.length)
|
|
67
|
+
return;
|
|
68
|
+
this.sending = this.drain(final).finally(() => (this.sending = undefined));
|
|
69
|
+
await this.sending;
|
|
70
|
+
}
|
|
71
|
+
capture(base, ctx) {
|
|
72
|
+
const o = this.options;
|
|
73
|
+
if (o.sampleRate !== undefined && Math.random() >= o.sampleRate)
|
|
74
|
+
return undefined;
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
if (now - this.window.start > 60_000)
|
|
77
|
+
this.window = { start: now, count: 0 };
|
|
78
|
+
if (++this.window.count > PER_MINUTE)
|
|
79
|
+
return undefined;
|
|
80
|
+
const id = uuid();
|
|
81
|
+
let event = {
|
|
82
|
+
...base,
|
|
83
|
+
id,
|
|
84
|
+
timestamp: new Date(now).toISOString(),
|
|
85
|
+
release: o.release ?? builtRelease(),
|
|
86
|
+
environment: o.environment,
|
|
87
|
+
breadcrumbs: this.crumbs.slice(),
|
|
88
|
+
user: this.user,
|
|
89
|
+
context: this.context,
|
|
90
|
+
tags: { ...this.tags, ...ctx.tags },
|
|
91
|
+
fingerprint: ctx.fingerprint,
|
|
92
|
+
request: ctx.request,
|
|
93
|
+
};
|
|
94
|
+
if (o.scrub !== false)
|
|
95
|
+
scrubEvent(event);
|
|
96
|
+
if (event.exception)
|
|
97
|
+
event.debugIds = debugIdsFor(files(event.exception));
|
|
98
|
+
try {
|
|
99
|
+
event = o.beforeSend ? o.beforeSend(event) : event;
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
this.log("beforeSend threw; sending the event as it was", e);
|
|
103
|
+
}
|
|
104
|
+
if (!event)
|
|
105
|
+
return undefined;
|
|
106
|
+
this.queue.push(event);
|
|
107
|
+
this.log("queued", event);
|
|
108
|
+
if (this.queue.length >= BATCH)
|
|
109
|
+
void this.flush();
|
|
110
|
+
else
|
|
111
|
+
this.timer ??= setTimeout(() => void this.flush(), DEBOUNCE_MS);
|
|
112
|
+
return id;
|
|
113
|
+
}
|
|
114
|
+
async drain(final) {
|
|
115
|
+
while (this.queue.length) {
|
|
116
|
+
if (Date.now() < this.pausedUntil && !final) {
|
|
117
|
+
this.timer ??= setTimeout(() => void this.flush(), this.pausedUntil - Date.now());
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const batch = this.queue.splice(0, BATCH);
|
|
121
|
+
const body = JSON.stringify({ sdk: this.sdk, events: batch });
|
|
122
|
+
let attempt = 0;
|
|
123
|
+
for (;;) {
|
|
124
|
+
const res = await this.transport.send(body, final).catch(() => "offline");
|
|
125
|
+
if (res === "offline") {
|
|
126
|
+
if (final || ++attempt > RETRIES)
|
|
127
|
+
break;
|
|
128
|
+
await sleep(500 * 2 ** attempt);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (res.status === 429 || res.status === 503) {
|
|
132
|
+
this.pausedUntil = Date.now() + (res.retryAfter ?? 60) * 1000;
|
|
133
|
+
if (!final)
|
|
134
|
+
this.queue.unshift(...batch);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
if (res.status >= 400 && !this.warned) {
|
|
138
|
+
this.warned = true;
|
|
139
|
+
console.warn(`[bertha] bertha refused the events (HTTP ${res.status}). Check the key and URL.`);
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
log(...args) {
|
|
146
|
+
if (this.options.debug)
|
|
147
|
+
console.debug("[bertha]", ...args);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export function toException(error, depth) {
|
|
151
|
+
if (!(error instanceof Error) && !isErrorLike(error)) {
|
|
152
|
+
return { type: "Error", message: describe(error), frames: [] };
|
|
153
|
+
}
|
|
154
|
+
const e = error;
|
|
155
|
+
const out = {
|
|
156
|
+
type: e.name || e.constructor?.name || "Error",
|
|
157
|
+
message: String(e.message ?? ""),
|
|
158
|
+
frames: parseStack(e.stack),
|
|
159
|
+
};
|
|
160
|
+
if (e.cause !== undefined && depth < MAX_CAUSES)
|
|
161
|
+
out.cause = toException(e.cause, depth + 1);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function isErrorLike(v) {
|
|
165
|
+
return !!v && typeof v === "object" && "message" in v && "stack" in v;
|
|
166
|
+
}
|
|
167
|
+
function describe(v) {
|
|
168
|
+
if (typeof v === "string")
|
|
169
|
+
return v;
|
|
170
|
+
try {
|
|
171
|
+
return `Non-error thrown: ${JSON.stringify(v)?.slice(0, 500) ?? String(v)}`;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return `Non-error thrown: ${String(v)}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function* files(e) {
|
|
178
|
+
for (; e; e = e.cause)
|
|
179
|
+
for (const f of e.frames)
|
|
180
|
+
yield f.file;
|
|
181
|
+
}
|
|
182
|
+
function scrubEvent(e) {
|
|
183
|
+
if (e.message)
|
|
184
|
+
e.message = scrubText(e.message);
|
|
185
|
+
for (let x = e.exception; x; x = x.cause)
|
|
186
|
+
x.message = scrubText(x.message);
|
|
187
|
+
e.breadcrumbs = e.breadcrumbs?.map((b) => ({ ...b, message: scrubText(b.message) }));
|
|
188
|
+
if (e.request)
|
|
189
|
+
e.request = { ...e.request, url: scrubUrl(e.request.url) };
|
|
190
|
+
}
|
|
191
|
+
export function uuid() {
|
|
192
|
+
// randomUUID is missing on plain-http pages.
|
|
193
|
+
const c = globalThis.crypto;
|
|
194
|
+
if (c?.randomUUID)
|
|
195
|
+
return c.randomUUID();
|
|
196
|
+
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => (Number(c) ^ ((Math.random() * 16) >> (Number(c) / 4))).toString(16));
|
|
197
|
+
}
|
|
198
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
199
|
+
/** Seconds from a 429 body or Retry-After header. */
|
|
200
|
+
export async function retryAfter(res) {
|
|
201
|
+
const header = Number(res.headers.get("retry-after"));
|
|
202
|
+
if (header > 0)
|
|
203
|
+
return header;
|
|
204
|
+
try {
|
|
205
|
+
const body = (await res.json());
|
|
206
|
+
return body.retryAfter;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Which bundle files have uploaded source maps. The build step appends a line to
|
|
2
|
+
// each bundle that records `new Error().stack` against the bundle's debug id; here
|
|
3
|
+
// those stacks are read back to learn each bundle's URL (whatever CDN or path it was
|
|
4
|
+
// served from).
|
|
5
|
+
import { parseStack } from "./stack.js";
|
|
6
|
+
const byFile = new Map();
|
|
7
|
+
let seen = 0;
|
|
8
|
+
function registry() {
|
|
9
|
+
return globalThis;
|
|
10
|
+
}
|
|
11
|
+
export function debugIdsFor(files) {
|
|
12
|
+
const ids = registry().__berthaIds;
|
|
13
|
+
if (!ids)
|
|
14
|
+
return undefined;
|
|
15
|
+
const stacks = Object.keys(ids);
|
|
16
|
+
if (stacks.length !== seen) {
|
|
17
|
+
for (const stack of stacks) {
|
|
18
|
+
const file = parseStack(stack)[0]?.file;
|
|
19
|
+
if (file)
|
|
20
|
+
byFile.set(file, ids[stack]);
|
|
21
|
+
}
|
|
22
|
+
seen = stacks.length;
|
|
23
|
+
}
|
|
24
|
+
let out;
|
|
25
|
+
for (const f of files) {
|
|
26
|
+
const id = byFile.get(f);
|
|
27
|
+
if (id)
|
|
28
|
+
(out ??= {})[f] = id;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** The release the build step stamped into the bundles, if any. */
|
|
33
|
+
export function builtRelease() {
|
|
34
|
+
return registry().__berthaRelease;
|
|
35
|
+
}
|
package/dist/core/env.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Environment variables, wherever the runtime keeps them.
|
|
2
|
+
export function env(name) {
|
|
3
|
+
try {
|
|
4
|
+
const v = globalThis.process?.env?.[name];
|
|
5
|
+
return v === "" ? undefined : v;
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function firstEnv(...names) {
|
|
12
|
+
for (const n of names) {
|
|
13
|
+
const v = env(n);
|
|
14
|
+
if (v)
|
|
15
|
+
return v;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Client } from "./client.ts";
|
|
2
|
+
import type { CaptureContext, IngestBreadcrumb, IngestUser } from "./types.ts";
|
|
3
|
+
export declare function setClient(c: Client | undefined): void;
|
|
4
|
+
export declare function getClient(): Client | undefined;
|
|
5
|
+
export declare function captureException(error: unknown, ctx?: CaptureContext): string | undefined;
|
|
6
|
+
export declare function captureMessage(message: string, ctx?: CaptureContext): string | undefined;
|
|
7
|
+
export declare function setUser(user: IngestUser | null): void;
|
|
8
|
+
export declare function setTag(key: string, value: string | null): void;
|
|
9
|
+
export declare function addBreadcrumb(crumb: Omit<IngestBreadcrumb, "timestamp"> & {
|
|
10
|
+
timestamp?: string;
|
|
11
|
+
}): void;
|
|
12
|
+
export declare function flush(): Promise<void>;
|
package/dist/core/hub.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The module-level client behind `captureException` and friends, so apps don't
|
|
2
|
+
// have to pass a client around.
|
|
3
|
+
let current;
|
|
4
|
+
export function setClient(c) {
|
|
5
|
+
current = c;
|
|
6
|
+
}
|
|
7
|
+
export function getClient() {
|
|
8
|
+
return current;
|
|
9
|
+
}
|
|
10
|
+
export function captureException(error, ctx) {
|
|
11
|
+
return current?.captureException(error, ctx);
|
|
12
|
+
}
|
|
13
|
+
export function captureMessage(message, ctx) {
|
|
14
|
+
return current?.captureMessage(message, ctx);
|
|
15
|
+
}
|
|
16
|
+
export function setUser(user) {
|
|
17
|
+
current?.setUser(user);
|
|
18
|
+
}
|
|
19
|
+
export function setTag(key, value) {
|
|
20
|
+
current?.setTag(key, value);
|
|
21
|
+
}
|
|
22
|
+
export function addBreadcrumb(crumb) {
|
|
23
|
+
current?.addBreadcrumb(crumb);
|
|
24
|
+
}
|
|
25
|
+
export function flush() {
|
|
26
|
+
return current?.flush() ?? Promise.resolve();
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// On by default: no email addresses, bearer tokens or secret-looking query values
|
|
2
|
+
// leave the app. Turn off with `scrub: false`.
|
|
3
|
+
const EMAIL = /[\w.+-]+@[\w-]+\.[\w.-]+/g;
|
|
4
|
+
const BEARER = /\b(Bearer|Basic)\s+[\w.~+/=-]+/gi;
|
|
5
|
+
const SECRET_PARAM = /([?&](?:[^=&#]*(?:token|key|secret|password|passwd|auth|session|code|sig)[^=&#]*)=)[^&#\s]*/gi;
|
|
6
|
+
export function scrubText(s) {
|
|
7
|
+
return s.replace(EMAIL, "[email]").replace(BEARER, "$1 [redacted]").replace(SECRET_PARAM, "$1[redacted]");
|
|
8
|
+
}
|
|
9
|
+
export function scrubUrl(url) {
|
|
10
|
+
return url.replace(SECRET_PARAM, "$1[redacted]");
|
|
11
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { IngestFrame } from "../generated/ingest.ts";
|
|
2
|
+
export declare const MAX_FRAMES = 100;
|
|
3
|
+
export declare function parseStack(stack: string | undefined): IngestFrame[];
|
|
4
|
+
/** Library code: the server decides the rest from the path. */
|
|
5
|
+
export declare function isLibrary(file: string): boolean;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Stack strings into frames, innermost first. V8 (Chrome, Node, Edge, Deno) and
|
|
2
|
+
// SpiderMonkey/JavaScriptCore (Firefox, Safari) formats.
|
|
3
|
+
const V8 = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?\s*$/;
|
|
4
|
+
const GECKO = /^\s*(.*?)@(.+?):(\d+):(\d+)\s*$/;
|
|
5
|
+
export const MAX_FRAMES = 100;
|
|
6
|
+
export function parseStack(stack) {
|
|
7
|
+
if (!stack)
|
|
8
|
+
return [];
|
|
9
|
+
const frames = [];
|
|
10
|
+
for (const line of stack.split("\n")) {
|
|
11
|
+
const m = V8.exec(line) ?? GECKO.exec(line);
|
|
12
|
+
if (!m)
|
|
13
|
+
continue;
|
|
14
|
+
const [, fn, rawFile, ln, col] = m;
|
|
15
|
+
let file = rawFile;
|
|
16
|
+
// `at eval (eval at f (file.js:1:2), <anonymous>:1:1)`: keep the outer file.
|
|
17
|
+
const evalAt = /\(eval at .*?\((.+?):\d+:\d+\)/.exec(line);
|
|
18
|
+
if (evalAt)
|
|
19
|
+
file = evalAt[1];
|
|
20
|
+
if (file === "<anonymous>" || file === "native")
|
|
21
|
+
continue;
|
|
22
|
+
const name = cleanFunction(fn);
|
|
23
|
+
const frame = { file, line: Number(ln), column: Number(col) };
|
|
24
|
+
if (name)
|
|
25
|
+
frame.function = name;
|
|
26
|
+
if (isLibrary(file))
|
|
27
|
+
frame.inApp = false;
|
|
28
|
+
frames.push(frame);
|
|
29
|
+
if (frames.length >= MAX_FRAMES)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
return frames;
|
|
33
|
+
}
|
|
34
|
+
function cleanFunction(fn) {
|
|
35
|
+
if (!fn)
|
|
36
|
+
return undefined;
|
|
37
|
+
const name = fn.replace(/^async /, "").replace(/^new /, "").replace(/ \[as .+\]$/, "").trim();
|
|
38
|
+
if (!name || name === "<anonymous>" || name === "Object.<anonymous>")
|
|
39
|
+
return undefined;
|
|
40
|
+
return name;
|
|
41
|
+
}
|
|
42
|
+
/** Library code: the server decides the rest from the path. */
|
|
43
|
+
export function isLibrary(file) {
|
|
44
|
+
return file.includes("/node_modules/") || file.startsWith("node:") || file.startsWith("internal/");
|
|
45
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Transport } from "./types.ts";
|
|
2
|
+
export interface FetchTransportOptions {
|
|
3
|
+
url: string;
|
|
4
|
+
key: string;
|
|
5
|
+
/** Key in the query string instead of a header (browsers). */
|
|
6
|
+
keyInQuery?: boolean;
|
|
7
|
+
gzip?: (body: string) => Promise<BodyInit | undefined>;
|
|
8
|
+
/** Hand-off for unloads (navigator.sendBeacon). Returns false if it couldn't. */
|
|
9
|
+
beacon?: (url: string, body: string) => boolean;
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
}
|
|
12
|
+
export declare function endpoint(base: string, path: string): string;
|
|
13
|
+
export declare function fetchTransport(o: FetchTransportOptions): Transport;
|
|
14
|
+
/** Gzip with CompressionStream (browsers, edge runtimes, Node 18+). */
|
|
15
|
+
export declare function streamGzip(body: string): Promise<BodyInit | undefined>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Sending over fetch. Browsers authenticate with `?key=` and send `text/plain` so no
|
|
2
|
+
// CORS preflight is needed; servers use the Authorization header. Gzip is detected by
|
|
3
|
+
// the server from the bytes, so no Content-Encoding header is sent either.
|
|
4
|
+
import { retryAfter } from "./client.js";
|
|
5
|
+
/** Keepalive requests are capped at 64KB by browsers. */
|
|
6
|
+
const KEEPALIVE_BYTES = 60_000;
|
|
7
|
+
const GZIP_FROM = 1024;
|
|
8
|
+
export function endpoint(base, path) {
|
|
9
|
+
return `${base.replace(/\/+$/, "")}/ingest/v1/${path}`;
|
|
10
|
+
}
|
|
11
|
+
export function fetchTransport(o) {
|
|
12
|
+
const f = o.fetch ?? globalThis.fetch.bind(globalThis);
|
|
13
|
+
const url = endpoint(o.url, "events") + (o.keyInQuery ? `?key=${encodeURIComponent(o.key)}` : "");
|
|
14
|
+
const headers = { "content-type": "text/plain" };
|
|
15
|
+
if (!o.keyInQuery)
|
|
16
|
+
headers.authorization = `Bearer ${o.key}`;
|
|
17
|
+
return {
|
|
18
|
+
async send(body, final) {
|
|
19
|
+
if (final && o.beacon?.(url, body))
|
|
20
|
+
return { status: 202 };
|
|
21
|
+
let payload = body;
|
|
22
|
+
if (o.gzip && body.length >= GZIP_FROM)
|
|
23
|
+
payload = (await o.gzip(body).catch(() => undefined)) ?? body;
|
|
24
|
+
try {
|
|
25
|
+
const res = await f(url, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers,
|
|
28
|
+
body: payload,
|
|
29
|
+
keepalive: body.length < KEEPALIVE_BYTES,
|
|
30
|
+
credentials: "omit",
|
|
31
|
+
});
|
|
32
|
+
return { status: res.status, retryAfter: res.status === 429 ? await retryAfter(res) : undefined };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return "offline";
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Gzip with CompressionStream (browsers, edge runtimes, Node 18+). */
|
|
41
|
+
export async function streamGzip(body) {
|
|
42
|
+
// Read off globalThis so bundlers' runtime checks don't flag it.
|
|
43
|
+
const CS = globalThis.CompressionStream;
|
|
44
|
+
if (!CS)
|
|
45
|
+
return undefined;
|
|
46
|
+
const stream = new Blob([body]).stream().pipeThrough(new CS("gzip"));
|
|
47
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
48
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IngestBreadcrumb, IngestEvent, IngestLevel, IngestUser } from "../generated/ingest.ts";
|
|
2
|
+
export type { IngestBreadcrumb, IngestEvent, IngestLevel, IngestUser };
|
|
3
|
+
export interface Options {
|
|
4
|
+
/** The project's ingest key. Public: safe to ship to browsers. */
|
|
5
|
+
key: string;
|
|
6
|
+
/** Where bertha runs, e.g. "https://bertha.example.com". */
|
|
7
|
+
url: string;
|
|
8
|
+
/** Defaults to the release stamped in at build time. */
|
|
9
|
+
release?: string;
|
|
10
|
+
/** "production", "staging" or "development"; free text is mapped. */
|
|
11
|
+
environment?: string;
|
|
12
|
+
/** Share of events to send, 0 to 1. Defaults to 1. */
|
|
13
|
+
sampleRate?: number;
|
|
14
|
+
/** Change or drop (return null) an event before it is sent. */
|
|
15
|
+
beforeSend?: (event: IngestEvent) => IngestEvent | null;
|
|
16
|
+
/** Redacts emails, bearer tokens and secret-looking query values. On by default. */
|
|
17
|
+
scrub?: boolean;
|
|
18
|
+
/** Tags on every event. */
|
|
19
|
+
tags?: Record<string, string>;
|
|
20
|
+
/** Log what the SDK does to the console. */
|
|
21
|
+
debug?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface CaptureContext {
|
|
24
|
+
level?: IngestLevel;
|
|
25
|
+
tags?: Record<string, string>;
|
|
26
|
+
fingerprint?: string[];
|
|
27
|
+
request?: IngestEvent["request"];
|
|
28
|
+
}
|
|
29
|
+
/** What a send came back with; "offline" means it never reached bertha. */
|
|
30
|
+
export type SendResult = {
|
|
31
|
+
status: number;
|
|
32
|
+
retryAfter?: number;
|
|
33
|
+
} | "offline";
|
|
34
|
+
export interface Transport {
|
|
35
|
+
/** `final`: the page or process is going away, so don't wait on anything. */
|
|
36
|
+
send(body: string, final: boolean): Promise<SendResult>;
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION = "0.1.1";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.1.1";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Client } from "../core/client.ts";
|
|
2
|
+
import type { Options } from "../core/types.ts";
|
|
3
|
+
export { addBreadcrumb, captureException, captureMessage, flush, getClient, setTag, setUser } from "../core/hub.ts";
|
|
4
|
+
export type { CaptureContext, IngestEvent, Options } from "../core/types.ts";
|
|
5
|
+
export declare function init(options?: Partial<Options>): Client | undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @bigbertha/sdk/edge: for edge runtimes (Next middleware and edge routes, Workers).
|
|
2
|
+
// No process to hook into, so errors are reported where they are caught.
|
|
3
|
+
import { Client } from "../core/client.js";
|
|
4
|
+
import { firstEnv } from "../core/env.js";
|
|
5
|
+
import { setClient } from "../core/hub.js";
|
|
6
|
+
import { fetchTransport, streamGzip } from "../core/transport.js";
|
|
7
|
+
import { VERSION } from "../core/version.js";
|
|
8
|
+
export { addBreadcrumb, captureException, captureMessage, flush, getClient, setTag, setUser } from "../core/hub.js";
|
|
9
|
+
export function init(options = {}) {
|
|
10
|
+
const key = options.key ?? firstEnv("BERTHA_KEY", "NEXT_PUBLIC_BERTHA_KEY");
|
|
11
|
+
const url = options.url ?? firstEnv("BERTHA_URL", "NEXT_PUBLIC_BERTHA_URL");
|
|
12
|
+
if (!key || !url) {
|
|
13
|
+
console.warn("[bertha] Set BERTHA_KEY and BERTHA_URL (or pass key and url). Nothing will be sent.");
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const client = new Client({
|
|
17
|
+
...options,
|
|
18
|
+
key,
|
|
19
|
+
url,
|
|
20
|
+
release: options.release ?? firstEnv("BERTHA_RELEASE"),
|
|
21
|
+
environment: options.environment ?? firstEnv("BERTHA_ENVIRONMENT", "NODE_ENV"),
|
|
22
|
+
}, fetchTransport({ url, key, gzip: streamGzip }), { name: "bertha.edge", version: VERSION }, { runtime: "Edge" });
|
|
23
|
+
setClient(client);
|
|
24
|
+
return client;
|
|
25
|
+
}
|