@belvador/core 0.1.0 → 0.3.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/dist/aborts.d.ts +12 -0
- package/dist/aborts.js +74 -0
- package/dist/constants.d.ts +15 -0
- package/dist/constants.js +18 -0
- package/dist/diagnostics.d.ts +60 -0
- package/dist/diagnostics.js +139 -0
- package/dist/facade.d.ts +3 -0
- package/dist/facade.js +9 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.js +9 -2
- package/dist/monitor.d.ts +89 -0
- package/dist/monitor.js +330 -0
- package/dist/options.d.ts +26 -0
- package/dist/options.js +57 -8
- package/dist/privacy.d.ts +1 -1
- package/dist/privacy.js +25 -5
- package/dist/trace.d.ts +32 -0
- package/dist/trace.js +184 -0
- package/dist/transport.d.ts +9 -0
- package/dist/transport.js +106 -3
- package/dist/verification.d.ts +51 -0
- package/dist/verification.js +87 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +180 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/monitor.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// Monitors (build observability-1.0 spec 06 §2–§4, RLY-25): check-ins on Sentry's wire
|
|
2
|
+
// (develop.sentry.dev/sdk/telemetry/check-ins) plus Belvador's versioned run context.
|
|
3
|
+
// `withMonitor` never changes what the app's function returns or throws, never adds an
|
|
4
|
+
// await to a synchronous function, and a failed send never fails the job.
|
|
5
|
+
import { captureException as engineCaptureException, createCheckInEnvelope, getActiveSpan, getClient, getCurrentScope, getTraceContextFromScope, spanToTraceContext, uuid4, withIsolationScope, } from "@sentry/core";
|
|
6
|
+
import { reportDiagnostic } from "./diagnostics.js";
|
|
7
|
+
import { isActive } from "./facade.js";
|
|
8
|
+
// Copies of `src/shared/contracts/monitors.ts` (a root test keeps them equal).
|
|
9
|
+
export const MONITOR_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
10
|
+
export const RUN_REASON_RE = /^[a-z0-9][a-z0-9_.-]{0,63}$/;
|
|
11
|
+
export const RUN_CONTEXT = "belvador_run";
|
|
12
|
+
export const MONITOR_CONTEXT = "monitor";
|
|
13
|
+
export const MONITOR_CONFIG_LIMITS = {
|
|
14
|
+
intervalValueMax: 1000,
|
|
15
|
+
checkinMarginMax: 1440,
|
|
16
|
+
maxRuntimeMax: 10080,
|
|
17
|
+
thresholdMax: 720,
|
|
18
|
+
environmentMax: 64,
|
|
19
|
+
attemptMax: 1000,
|
|
20
|
+
};
|
|
21
|
+
export const INTERVAL_UNITS = ["minute", "hour", "day", "week", "month", "year"];
|
|
22
|
+
const HEX32 = /^[0-9a-f]{32}$/;
|
|
23
|
+
const DASHED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
24
|
+
/** A UUID as 32 lowercase hex; any other id maps to a stable UUID-shaped hash (grouping only). */
|
|
25
|
+
export function runIdOf(value) {
|
|
26
|
+
const v = value.trim().toLowerCase();
|
|
27
|
+
if (HEX32.test(v))
|
|
28
|
+
return v;
|
|
29
|
+
if (DASHED.test(v))
|
|
30
|
+
return v.replace(/-/g, "");
|
|
31
|
+
// Two 64-bit FNV-1a lanes → 128 bits; not a security boundary, only "same id → same run".
|
|
32
|
+
let a = 0xcbf29ce484222325n;
|
|
33
|
+
let b = 0x84222325cbf29ce4n;
|
|
34
|
+
const prime = 0x100000001b3n;
|
|
35
|
+
const mask = (1n << 64n) - 1n;
|
|
36
|
+
for (let i = 0; i < value.length; i++) {
|
|
37
|
+
const c = BigInt(value.charCodeAt(i));
|
|
38
|
+
a = ((a ^ c) * prime) & mask;
|
|
39
|
+
b = ((b ^ (c + 0x9en)) * prime) & mask;
|
|
40
|
+
}
|
|
41
|
+
return a.toString(16).padStart(16, "0") + b.toString(16).padStart(16, "0");
|
|
42
|
+
}
|
|
43
|
+
function validTimeZone(tz) {
|
|
44
|
+
try {
|
|
45
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function intIn(v, min, max) {
|
|
53
|
+
return v === undefined || (typeof v === "number" && Number.isInteger(v) && v >= min && v <= max);
|
|
54
|
+
}
|
|
55
|
+
/** Wire `monitor_config`, or null (with a diagnostic) when anything is invalid. */
|
|
56
|
+
export function monitorConfigOf(input) {
|
|
57
|
+
if (!input || input.schedule === undefined)
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
const s = input.schedule;
|
|
61
|
+
let schedule = null;
|
|
62
|
+
if (s && s.type === "crontab" && typeof s.value === "string") {
|
|
63
|
+
const fields = s.value.trim().split(/\s+/);
|
|
64
|
+
if (s.value.length <= 120 && fields.length === 5 && fields.every((f) => /^[0-9A-Za-z*,/?#LW-]+$/.test(f)))
|
|
65
|
+
schedule = { type: "crontab", value: fields.join(" ") };
|
|
66
|
+
}
|
|
67
|
+
else if (s &&
|
|
68
|
+
s.type === "interval" &&
|
|
69
|
+
intIn(s.value, 1, MONITOR_CONFIG_LIMITS.intervalValueMax) &&
|
|
70
|
+
INTERVAL_UNITS.includes(s.unit)) {
|
|
71
|
+
schedule = { type: "interval", value: s.value, unit: s.unit };
|
|
72
|
+
}
|
|
73
|
+
const tz = input.timezone ?? "UTC";
|
|
74
|
+
const ok = schedule !== null &&
|
|
75
|
+
typeof tz === "string" &&
|
|
76
|
+
tz.length > 0 &&
|
|
77
|
+
tz.length <= 64 &&
|
|
78
|
+
validTimeZone(tz) &&
|
|
79
|
+
intIn(input.checkinMargin, 1, MONITOR_CONFIG_LIMITS.checkinMarginMax) &&
|
|
80
|
+
// Larger than Sentry's 7-day ceiling is clamped by the node, like Sentry.
|
|
81
|
+
intIn(input.maxRuntime, 1, Number.MAX_SAFE_INTEGER) &&
|
|
82
|
+
intIn(input.failureIssueThreshold, 1, MONITOR_CONFIG_LIMITS.thresholdMax) &&
|
|
83
|
+
intIn(input.recoveryThreshold, 1, MONITOR_CONFIG_LIMITS.thresholdMax);
|
|
84
|
+
if (!ok) {
|
|
85
|
+
reportDiagnostic("BLV_MONITOR_CONFIG_INVALID");
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const out = { schedule, timezone: tz };
|
|
89
|
+
if (input.checkinMargin !== undefined)
|
|
90
|
+
out["checkin_margin"] = input.checkinMargin;
|
|
91
|
+
if (input.maxRuntime !== undefined)
|
|
92
|
+
out["max_runtime"] = input.maxRuntime;
|
|
93
|
+
if (input.failureIssueThreshold !== undefined)
|
|
94
|
+
out["failure_issue_threshold"] = input.failureIssueThreshold;
|
|
95
|
+
if (input.recoveryThreshold !== undefined)
|
|
96
|
+
out["recovery_threshold"] = input.recoveryThreshold;
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
reportDiagnostic("BLV_MONITOR_CONFIG_INVALID");
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function cleanReason(reason) {
|
|
105
|
+
if (reason === undefined)
|
|
106
|
+
return undefined;
|
|
107
|
+
if (typeof reason === "string" && RUN_REASON_RE.test(reason))
|
|
108
|
+
return reason;
|
|
109
|
+
reportDiagnostic("BLV_MONITOR_INVALID_REASON");
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
function now() {
|
|
113
|
+
try {
|
|
114
|
+
return typeof performance !== "undefined" && typeof performance.now === "function"
|
|
115
|
+
? performance.now()
|
|
116
|
+
: Date.now();
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return Date.now();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** The check-in item exactly as sent (exported for the golden test). */
|
|
123
|
+
export function buildCheckIn(input, client, config, trace) {
|
|
124
|
+
const options = client.getOptions();
|
|
125
|
+
const id = input.checkInId ? runIdOf(input.checkInId) : uuid4();
|
|
126
|
+
const env = typeof input.environment === "string" && input.environment.trim() ? input.environment.trim() : options.environment;
|
|
127
|
+
const run = { v: 1 };
|
|
128
|
+
if (input.runId)
|
|
129
|
+
run["run_id"] = runIdOf(input.runId);
|
|
130
|
+
if (intIn(input.attempt, 1, MONITOR_CONFIG_LIMITS.attemptMax) && input.attempt !== undefined)
|
|
131
|
+
run["attempt"] = input.attempt;
|
|
132
|
+
if (input.willRetry === true && input.status === "error")
|
|
133
|
+
run["will_retry"] = true;
|
|
134
|
+
if (input.outcome && input.status !== "in_progress")
|
|
135
|
+
run["outcome"] = input.outcome;
|
|
136
|
+
const reason = cleanReason(input.reason);
|
|
137
|
+
if (reason)
|
|
138
|
+
run["reason"] = reason;
|
|
139
|
+
const item = {
|
|
140
|
+
check_in_id: id,
|
|
141
|
+
monitor_slug: input.monitorSlug,
|
|
142
|
+
status: input.status,
|
|
143
|
+
};
|
|
144
|
+
if (input.status !== "in_progress" && typeof input.duration === "number" && Number.isFinite(input.duration) && input.duration >= 0)
|
|
145
|
+
item.duration = input.duration;
|
|
146
|
+
if (options.release)
|
|
147
|
+
item.release = options.release;
|
|
148
|
+
if (env)
|
|
149
|
+
item.environment = String(env).slice(0, MONITOR_CONFIG_LIMITS.environmentMax);
|
|
150
|
+
if (config)
|
|
151
|
+
item["monitor_config"] = config;
|
|
152
|
+
const contexts = {};
|
|
153
|
+
if (trace)
|
|
154
|
+
contexts["trace"] = { trace_id: trace.trace_id, span_id: trace.span_id };
|
|
155
|
+
if (Object.keys(run).length > 1)
|
|
156
|
+
contexts[RUN_CONTEXT] = run;
|
|
157
|
+
if (Object.keys(contexts).length)
|
|
158
|
+
item["contexts"] = contexts;
|
|
159
|
+
return item;
|
|
160
|
+
}
|
|
161
|
+
function send(item, client) {
|
|
162
|
+
try {
|
|
163
|
+
const envelope = createCheckInEnvelope(item, undefined, client.getSdkMetadata(), client.getOptions().tunnel, client.getDsn());
|
|
164
|
+
Promise.resolve(client.sendEnvelope(envelope)).then(undefined, () => undefined);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
reportDiagnostic("BLV_CHECK_IN_DROPPED");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** The trace errors of this scope carry: the active (request) span's, else the scope's. */
|
|
171
|
+
function currentTrace() {
|
|
172
|
+
try {
|
|
173
|
+
const span = getActiveSpan();
|
|
174
|
+
const t = span ? spanToTraceContext(span) : getTraceContextFromScope(getCurrentScope());
|
|
175
|
+
return t?.trace_id && t.span_id ? { trace_id: t.trace_id, span_id: t.span_id } : null;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Sends one check-in; returns its id ("" when the SDK is off or the slug is invalid). Never throws. */
|
|
182
|
+
export function captureCheckIn(checkIn, monitorConfig) {
|
|
183
|
+
try {
|
|
184
|
+
if (!isActive())
|
|
185
|
+
return "";
|
|
186
|
+
const client = getClient();
|
|
187
|
+
if (!client)
|
|
188
|
+
return "";
|
|
189
|
+
if (typeof checkIn?.monitorSlug !== "string" || !MONITOR_SLUG_RE.test(checkIn.monitorSlug)) {
|
|
190
|
+
reportDiagnostic("BLV_MONITOR_INVALID_SLUG");
|
|
191
|
+
return "";
|
|
192
|
+
}
|
|
193
|
+
const item = buildCheckIn(checkIn, client, monitorConfigOf(monitorConfig), currentTrace());
|
|
194
|
+
send(item, client);
|
|
195
|
+
return String(item.check_in_id);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return "";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** A healthy check-in for an interval monitor ("still alive"). */
|
|
202
|
+
export function heartbeat(slug, options) {
|
|
203
|
+
captureCheckIn({ monitorSlug: slug, status: "ok", ...(options?.environment ? { environment: options.environment } : {}) });
|
|
204
|
+
}
|
|
205
|
+
function isThenable(v) {
|
|
206
|
+
return v !== null && (typeof v === "object" || typeof v === "function") && typeof v.then === "function";
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Runs `fn` as one monitored attempt: `in_progress` first, then `ok` (or skipped/unknown
|
|
210
|
+
* via the handle) when it returns, or the error captured as a normal issue and `error`
|
|
211
|
+
* when it throws — the same value or error goes back to the caller, unchanged.
|
|
212
|
+
*/
|
|
213
|
+
export function withMonitor(slug, fn, options) {
|
|
214
|
+
const inert = { checkInId: "", runId: "", skip: () => undefined, unknown: () => undefined };
|
|
215
|
+
let ready = false;
|
|
216
|
+
let client;
|
|
217
|
+
try {
|
|
218
|
+
client = isActive() ? getClient() : undefined;
|
|
219
|
+
if (client && (typeof slug !== "string" || !MONITOR_SLUG_RE.test(slug))) {
|
|
220
|
+
reportDiagnostic("BLV_MONITOR_INVALID_SLUG");
|
|
221
|
+
client = undefined;
|
|
222
|
+
}
|
|
223
|
+
ready = client !== undefined;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
ready = false;
|
|
227
|
+
}
|
|
228
|
+
if (!ready || !client)
|
|
229
|
+
return fn(inert);
|
|
230
|
+
const c = client;
|
|
231
|
+
const o = options ?? {};
|
|
232
|
+
let config = null;
|
|
233
|
+
try {
|
|
234
|
+
config = monitorConfigOf(o);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
config = null;
|
|
238
|
+
}
|
|
239
|
+
const base = {
|
|
240
|
+
monitorSlug: slug,
|
|
241
|
+
...(o.environment !== undefined ? { environment: o.environment } : {}),
|
|
242
|
+
...(o.attempt !== undefined ? { attempt: o.attempt } : {}),
|
|
243
|
+
};
|
|
244
|
+
return withIsolationScope((scope) => {
|
|
245
|
+
let checkInId = "";
|
|
246
|
+
let runId = "";
|
|
247
|
+
let outcome = null;
|
|
248
|
+
let finished = false;
|
|
249
|
+
let started = 0;
|
|
250
|
+
try {
|
|
251
|
+
scope.setContext(MONITOR_CONTEXT, { slug });
|
|
252
|
+
checkInId = uuid4();
|
|
253
|
+
runId = o.runId ? runIdOf(o.runId) : checkInId;
|
|
254
|
+
started = now();
|
|
255
|
+
send(buildCheckIn({ ...base, status: "in_progress", checkInId, runId }, c, config, currentTrace()), c);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
/* never stops the job */
|
|
259
|
+
}
|
|
260
|
+
const handle = {
|
|
261
|
+
checkInId,
|
|
262
|
+
runId,
|
|
263
|
+
skip: (reason) => {
|
|
264
|
+
if (!finished)
|
|
265
|
+
outcome = { kind: "skipped", reason };
|
|
266
|
+
},
|
|
267
|
+
unknown: (reason) => {
|
|
268
|
+
if (!finished)
|
|
269
|
+
outcome = { kind: "unknown", reason };
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
const finish = (status, error) => {
|
|
273
|
+
if (finished)
|
|
274
|
+
return;
|
|
275
|
+
finished = true;
|
|
276
|
+
try {
|
|
277
|
+
let willRetry = false;
|
|
278
|
+
if (status === "error") {
|
|
279
|
+
try {
|
|
280
|
+
willRetry = typeof o.willRetry === "function" ? o.willRetry(error) === true : o.willRetry === true;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
willRetry = false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const done = outcome;
|
|
287
|
+
send(buildCheckIn({
|
|
288
|
+
...base,
|
|
289
|
+
status,
|
|
290
|
+
checkInId,
|
|
291
|
+
runId,
|
|
292
|
+
duration: Math.max(0, (now() - started) / 1000),
|
|
293
|
+
...(willRetry ? { willRetry } : {}),
|
|
294
|
+
...(status === "ok" && done ? { outcome: done.kind, reason: done.reason } : {}),
|
|
295
|
+
}, c, config, currentTrace()), c);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
/* never stops the job */
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
const failed = (error) => {
|
|
302
|
+
try {
|
|
303
|
+
engineCaptureException(error);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
/* reported or not, the error goes back unchanged */
|
|
307
|
+
}
|
|
308
|
+
finish("error", error);
|
|
309
|
+
};
|
|
310
|
+
let result;
|
|
311
|
+
try {
|
|
312
|
+
result = fn(handle);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
failed(error);
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
if (isThenable(result)) {
|
|
319
|
+
return result.then((value) => {
|
|
320
|
+
finish("ok");
|
|
321
|
+
return value;
|
|
322
|
+
}, (error) => {
|
|
323
|
+
failed(error);
|
|
324
|
+
throw error;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
finish("ok");
|
|
328
|
+
return result;
|
|
329
|
+
});
|
|
330
|
+
}
|
package/dist/options.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Breadcrumb, BreadcrumbHint, ErrorEvent, EventHint } from "@sentry/core";
|
|
2
|
+
import { type Diagnostic } from "./diagnostics.js";
|
|
3
|
+
import { type TracePropagationOptions } from "./trace.js";
|
|
2
4
|
import { type SendPolicy } from "./transport.js";
|
|
3
5
|
export type BelvadorEvent = ErrorEvent;
|
|
4
6
|
export type BelvadorEventHint = EventHint;
|
|
@@ -19,12 +21,35 @@ export type BelvadorOptions = {
|
|
|
19
21
|
/** Default 50, at most 100. */
|
|
20
22
|
maxBreadcrumbs?: number;
|
|
21
23
|
ignoreErrors?: Array<string | RegExp>;
|
|
24
|
+
/**
|
|
25
|
+
* Also report errors from work the client cancelled: an `AbortError` (fetch or stream
|
|
26
|
+
* aborted), or a server error thrown after the visitor closed the connection. Default
|
|
27
|
+
* false — cancellations are expected and never become issues. A `TimeoutError` is not
|
|
28
|
+
* a cancellation and is always reported.
|
|
29
|
+
*/
|
|
30
|
+
captureAborted?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Browsers: also report a View Transition the browser skipped on purpose (page hidden,
|
|
33
|
+
* another transition started, `skipTransition()`). Default false — these are expected.
|
|
34
|
+
*/
|
|
35
|
+
captureViewTransitionSkips?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Link errors and job check-ins across services with one trace id. Off by default:
|
|
38
|
+
* requests whose URL matches `targets` get `traceparent` + `sentry-trace` headers
|
|
39
|
+
* (the receiving API must allow them in CORS). No spans or transactions are sent.
|
|
40
|
+
*/
|
|
41
|
+
tracePropagation?: TracePropagationOptions;
|
|
22
42
|
/** Send attachments added with scope.addAttachment (each ≤ 5 MB). Default false. */
|
|
23
43
|
attachments?: boolean;
|
|
24
44
|
/** Runs before Belvador's own scrubbing; return null to drop the event. */
|
|
25
45
|
beforeSend?: (event: BelvadorEvent, hint: BelvadorEventHint) => BelvadorEvent | null | PromiseLike<BelvadorEvent | null>;
|
|
26
46
|
/** Runs before Belvador's own scrubbing; return null to drop the breadcrumb. */
|
|
27
47
|
beforeBreadcrumb?: (crumb: BelvadorBreadcrumb, hint?: BreadcrumbHint) => BelvadorBreadcrumb | null;
|
|
48
|
+
/**
|
|
49
|
+
* Local diagnostics (missing DSN, refused key, failed sends, …): at most once a
|
|
50
|
+
* minute per code, never sent anywhere. Errors thrown here are ignored.
|
|
51
|
+
*/
|
|
52
|
+
onDiagnostic?: (diagnostic: Diagnostic) => void;
|
|
28
53
|
};
|
|
29
54
|
export type RuntimeName = "browser" | "react" | "cloudflare" | "tanstack-start";
|
|
30
55
|
export declare const DEFAULT_MAX_BREADCRUMBS = 50;
|
|
@@ -60,6 +85,7 @@ export declare function buildEngineOptions(opts: BelvadorOptions, runtime: Runti
|
|
|
60
85
|
defaultIntegrations: false;
|
|
61
86
|
transport: (options: import("@sentry/core").BaseTransportOptions) => import("@sentry/core").Transport;
|
|
62
87
|
_belvador: boolean;
|
|
88
|
+
_belvadorTrace: import("./trace.js").TraceConfig | null;
|
|
63
89
|
_metadata: {
|
|
64
90
|
sdk: {
|
|
65
91
|
name: string;
|
package/dist/options.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { isClientAbort, isViewTransitionSkip } from "./aborts.js";
|
|
2
|
+
import { configureDiagnostics, reportDiagnostic } from "./diagnostics.js";
|
|
1
3
|
import { checkDsn } from "./dsn.js";
|
|
2
4
|
import { scrubBreadcrumb, scrubEvent } from "./privacy.js";
|
|
5
|
+
import { VERIFICATION_CONTEXT, VERIFICATION_TAG, noteVerificationOutcome, verificationOf, } from "./verification.js";
|
|
6
|
+
import { TRACE_OPTION, traceConfigOf } from "./trace.js";
|
|
3
7
|
import { makeBelvadorTransport } from "./transport.js";
|
|
4
8
|
import { ENGINE_VERSION, SDK_VERSION } from "./version.js";
|
|
5
9
|
/** Engine package behind each runtime (for `sdk.packages`). */
|
|
@@ -30,19 +34,49 @@ export function warnOnce(message) {
|
|
|
30
34
|
function clamp(n, min, max, fallback) {
|
|
31
35
|
return typeof n === "number" && Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
|
32
36
|
}
|
|
33
|
-
function chainBeforeSend(user) {
|
|
37
|
+
function chainBeforeSend(user, captureAborted, captureViewTransitionSkips) {
|
|
34
38
|
return (event, hint) => {
|
|
39
|
+
// Integration health: a verification error carries its own tag and context; they are
|
|
40
|
+
// set before the app's beforeSend and restored after scrubbing (ids are not user data).
|
|
41
|
+
const v = verificationOf(hint?.originalException);
|
|
42
|
+
const original = hint?.originalException;
|
|
43
|
+
// A skipped View Transition is the standard working as designed (spec 03 §8).
|
|
44
|
+
if (!v && !captureViewTransitionSkips && isViewTransitionSkip(original))
|
|
45
|
+
return null;
|
|
46
|
+
// Cancelled by the client: expected, dropped before the app's hook (not a diagnostic).
|
|
47
|
+
// An opted-in View Transition skip is reported even though it is an AbortError.
|
|
48
|
+
if (!v && !captureAborted && isClientAbort(original) && !(captureViewTransitionSkips && isViewTransitionSkip(original)))
|
|
49
|
+
return null;
|
|
50
|
+
const mark = (e) => {
|
|
51
|
+
if (!v)
|
|
52
|
+
return e;
|
|
53
|
+
e.tags = { ...(e.tags ?? {}), [VERIFICATION_TAG]: v.id };
|
|
54
|
+
e.contexts = { ...(e.contexts ?? {}), [VERIFICATION_CONTEXT]: { ...v.context } };
|
|
55
|
+
return e;
|
|
56
|
+
};
|
|
57
|
+
const finish = (e) => {
|
|
58
|
+
if (!e) {
|
|
59
|
+
if (v) {
|
|
60
|
+
noteVerificationOutcome(v.id, v.context.check, { kind: "dropped" });
|
|
61
|
+
reportDiagnostic("BLV_DROPPED_BY_APP");
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
// A scrubber failure drops the event (never sends it unscrubbed).
|
|
66
|
+
const clean = scrubEvent(e);
|
|
67
|
+
return clean ? mark(clean) : null;
|
|
68
|
+
};
|
|
35
69
|
let out;
|
|
36
70
|
try {
|
|
37
|
-
out = user ? user(event, hint) : event;
|
|
71
|
+
out = user ? user(mark(event), hint) : mark(event);
|
|
38
72
|
}
|
|
39
73
|
catch {
|
|
40
|
-
return null;
|
|
74
|
+
return finish(null);
|
|
41
75
|
}
|
|
42
76
|
if (out && typeof out.then === "function") {
|
|
43
|
-
return Promise.resolve(out).then((
|
|
77
|
+
return Promise.resolve(out).then(finish, () => finish(null));
|
|
44
78
|
}
|
|
45
|
-
return
|
|
79
|
+
return finish(out);
|
|
46
80
|
};
|
|
47
81
|
}
|
|
48
82
|
function chainBeforeBreadcrumb(user) {
|
|
@@ -63,9 +97,22 @@ function chainBeforeBreadcrumb(user) {
|
|
|
63
97
|
export function buildEngineOptions(opts, runtime, send) {
|
|
64
98
|
if (!opts || opts.disabled === true)
|
|
65
99
|
return null;
|
|
100
|
+
const sdkName = `belvador.javascript.${runtime}`;
|
|
66
101
|
const dsn = checkDsn(opts.dsn);
|
|
102
|
+
configureDiagnostics({
|
|
103
|
+
onDiagnostic: typeof opts.onDiagnostic === "function" ? opts.onDiagnostic : undefined,
|
|
104
|
+
debug: opts.debug === true,
|
|
105
|
+
sdk: { name: sdkName, version: SDK_VERSION },
|
|
106
|
+
dsn: dsn
|
|
107
|
+
? { host: dsn.host, project: dsn.projectId, keyHint: dsn.publicKey.slice(0, 4) }
|
|
108
|
+
: null,
|
|
109
|
+
});
|
|
67
110
|
if (!dsn) {
|
|
68
|
-
|
|
111
|
+
const missing = typeof opts.dsn !== "string" || opts.dsn.trim() === "";
|
|
112
|
+
reportDiagnostic(missing ? "BLV_DSN_MISSING" : "BLV_DSN_INVALID");
|
|
113
|
+
warnOnce(missing
|
|
114
|
+
? "no DSN — the SDK is off. Copy the DSN from your Belvador project settings."
|
|
115
|
+
: "invalid DSN — the SDK is off. Copy the DSN from your Belvador project settings.");
|
|
69
116
|
return null;
|
|
70
117
|
}
|
|
71
118
|
const attachments = opts.attachments === true;
|
|
@@ -86,15 +133,17 @@ export function buildEngineOptions(opts, runtime, send) {
|
|
|
86
133
|
// Never read SENTRY_TUNNEL / spotlight from the environment; events go only to the DSN.
|
|
87
134
|
tunnel: "",
|
|
88
135
|
spotlight: false,
|
|
89
|
-
beforeSend: chainBeforeSend(opts.beforeSend),
|
|
136
|
+
beforeSend: chainBeforeSend(opts.beforeSend, opts.captureAborted === true, opts.captureViewTransitionSkips === true),
|
|
90
137
|
beforeBreadcrumb: chainBeforeBreadcrumb(opts.beforeBreadcrumb),
|
|
91
138
|
beforeSendTransaction: () => null,
|
|
92
139
|
defaultIntegrations: false,
|
|
93
140
|
transport: makeBelvadorTransport({ ...send, attachments, debug: opts.debug === true }),
|
|
94
141
|
[BELVADOR_MARKER]: true,
|
|
142
|
+
// Checked trace propagation of this client (null = off); the engine itself never adds headers.
|
|
143
|
+
[TRACE_OPTION]: traceConfigOf(opts.tracePropagation),
|
|
95
144
|
_metadata: {
|
|
96
145
|
sdk: {
|
|
97
|
-
name:
|
|
146
|
+
name: sdkName,
|
|
98
147
|
version: SDK_VERSION,
|
|
99
148
|
packages: [
|
|
100
149
|
{ name: `npm:@belvador/${runtime}`, version: SDK_VERSION },
|
package/dist/privacy.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export declare const SENSITIVE_KEY_PARTS: readonly string[];
|
|
|
4
4
|
export declare function isSensitiveKey(key: string): boolean;
|
|
5
5
|
/** Credentials in URLs and `name=value` text: userinfo removed, sensitive parameter values filtered. */
|
|
6
6
|
export declare function scrubParams(text: string): string;
|
|
7
|
-
/** Every rule for free text: parameters, tokens, card numbers, emails. */
|
|
7
|
+
/** Every rule for free text: assignments, parameters, tokens, card numbers, emails. */
|
|
8
8
|
export declare function scrubText(text: string): string;
|
|
9
9
|
/**
|
|
10
10
|
* Bounded deep copy with every rule applied: sensitive keys → [Filtered],
|
package/dist/privacy.js
CHANGED
|
@@ -86,8 +86,15 @@ const EMAIL = /\b[A-Za-z0-9._%+-]{1,64}@([A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,6
|
|
|
86
86
|
const USERINFO = /\b([a-z][a-z0-9+.-]{1,15}:\/\/)[^\s/?#@]+@/gi;
|
|
87
87
|
/** name=value inside URLs, query strings and log lines. */
|
|
88
88
|
const PARAM = /([A-Za-z0-9_.~%[\]-]{1,128})=([^&#\s"'<>,;]*)/g;
|
|
89
|
-
/**
|
|
90
|
-
const
|
|
89
|
+
/** `Basic <base64>` credentials in headers and log lines. */
|
|
90
|
+
const BASIC_AUTH = /\bBasic\s+[A-Za-z0-9+/]{8,}={0,2}/g;
|
|
91
|
+
/** "name": "value" / 'name': 'value' inside JSON- or dict-looking text (bounded, linear). */
|
|
92
|
+
const QUOTED_ASSIGNMENT = /(["'])([^"'\\\n]{1,128})\1([ \t]{0,8}:[ \t]{0,8})(?:"((?:[^"\\\n]|\\.){0,1024})"|'((?:[^'\\\n]|\\.){0,1024})')/g;
|
|
93
|
+
/**
|
|
94
|
+
* name=value, name => value and name: value in log lines; the value is one quoted string
|
|
95
|
+
* or one bare token. The name must not continue a longer word (the leading group).
|
|
96
|
+
*/
|
|
97
|
+
const ASSIGNMENT = /(^|[^A-Za-z0-9_.~%[\]-])([A-Za-z0-9_.~%[\]-]{1,128})([ \t]{0,8}(?:=>|=|:)[ \t]{0,8})("(?:[^"\\]|\\.){0,1024}"|'(?:[^'\\]|\\.){0,1024}'|[^\s&#"'<>,;{}()]{1,1024})/g;
|
|
91
98
|
export function isSensitiveKey(key) {
|
|
92
99
|
const lower = key.toLowerCase();
|
|
93
100
|
for (const part of SENSITIVE_KEY_PARTS)
|
|
@@ -122,14 +129,27 @@ function luhn(digits) {
|
|
|
122
129
|
export function scrubParams(text) {
|
|
123
130
|
return text
|
|
124
131
|
.replace(USERINFO, `$1${FILTERED}@`)
|
|
125
|
-
|
|
132
|
+
// An empty value hides nothing (and a quoted value is left to the assignment rule).
|
|
133
|
+
.replace(PARAM, (match, name, value) => value !== "" && isSensitiveKey(decodeName(name)) ? `${name}=${FILTERED}` : match);
|
|
126
134
|
}
|
|
127
|
-
/** Every rule for free text: parameters, tokens, card numbers, emails. */
|
|
135
|
+
/** Every rule for free text: assignments, parameters, tokens, card numbers, emails. */
|
|
128
136
|
export function scrubText(text) {
|
|
129
137
|
if (text.length === 0)
|
|
130
138
|
return text;
|
|
131
139
|
return scrubParams(text)
|
|
132
|
-
.replace(
|
|
140
|
+
.replace(BASIC_AUTH, `Basic ${FILTERED}`)
|
|
141
|
+
.replace(QUOTED_ASSIGNMENT, (match, q, name, sep, dq) => {
|
|
142
|
+
if (!isSensitiveKey(name))
|
|
143
|
+
return match;
|
|
144
|
+
const vq = dq !== undefined ? '"' : "'";
|
|
145
|
+
return `${q}${name}${q}${sep}${vq}${FILTERED}${vq}`;
|
|
146
|
+
})
|
|
147
|
+
.replace(ASSIGNMENT, (match, lead, name, sep, value) => {
|
|
148
|
+
if (!isSensitiveKey(decodeName(name)) || value === FILTERED)
|
|
149
|
+
return match;
|
|
150
|
+
const q = value[0] === '"' || value[0] === "'" ? value[0] : "";
|
|
151
|
+
return `${lead}${name}${sep}${q}${FILTERED}${q}`;
|
|
152
|
+
})
|
|
133
153
|
.replace(BEARER, `Bearer ${FILTERED}`)
|
|
134
154
|
.replace(PROVIDER_KEY, FILTERED)
|
|
135
155
|
.replace(JWT, FILTERED)
|
package/dist/trace.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type TracePropagationOptions = {
|
|
2
|
+
/** URLs (substring) or RegExps; relative patterns match same-origin paths in browsers. Empty = off. */
|
|
3
|
+
targets?: Array<string | RegExp>;
|
|
4
|
+
/** Also send `baggage` (only the engine's `sentry-*` keys). Default false. */
|
|
5
|
+
baggage?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type TraceConfig = {
|
|
8
|
+
targets: Array<string | RegExp>;
|
|
9
|
+
baggage: boolean;
|
|
10
|
+
};
|
|
11
|
+
/** Engine-options key carrying the checked config of one client. */
|
|
12
|
+
export declare const TRACE_OPTION = "_belvadorTrace";
|
|
13
|
+
/** Valid targets only (others reported once a minute); null = propagation off. */
|
|
14
|
+
export declare function traceConfigOf(opts: TracePropagationOptions | undefined): TraceConfig | null;
|
|
15
|
+
/** W3C `traceparent` → `sentry-trace` (sampling left to the receiver), or null when invalid. */
|
|
16
|
+
export declare function sentryTraceFromTraceparent(value: string | null | undefined): string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Server side: a request carrying a valid `traceparent` but no `sentry-trace` gets the
|
|
19
|
+
* equivalent `sentry-trace`, so the engine continues the same trace. Anything else is
|
|
20
|
+
* returned unchanged (same object).
|
|
21
|
+
*/
|
|
22
|
+
export declare function withIncomingTraceparent<R extends Request>(request: R): R;
|
|
23
|
+
/** Sentry's rule: the whole URL, or (browsers) the path of a same-origin URL. */
|
|
24
|
+
export declare function urlMatches(url: string, targets: ReadonlyArray<string | RegExp>): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The current trace as headers: the active span's ids when there is one (a server request),
|
|
27
|
+
* else the scope's trace with a fresh span id. Errors-only, so the sampling flag is never set
|
|
28
|
+
* (`traceparent` flags 00, `sentry-trace` without the third part): the receiver decides.
|
|
29
|
+
*/
|
|
30
|
+
export declare function traceHeaders(baggage: boolean): Record<string, string> | null;
|
|
31
|
+
/** Installs the fetch hook once per process; each client decides with its own config. */
|
|
32
|
+
export declare function installTracePropagation(): void;
|