@belvador/core 0.1.0 → 0.2.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 +10 -0
- package/dist/aborts.js +41 -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 +129 -0
- package/dist/facade.d.ts +3 -0
- package/dist/facade.js +9 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/options.d.ts +13 -0
- package/dist/options.js +49 -8
- package/dist/transport.d.ts +5 -0
- package/dist/transport.js +81 -2
- 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/aborts.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Errors whose own name, or whose direct cause's name, is "AbortError". */
|
|
2
|
+
export declare function isAbortError(error: unknown): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Called by Belvador's server middleware and Worker wrapper when a request fails after its
|
|
5
|
+
* client went away (`request.signal.aborted`): whatever was thrown is then treated as an
|
|
6
|
+
* abort, even when it is not named AbortError (stream "terminated", "aborted", …).
|
|
7
|
+
*/
|
|
8
|
+
export declare function noteClientAbort(error: unknown, signal: AbortSignal | null | undefined): void;
|
|
9
|
+
/** True for an AbortError or an error thrown while its request's client had gone. */
|
|
10
|
+
export declare function isClientAbort(error: unknown): boolean;
|
package/dist/aborts.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Client-cancelled work is expected, not a failure (integration-health 05-decisions,
|
|
2
|
+
// Supaclip finding): a user who closes the tab or navigates away aborts in-flight
|
|
3
|
+
// requests, and the server code serving them throws. These are dropped by default;
|
|
4
|
+
// `captureAborted: true` reports them again. Imports nothing (runs in every runtime).
|
|
5
|
+
/** Errors whose own name, or whose direct cause's name, is "AbortError". */
|
|
6
|
+
export function isAbortError(error) {
|
|
7
|
+
try {
|
|
8
|
+
if (nameOf(error) === "AbortError")
|
|
9
|
+
return true;
|
|
10
|
+
if (error !== null && typeof error === "object" && "cause" in error)
|
|
11
|
+
return nameOf(error.cause) === "AbortError";
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
/* a hostile getter: not an abort */
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
function nameOf(value) {
|
|
19
|
+
return value !== null && typeof value === "object" ? value.name : undefined;
|
|
20
|
+
}
|
|
21
|
+
const clientAborted = new WeakSet();
|
|
22
|
+
/**
|
|
23
|
+
* Called by Belvador's server middleware and Worker wrapper when a request fails after its
|
|
24
|
+
* client went away (`request.signal.aborted`): whatever was thrown is then treated as an
|
|
25
|
+
* abort, even when it is not named AbortError (stream "terminated", "aborted", …).
|
|
26
|
+
*/
|
|
27
|
+
export function noteClientAbort(error, signal) {
|
|
28
|
+
try {
|
|
29
|
+
if (signal?.aborted === true && error !== null && typeof error === "object")
|
|
30
|
+
clientAborted.add(error);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* never throws into the app */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** True for an AbortError or an error thrown while its request's client had gone. */
|
|
37
|
+
export function isClientAbort(error) {
|
|
38
|
+
if (isAbortError(error))
|
|
39
|
+
return true;
|
|
40
|
+
return error !== null && typeof error === "object" && clientAborted.has(error);
|
|
41
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Reserved tag key; its value is the verification id. */
|
|
2
|
+
export declare const VERIFICATION_TAG = "belvador.verification";
|
|
3
|
+
/** Context the SDK adds to a verification event. */
|
|
4
|
+
export declare const VERIFICATION_CONTEXT = "belvador_verification";
|
|
5
|
+
/** Exception type a verification captures or throws. */
|
|
6
|
+
export declare const VERIFICATION_ERROR_TYPE = "BelvadorVerificationError";
|
|
7
|
+
/** `bv1_` + 32 lowercase hex. */
|
|
8
|
+
export declare const VERIFICATION_ID_RE: RegExp;
|
|
9
|
+
export declare const VERIFY_LIMITS: {
|
|
10
|
+
readonly timeoutDefaultMs: 20000;
|
|
11
|
+
readonly timeoutMaxMs: 30000;
|
|
12
|
+
readonly readbackMaxRequests: 10;
|
|
13
|
+
/** Waits between read-backs; the last value repeats. */
|
|
14
|
+
readonly readbackDelaysMs: readonly number[];
|
|
15
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Integration-health wire constants (build integration-health-1.0 spec 01/02), a copy
|
|
2
|
+
// of `src/shared/contracts/integration-health.ts` that a root test keeps equal.
|
|
3
|
+
// Imports nothing.
|
|
4
|
+
/** Reserved tag key; its value is the verification id. */
|
|
5
|
+
export const VERIFICATION_TAG = "belvador.verification";
|
|
6
|
+
/** Context the SDK adds to a verification event. */
|
|
7
|
+
export const VERIFICATION_CONTEXT = "belvador_verification";
|
|
8
|
+
/** Exception type a verification captures or throws. */
|
|
9
|
+
export const VERIFICATION_ERROR_TYPE = "BelvadorVerificationError";
|
|
10
|
+
/** `bv1_` + 32 lowercase hex. */
|
|
11
|
+
export const VERIFICATION_ID_RE = /^bv1_[0-9a-f]{32}$/;
|
|
12
|
+
export const VERIFY_LIMITS = {
|
|
13
|
+
timeoutDefaultMs: 20000,
|
|
14
|
+
timeoutMaxMs: 30000,
|
|
15
|
+
readbackMaxRequests: 10,
|
|
16
|
+
/** Waits between read-backs; the last value repeats. */
|
|
17
|
+
readbackDelaysMs: [1000, 2000, 3000, 4000, 5000],
|
|
18
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export declare const DIAGNOSTIC_CODES: readonly ["BLV_DSN_MISSING", "BLV_DSN_INVALID", "BLV_INIT_CONFLICT", "BLV_NOT_INITIALIZED", "BLV_SEND_UNAUTHORIZED", "BLV_SEND_RATE_LIMITED", "BLV_SEND_REJECTED", "BLV_SEND_NETWORK", "BLV_SEND_SERVER", "BLV_QUEUE_FULL", "BLV_EVENT_TOO_LARGE", "BLV_HOOK_MISSING", "BLV_DROPPED_BY_APP"];
|
|
2
|
+
export type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[number];
|
|
3
|
+
/** Public page explaining every code and the verification (one anchor per code). */
|
|
4
|
+
export declare const VERIFY_DOCS_URL = "https://belvador.dev/en/docs/sdk/verify";
|
|
5
|
+
/** One callback/console line per code per interval; repeats add to `count`. */
|
|
6
|
+
export declare const DIAGNOSTIC_INTERVAL_MS = 60000;
|
|
7
|
+
export type Diagnostic = {
|
|
8
|
+
code: DiagnosticCode;
|
|
9
|
+
/** Epoch ms of this report. */
|
|
10
|
+
at: number;
|
|
11
|
+
/** Occurrences since the previous report of this code (≥ 1). */
|
|
12
|
+
count: number;
|
|
13
|
+
/** HTTP status of the send, when there was one. */
|
|
14
|
+
status?: number;
|
|
15
|
+
/** Seconds the edge asked to wait (429). */
|
|
16
|
+
retryAfterS?: number;
|
|
17
|
+
};
|
|
18
|
+
export type DiagnosticsSnapshot = {
|
|
19
|
+
active: boolean;
|
|
20
|
+
sdk: {
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
};
|
|
24
|
+
/** Host, project and the first 4 characters of the key — never the whole DSN. */
|
|
25
|
+
dsn: {
|
|
26
|
+
host: string;
|
|
27
|
+
project: number;
|
|
28
|
+
keyHint: string;
|
|
29
|
+
} | null;
|
|
30
|
+
lastSend: {
|
|
31
|
+
status: number | "network";
|
|
32
|
+
at: number;
|
|
33
|
+
} | null;
|
|
34
|
+
counts: Partial<Record<DiagnosticCode, number>>;
|
|
35
|
+
};
|
|
36
|
+
type Config = {
|
|
37
|
+
onDiagnostic: ((d: Diagnostic) => void) | undefined;
|
|
38
|
+
debug: boolean;
|
|
39
|
+
sdk: {
|
|
40
|
+
name: string;
|
|
41
|
+
version: string;
|
|
42
|
+
};
|
|
43
|
+
dsn: DiagnosticsSnapshot["dsn"];
|
|
44
|
+
};
|
|
45
|
+
/** Anchor on the verification page for one code. */
|
|
46
|
+
export declare function diagnosticDocsUrl(code: DiagnosticCode): string;
|
|
47
|
+
export declare function diagnosticMessage(code: DiagnosticCode): string;
|
|
48
|
+
/** Set by init (and by every Worker invocation). Never throws. */
|
|
49
|
+
export declare function configureDiagnostics(next: Partial<Config>): void;
|
|
50
|
+
/** Records one occurrence; reports at most once per code per minute (NOT_INITIALIZED once). */
|
|
51
|
+
export declare function reportDiagnostic(code: DiagnosticCode, extra?: {
|
|
52
|
+
status?: number;
|
|
53
|
+
retryAfterS?: number;
|
|
54
|
+
}, now?: number): void;
|
|
55
|
+
export declare function noteSend(status: number | "network", now?: number): void;
|
|
56
|
+
/** Fixed-size snapshot for support and AI agents; contains no event data. */
|
|
57
|
+
export declare function snapshotDiagnostics(active: boolean): DiagnosticsSnapshot;
|
|
58
|
+
/** Tests only: forget every count and setting. */
|
|
59
|
+
export declare function resetDiagnostics(): void;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Local diagnostics (build integration-health-1.0 spec 02 §2): what went wrong in
|
|
2
|
+
// this app, said once per minute per code, never sent anywhere. The code list is a
|
|
3
|
+
// copy of `src/shared/contracts/integration-health.ts`; a root test keeps them equal.
|
|
4
|
+
// Imports nothing.
|
|
5
|
+
export const DIAGNOSTIC_CODES = [
|
|
6
|
+
"BLV_DSN_MISSING",
|
|
7
|
+
"BLV_DSN_INVALID",
|
|
8
|
+
"BLV_INIT_CONFLICT",
|
|
9
|
+
"BLV_NOT_INITIALIZED",
|
|
10
|
+
"BLV_SEND_UNAUTHORIZED",
|
|
11
|
+
"BLV_SEND_RATE_LIMITED",
|
|
12
|
+
"BLV_SEND_REJECTED",
|
|
13
|
+
"BLV_SEND_NETWORK",
|
|
14
|
+
"BLV_SEND_SERVER",
|
|
15
|
+
"BLV_QUEUE_FULL",
|
|
16
|
+
"BLV_EVENT_TOO_LARGE",
|
|
17
|
+
"BLV_HOOK_MISSING",
|
|
18
|
+
"BLV_DROPPED_BY_APP",
|
|
19
|
+
];
|
|
20
|
+
/** Public page explaining every code and the verification (one anchor per code). */
|
|
21
|
+
export const VERIFY_DOCS_URL = "https://belvador.dev/en/docs/sdk/verify";
|
|
22
|
+
/** One callback/console line per code per interval; repeats add to `count`. */
|
|
23
|
+
export const DIAGNOSTIC_INTERVAL_MS = 60000;
|
|
24
|
+
const MESSAGES = {
|
|
25
|
+
BLV_DSN_MISSING: "no DSN — the SDK is off",
|
|
26
|
+
BLV_DSN_INVALID: "the DSN is not valid — the SDK is off",
|
|
27
|
+
BLV_INIT_CONFLICT: "another error-tracking client is already running — init skipped",
|
|
28
|
+
BLV_NOT_INITIALIZED: "capture called while the SDK is not running",
|
|
29
|
+
BLV_SEND_UNAUTHORIZED: "the DSN key was refused (revoked or wrong project) — sending stopped",
|
|
30
|
+
BLV_SEND_RATE_LIMITED: "rate limited by Belvador — events are dropped until the wait ends",
|
|
31
|
+
BLV_SEND_REJECTED: "an event was rejected (too large or not accepted)",
|
|
32
|
+
BLV_SEND_NETWORK: "Belvador could not be reached after retries (network, CSP or ad blocker)",
|
|
33
|
+
BLV_SEND_SERVER: "Belvador answered with a server error after retries",
|
|
34
|
+
BLV_QUEUE_FULL: "the send queue is full — an event was dropped",
|
|
35
|
+
BLV_EVENT_TOO_LARGE: "an event or attachment is over the size limit — dropped",
|
|
36
|
+
BLV_HOOK_MISSING: "this server code does not run inside Belvador's middleware or wrapper",
|
|
37
|
+
BLV_DROPPED_BY_APP: "the event was dropped before sending (beforeSend, ignoreErrors or sampleRate)",
|
|
38
|
+
};
|
|
39
|
+
const config = {
|
|
40
|
+
onDiagnostic: undefined,
|
|
41
|
+
debug: false,
|
|
42
|
+
sdk: { name: "", version: "" },
|
|
43
|
+
dsn: null,
|
|
44
|
+
};
|
|
45
|
+
const perCode = new Map();
|
|
46
|
+
let lastSend = null;
|
|
47
|
+
/** Anchor on the verification page for one code. */
|
|
48
|
+
export function diagnosticDocsUrl(code) {
|
|
49
|
+
return `${VERIFY_DOCS_URL}#${code.toLowerCase().replace(/_/g, "-")}`;
|
|
50
|
+
}
|
|
51
|
+
export function diagnosticMessage(code) {
|
|
52
|
+
return MESSAGES[code];
|
|
53
|
+
}
|
|
54
|
+
/** Set by init (and by every Worker invocation). Never throws. */
|
|
55
|
+
export function configureDiagnostics(next) {
|
|
56
|
+
try {
|
|
57
|
+
Object.assign(config, next);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* frozen input */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function emit(d) {
|
|
64
|
+
const cb = config.onDiagnostic;
|
|
65
|
+
if (cb) {
|
|
66
|
+
try {
|
|
67
|
+
cb({ ...d });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* a failing callback never reaches the app or the SDK */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (config.debug) {
|
|
74
|
+
try {
|
|
75
|
+
console.warn(`[belvador] ${d.code}: ${MESSAGES[d.code]} — ${diagnosticDocsUrl(d.code)}`);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* no console */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Records one occurrence; reports at most once per code per minute (NOT_INITIALIZED once). */
|
|
83
|
+
export function reportDiagnostic(code, extra = {}, now = Date.now()) {
|
|
84
|
+
try {
|
|
85
|
+
const s = perCode.get(code) ?? { last: -Infinity, pending: 0, total: 0 };
|
|
86
|
+
s.total++;
|
|
87
|
+
s.pending++;
|
|
88
|
+
perCode.set(code, s);
|
|
89
|
+
const once = code === "BLV_NOT_INITIALIZED";
|
|
90
|
+
if (once ? s.total > 1 : now - s.last < DIAGNOSTIC_INTERVAL_MS)
|
|
91
|
+
return;
|
|
92
|
+
const d = { code, at: now, count: s.pending };
|
|
93
|
+
if (extra.status !== undefined)
|
|
94
|
+
d.status = extra.status;
|
|
95
|
+
if (extra.retryAfterS !== undefined)
|
|
96
|
+
d.retryAfterS = extra.retryAfterS;
|
|
97
|
+
s.last = now;
|
|
98
|
+
s.pending = 0;
|
|
99
|
+
emit(d);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* never throws */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function noteSend(status, now = Date.now()) {
|
|
106
|
+
lastSend = { status, at: now };
|
|
107
|
+
}
|
|
108
|
+
/** Fixed-size snapshot for support and AI agents; contains no event data. */
|
|
109
|
+
export function snapshotDiagnostics(active) {
|
|
110
|
+
const counts = {};
|
|
111
|
+
for (const [code, s] of perCode)
|
|
112
|
+
counts[code] = s.total;
|
|
113
|
+
return {
|
|
114
|
+
active,
|
|
115
|
+
sdk: { ...config.sdk },
|
|
116
|
+
dsn: config.dsn ? { ...config.dsn } : null,
|
|
117
|
+
lastSend: lastSend ? { ...lastSend } : null,
|
|
118
|
+
counts,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Tests only: forget every count and setting. */
|
|
122
|
+
export function resetDiagnostics() {
|
|
123
|
+
perCode.clear();
|
|
124
|
+
lastSend = null;
|
|
125
|
+
config.onDiagnostic = undefined;
|
|
126
|
+
config.debug = false;
|
|
127
|
+
config.sdk = { name: "", version: "" };
|
|
128
|
+
config.dsn = null;
|
|
129
|
+
}
|
package/dist/facade.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { Breadcrumb, ExclusiveEventHintOrCaptureContext, Scope, SeverityLevel } from "@sentry/core";
|
|
2
|
+
import { type DiagnosticsSnapshot } from "./diagnostics.js";
|
|
2
3
|
export type { Scope, SeverityLevel };
|
|
3
4
|
/** True when the current scope's client was set up by a Belvador `init`. */
|
|
4
5
|
export declare function isActive(): boolean;
|
|
6
|
+
/** Local diagnostics snapshot (integration-health spec 02 §2.2); no event data. */
|
|
7
|
+
export declare function getDiagnostics(): DiagnosticsSnapshot;
|
|
5
8
|
/** Returns the event id (not a delivery receipt); "" when the SDK is off. */
|
|
6
9
|
export declare function captureException(error: unknown, context?: ExclusiveEventHintOrCaptureContext): string;
|
|
7
10
|
export declare function captureMessage(message: string, level?: SeverityLevel): string;
|
package/dist/facade.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// function is a no-op returning a neutral value when no Belvador client is
|
|
3
3
|
// active, and never throws into the app.
|
|
4
4
|
import { addBreadcrumb as engineAddBreadcrumb, captureException as engineCaptureException, captureMessage as engineCaptureMessage, close as engineClose, flush as engineFlush, getClient, lastEventId as engineLastEventId, setContext as engineSetContext, setTag as engineSetTag, setTags as engineSetTags, setUser as engineSetUser, withScope as engineWithScope, } from "@sentry/core";
|
|
5
|
+
import { reportDiagnostic, snapshotDiagnostics } from "./diagnostics.js";
|
|
5
6
|
import { BELVADOR_MARKER } from "./options.js";
|
|
6
7
|
/** True when the current scope's client was set up by a Belvador `init`. */
|
|
7
8
|
export function isActive() {
|
|
@@ -13,6 +14,10 @@ export function isActive() {
|
|
|
13
14
|
return false;
|
|
14
15
|
}
|
|
15
16
|
}
|
|
17
|
+
/** Local diagnostics snapshot (integration-health spec 02 §2.2); no event data. */
|
|
18
|
+
export function getDiagnostics() {
|
|
19
|
+
return snapshotDiagnostics(isActive());
|
|
20
|
+
}
|
|
16
21
|
function guard(fallback, fn) {
|
|
17
22
|
if (!isActive())
|
|
18
23
|
return fallback;
|
|
@@ -25,9 +30,13 @@ function guard(fallback, fn) {
|
|
|
25
30
|
}
|
|
26
31
|
/** Returns the event id (not a delivery receipt); "" when the SDK is off. */
|
|
27
32
|
export function captureException(error, context) {
|
|
33
|
+
if (!isActive())
|
|
34
|
+
reportDiagnostic("BLV_NOT_INITIALIZED");
|
|
28
35
|
return guard("", () => engineCaptureException(error, context));
|
|
29
36
|
}
|
|
30
37
|
export function captureMessage(message, level) {
|
|
38
|
+
if (!isActive())
|
|
39
|
+
reportDiagnostic("BLV_NOT_INITIALIZED");
|
|
31
40
|
return guard("", () => engineCaptureMessage(String(message), level));
|
|
32
41
|
}
|
|
33
42
|
export function addBreadcrumb(crumb) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export { SDK_VERSION, ENGINE_VERSION } from "./version.js";
|
|
2
2
|
export { checkDsn, type BelvadorDsn } from "./dsn.js";
|
|
3
|
+
export { isAbortError, isClientAbort, noteClientAbort } from "./aborts.js";
|
|
3
4
|
export { FILTERED, SENSITIVE_KEY_PARTS, isSensitiveKey, scrubBreadcrumb, scrubEvent, scrubParams, scrubText, scrubValue, } from "./privacy.js";
|
|
4
5
|
export { SEND_LIMITS, SENT_ITEM_TYPES, filterEnvelope, makeBelvadorTransport, sendWithRetry, type FetchLike, type SendPolicy, type SendState, } from "./transport.js";
|
|
5
6
|
export { BELVADOR_MARKER, DEFAULT_MAX_BREADCRUMBS, MAX_BREADCRUMBS, MAX_VALUE_LENGTH, buildEngineOptions, warnOnce, type BelvadorBreadcrumb, type BelvadorEvent, type BelvadorEventHint, type BelvadorOptions, type EngineOptions, type RuntimeName, } from "./options.js";
|
|
6
|
-
export { addBreadcrumb, captureException, captureMessage, close, flush, isActive, lastEventId, setContext, setTag, setTags, setUser, withScope, type Scope, type SeverityLevel, } from "./facade.js";
|
|
7
|
+
export { addBreadcrumb, captureException, captureMessage, close, flush, getDiagnostics, isActive, lastEventId, setContext, setTag, setTags, setUser, withScope, type Scope, type SeverityLevel, } from "./facade.js";
|
|
8
|
+
export { DIAGNOSTIC_CODES, DIAGNOSTIC_INTERVAL_MS, VERIFY_DOCS_URL, configureDiagnostics, diagnosticDocsUrl, diagnosticMessage, reportDiagnostic, resetDiagnostics, type Diagnostic, type DiagnosticCode, type DiagnosticsSnapshot, } from "./diagnostics.js";
|
|
9
|
+
export { BelvadorVerificationError, VERIFICATION_CONTEXT, VERIFICATION_ERROR_TYPE, VERIFICATION_ID_RE, VERIFICATION_TAG, currentHooks, markHooked, newVerificationId, verificationOf, type HookKind, type VerificationContext, type VerificationSurface, } from "./verification.js";
|
|
10
|
+
export { VERIFY_LIMITS, runVerification, type CheckName, type CheckStatus, type VerificationResult, type VerifyOptions, type VerifyRuntime, } from "./verify.js";
|
|
11
|
+
export { diagnosticForStatus } from "./transport.js";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// /tanstack-start instead. Importing this module has no side effects.
|
|
4
4
|
export { SDK_VERSION, ENGINE_VERSION } from "./version.js";
|
|
5
5
|
export { checkDsn } from "./dsn.js";
|
|
6
|
+
export { isAbortError, isClientAbort, noteClientAbort } from "./aborts.js";
|
|
6
7
|
export { FILTERED, SENSITIVE_KEY_PARTS, isSensitiveKey, scrubBreadcrumb, scrubEvent, scrubParams, scrubText, scrubValue, } from "./privacy.js";
|
|
7
8
|
export { SEND_LIMITS, SENT_ITEM_TYPES, filterEnvelope, makeBelvadorTransport, sendWithRetry, } from "./transport.js";
|
|
8
9
|
export { BELVADOR_MARKER, DEFAULT_MAX_BREADCRUMBS, MAX_BREADCRUMBS, MAX_VALUE_LENGTH, buildEngineOptions, warnOnce, } from "./options.js";
|
|
9
|
-
export { addBreadcrumb, captureException, captureMessage, close, flush, isActive, lastEventId, setContext, setTag, setTags, setUser, withScope, } from "./facade.js";
|
|
10
|
+
export { addBreadcrumb, captureException, captureMessage, close, flush, getDiagnostics, isActive, lastEventId, setContext, setTag, setTags, setUser, withScope, } from "./facade.js";
|
|
11
|
+
export { DIAGNOSTIC_CODES, DIAGNOSTIC_INTERVAL_MS, VERIFY_DOCS_URL, configureDiagnostics, diagnosticDocsUrl, diagnosticMessage, reportDiagnostic, resetDiagnostics, } from "./diagnostics.js";
|
|
12
|
+
export { BelvadorVerificationError, VERIFICATION_CONTEXT, VERIFICATION_ERROR_TYPE, VERIFICATION_ID_RE, VERIFICATION_TAG, currentHooks, markHooked, newVerificationId, verificationOf, } from "./verification.js";
|
|
13
|
+
export { VERIFY_LIMITS, runVerification, } from "./verify.js";
|
|
14
|
+
export { diagnosticForStatus } from "./transport.js";
|
package/dist/options.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Breadcrumb, BreadcrumbHint, ErrorEvent, EventHint } from "@sentry/core";
|
|
2
|
+
import { type Diagnostic } from "./diagnostics.js";
|
|
2
3
|
import { type SendPolicy } from "./transport.js";
|
|
3
4
|
export type BelvadorEvent = ErrorEvent;
|
|
4
5
|
export type BelvadorEventHint = EventHint;
|
|
@@ -19,12 +20,24 @@ export type BelvadorOptions = {
|
|
|
19
20
|
/** Default 50, at most 100. */
|
|
20
21
|
maxBreadcrumbs?: number;
|
|
21
22
|
ignoreErrors?: Array<string | RegExp>;
|
|
23
|
+
/**
|
|
24
|
+
* Also report errors from work the client cancelled: an `AbortError` (fetch or stream
|
|
25
|
+
* aborted), or a server error thrown after the visitor closed the connection. Default
|
|
26
|
+
* false — cancellations are expected and never become issues. A `TimeoutError` is not
|
|
27
|
+
* a cancellation and is always reported.
|
|
28
|
+
*/
|
|
29
|
+
captureAborted?: boolean;
|
|
22
30
|
/** Send attachments added with scope.addAttachment (each ≤ 5 MB). Default false. */
|
|
23
31
|
attachments?: boolean;
|
|
24
32
|
/** Runs before Belvador's own scrubbing; return null to drop the event. */
|
|
25
33
|
beforeSend?: (event: BelvadorEvent, hint: BelvadorEventHint) => BelvadorEvent | null | PromiseLike<BelvadorEvent | null>;
|
|
26
34
|
/** Runs before Belvador's own scrubbing; return null to drop the breadcrumb. */
|
|
27
35
|
beforeBreadcrumb?: (crumb: BelvadorBreadcrumb, hint?: BreadcrumbHint) => BelvadorBreadcrumb | null;
|
|
36
|
+
/**
|
|
37
|
+
* Local diagnostics (missing DSN, refused key, failed sends, …): at most once a
|
|
38
|
+
* minute per code, never sent anywhere. Errors thrown here are ignored.
|
|
39
|
+
*/
|
|
40
|
+
onDiagnostic?: (diagnostic: Diagnostic) => void;
|
|
28
41
|
};
|
|
29
42
|
export type RuntimeName = "browser" | "react" | "cloudflare" | "tanstack-start";
|
|
30
43
|
export declare const DEFAULT_MAX_BREADCRUMBS = 50;
|
package/dist/options.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { isClientAbort } 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";
|
|
3
6
|
import { makeBelvadorTransport } from "./transport.js";
|
|
4
7
|
import { ENGINE_VERSION, SDK_VERSION } from "./version.js";
|
|
5
8
|
/** Engine package behind each runtime (for `sdk.packages`). */
|
|
@@ -30,19 +33,44 @@ export function warnOnce(message) {
|
|
|
30
33
|
function clamp(n, min, max, fallback) {
|
|
31
34
|
return typeof n === "number" && Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
|
32
35
|
}
|
|
33
|
-
function chainBeforeSend(user) {
|
|
36
|
+
function chainBeforeSend(user, captureAborted) {
|
|
34
37
|
return (event, hint) => {
|
|
38
|
+
// Integration health: a verification error carries its own tag and context; they are
|
|
39
|
+
// set before the app's beforeSend and restored after scrubbing (ids are not user data).
|
|
40
|
+
const v = verificationOf(hint?.originalException);
|
|
41
|
+
// Cancelled by the client: expected, dropped before the app's hook (not a diagnostic).
|
|
42
|
+
if (!v && !captureAborted && isClientAbort(hint?.originalException))
|
|
43
|
+
return null;
|
|
44
|
+
const mark = (e) => {
|
|
45
|
+
if (!v)
|
|
46
|
+
return e;
|
|
47
|
+
e.tags = { ...(e.tags ?? {}), [VERIFICATION_TAG]: v.id };
|
|
48
|
+
e.contexts = { ...(e.contexts ?? {}), [VERIFICATION_CONTEXT]: { ...v.context } };
|
|
49
|
+
return e;
|
|
50
|
+
};
|
|
51
|
+
const finish = (e) => {
|
|
52
|
+
if (!e) {
|
|
53
|
+
if (v) {
|
|
54
|
+
noteVerificationOutcome(v.id, v.context.check, { kind: "dropped" });
|
|
55
|
+
reportDiagnostic("BLV_DROPPED_BY_APP");
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// A scrubber failure drops the event (never sends it unscrubbed).
|
|
60
|
+
const clean = scrubEvent(e);
|
|
61
|
+
return clean ? mark(clean) : null;
|
|
62
|
+
};
|
|
35
63
|
let out;
|
|
36
64
|
try {
|
|
37
|
-
out = user ? user(event, hint) : event;
|
|
65
|
+
out = user ? user(mark(event), hint) : mark(event);
|
|
38
66
|
}
|
|
39
67
|
catch {
|
|
40
|
-
return null;
|
|
68
|
+
return finish(null);
|
|
41
69
|
}
|
|
42
70
|
if (out && typeof out.then === "function") {
|
|
43
|
-
return Promise.resolve(out).then((
|
|
71
|
+
return Promise.resolve(out).then(finish, () => finish(null));
|
|
44
72
|
}
|
|
45
|
-
return
|
|
73
|
+
return finish(out);
|
|
46
74
|
};
|
|
47
75
|
}
|
|
48
76
|
function chainBeforeBreadcrumb(user) {
|
|
@@ -63,9 +91,22 @@ function chainBeforeBreadcrumb(user) {
|
|
|
63
91
|
export function buildEngineOptions(opts, runtime, send) {
|
|
64
92
|
if (!opts || opts.disabled === true)
|
|
65
93
|
return null;
|
|
94
|
+
const sdkName = `belvador.javascript.${runtime}`;
|
|
66
95
|
const dsn = checkDsn(opts.dsn);
|
|
96
|
+
configureDiagnostics({
|
|
97
|
+
onDiagnostic: typeof opts.onDiagnostic === "function" ? opts.onDiagnostic : undefined,
|
|
98
|
+
debug: opts.debug === true,
|
|
99
|
+
sdk: { name: sdkName, version: SDK_VERSION },
|
|
100
|
+
dsn: dsn
|
|
101
|
+
? { host: dsn.host, project: dsn.projectId, keyHint: dsn.publicKey.slice(0, 4) }
|
|
102
|
+
: null,
|
|
103
|
+
});
|
|
67
104
|
if (!dsn) {
|
|
68
|
-
|
|
105
|
+
const missing = typeof opts.dsn !== "string" || opts.dsn.trim() === "";
|
|
106
|
+
reportDiagnostic(missing ? "BLV_DSN_MISSING" : "BLV_DSN_INVALID");
|
|
107
|
+
warnOnce(missing
|
|
108
|
+
? "no DSN — the SDK is off. Copy the DSN from your Belvador project settings."
|
|
109
|
+
: "invalid DSN — the SDK is off. Copy the DSN from your Belvador project settings.");
|
|
69
110
|
return null;
|
|
70
111
|
}
|
|
71
112
|
const attachments = opts.attachments === true;
|
|
@@ -86,7 +127,7 @@ export function buildEngineOptions(opts, runtime, send) {
|
|
|
86
127
|
// Never read SENTRY_TUNNEL / spotlight from the environment; events go only to the DSN.
|
|
87
128
|
tunnel: "",
|
|
88
129
|
spotlight: false,
|
|
89
|
-
beforeSend: chainBeforeSend(opts.beforeSend),
|
|
130
|
+
beforeSend: chainBeforeSend(opts.beforeSend, opts.captureAborted === true),
|
|
90
131
|
beforeBreadcrumb: chainBeforeBreadcrumb(opts.beforeBreadcrumb),
|
|
91
132
|
beforeSendTransaction: () => null,
|
|
92
133
|
defaultIntegrations: false,
|
|
@@ -94,7 +135,7 @@ export function buildEngineOptions(opts, runtime, send) {
|
|
|
94
135
|
[BELVADOR_MARKER]: true,
|
|
95
136
|
_metadata: {
|
|
96
137
|
sdk: {
|
|
97
|
-
name:
|
|
138
|
+
name: sdkName,
|
|
98
139
|
version: SDK_VERSION,
|
|
99
140
|
packages: [
|
|
100
141
|
{ name: `npm:@belvador/${runtime}`, version: SDK_VERSION },
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BaseTransportOptions, Envelope, Transport } from "@sentry/core";
|
|
2
|
+
import { type DiagnosticCode } from "./diagnostics.js";
|
|
2
3
|
/** The numbers of `docs/build-sdk-1.0/rules.md` §2. */
|
|
3
4
|
export declare const SEND_LIMITS: {
|
|
4
5
|
readonly maxQueue: 30;
|
|
@@ -28,7 +29,11 @@ export type SendState = {
|
|
|
28
29
|
queuedBytes: number;
|
|
29
30
|
keepaliveBytes: number;
|
|
30
31
|
keepaliveCount: number;
|
|
32
|
+
/** Epoch ms until which the edge asked us to wait (429), for diagnostics. */
|
|
33
|
+
rateLimitedUntil?: number;
|
|
31
34
|
};
|
|
35
|
+
/** Integration health spec 02 §2.1: what one HTTP answer means for the app. */
|
|
36
|
+
export declare function diagnosticForStatus(status: number): DiagnosticCode | null;
|
|
32
37
|
/**
|
|
33
38
|
* One request with the retry policy: network errors and 500/502/503/504 are
|
|
34
39
|
* retried up to 5 attempts (1/2/4/8 s; a 503 Retry-After ≤ 60 s is honoured),
|
package/dist/transport.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// One transport for every runtime, built on the engine's `createTransport`
|
|
4
4
|
// (rate limits per category, client reports) with our own request executor.
|
|
5
5
|
import { createEnvelope, createTransport, envelopeItemTypeToDataCategory, forEachEnvelopeItem, makePromiseBuffer, serializeEnvelope, suppressTracing, } from "@sentry/core";
|
|
6
|
+
import { noteSend, reportDiagnostic } from "./diagnostics.js";
|
|
7
|
+
import { noteVerificationOutcome, verificationOfEvent } from "./verification.js";
|
|
6
8
|
/** The numbers of `docs/build-sdk-1.0/rules.md` §2. */
|
|
7
9
|
export const SEND_LIMITS = {
|
|
8
10
|
maxQueue: 30,
|
|
@@ -22,6 +24,31 @@ const STOP_STATUS = new Set([401, 403]);
|
|
|
22
24
|
// Browsers cap keepalive bodies at 64 KiB in flight (fetch spec); stay below it.
|
|
23
25
|
const KEEPALIVE_MAX_BYTES = 60000;
|
|
24
26
|
const KEEPALIVE_MAX_COUNT = 15;
|
|
27
|
+
/** Integration health spec 02 §2.1: what one HTTP answer means for the app. */
|
|
28
|
+
export function diagnosticForStatus(status) {
|
|
29
|
+
if (status >= 200 && status < 300)
|
|
30
|
+
return null;
|
|
31
|
+
if (status === 401 || status === 403)
|
|
32
|
+
return "BLV_SEND_UNAUTHORIZED";
|
|
33
|
+
if (status === 429)
|
|
34
|
+
return "BLV_SEND_RATE_LIMITED";
|
|
35
|
+
if (status >= 500)
|
|
36
|
+
return "BLV_SEND_SERVER";
|
|
37
|
+
return "BLV_SEND_REJECTED";
|
|
38
|
+
}
|
|
39
|
+
/** Verification events in an envelope (id + check), read from tags and context. */
|
|
40
|
+
function verificationsIn(envelope) {
|
|
41
|
+
const out = [];
|
|
42
|
+
forEachEnvelopeItem(envelope, (item, type) => {
|
|
43
|
+
if (type !== "event")
|
|
44
|
+
return;
|
|
45
|
+
const payload = item[1];
|
|
46
|
+
const v = payload && typeof payload === "object" ? verificationOfEvent(payload) : null;
|
|
47
|
+
if (v)
|
|
48
|
+
out.push(v);
|
|
49
|
+
});
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
25
52
|
function utf8Length(s) {
|
|
26
53
|
let n = 0;
|
|
27
54
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -185,6 +212,7 @@ export function makeBelvadorTransport(policy) {
|
|
|
185
212
|
queuedBytes: 0,
|
|
186
213
|
keepaliveBytes: 0,
|
|
187
214
|
keepaliveCount: 0,
|
|
215
|
+
rateLimitedUntil: 0,
|
|
188
216
|
};
|
|
189
217
|
const fetchImpl = policy.fetch ?? globalThis.fetch.bind(globalThis);
|
|
190
218
|
const maxEnvelope = policy.attachments
|
|
@@ -209,11 +237,29 @@ export function makeBelvadorTransport(policy) {
|
|
|
209
237
|
...(options.headers ? { headers: options.headers } : {}),
|
|
210
238
|
...(policy.browser ? { referrerPolicy: "strict-origin", keepalive } : {}),
|
|
211
239
|
};
|
|
212
|
-
|
|
240
|
+
let res;
|
|
241
|
+
try {
|
|
242
|
+
res = await suppressTracing(() => sendWithRetry(fetchImpl, options.url, init, state, policy.sleep));
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
noteSend("network");
|
|
246
|
+
reportDiagnostic("BLV_SEND_NETWORK");
|
|
247
|
+
throw e;
|
|
248
|
+
}
|
|
213
249
|
const headers = {
|
|
214
250
|
"x-sentry-rate-limits": res.headers.get("X-Sentry-Rate-Limits"),
|
|
215
251
|
"retry-after": res.headers.get("Retry-After"),
|
|
216
252
|
};
|
|
253
|
+
noteSend(res.status);
|
|
254
|
+
const code = diagnosticForStatus(res.status);
|
|
255
|
+
if (code === "BLV_SEND_RATE_LIMITED") {
|
|
256
|
+
const after = retryAfterMs(res);
|
|
257
|
+
const seconds = Math.round((after ?? 60000) / 1000);
|
|
258
|
+
state.rateLimitedUntil = Date.now() + seconds * 1000;
|
|
259
|
+
reportDiagnostic(code, { status: res.status, retryAfterS: seconds });
|
|
260
|
+
}
|
|
261
|
+
else if (code)
|
|
262
|
+
reportDiagnostic(code, { status: res.status });
|
|
217
263
|
await discard(res);
|
|
218
264
|
return { statusCode: res.status, headers };
|
|
219
265
|
}
|
|
@@ -228,29 +274,62 @@ export function makeBelvadorTransport(policy) {
|
|
|
228
274
|
return {
|
|
229
275
|
send(envelope) {
|
|
230
276
|
try {
|
|
231
|
-
|
|
277
|
+
const verifications = verificationsIn(envelope);
|
|
278
|
+
const fail = (code, status) => {
|
|
279
|
+
for (const v of verifications)
|
|
280
|
+
noteVerificationOutcome(v.id, v.check, {
|
|
281
|
+
kind: "failed",
|
|
282
|
+
code,
|
|
283
|
+
...(status !== undefined ? { status } : {}),
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
if (state.stopped) {
|
|
287
|
+
fail("BLV_SEND_UNAUTHORIZED");
|
|
232
288
|
return Promise.resolve({});
|
|
289
|
+
}
|
|
233
290
|
const kept = filterEnvelope(envelope, policy, options.recordDroppedEvent);
|
|
234
291
|
if (!kept)
|
|
235
292
|
return Promise.resolve({});
|
|
236
293
|
const size = byteLength(serializeEnvelope(kept));
|
|
237
294
|
if (size > maxEnvelope) {
|
|
238
295
|
recordAll(kept, "send_error", options.recordDroppedEvent);
|
|
296
|
+
reportDiagnostic("BLV_EVENT_TOO_LARGE");
|
|
297
|
+
fail("BLV_EVENT_TOO_LARGE");
|
|
239
298
|
return Promise.resolve({});
|
|
240
299
|
}
|
|
241
300
|
if (state.queuedBytes + size > maxQueued) {
|
|
242
301
|
recordAll(kept, "queue_overflow", options.recordDroppedEvent);
|
|
302
|
+
reportDiagnostic("BLV_QUEUE_FULL");
|
|
303
|
+
fail("BLV_QUEUE_FULL");
|
|
243
304
|
return Promise.resolve({});
|
|
244
305
|
}
|
|
245
306
|
state.queuedBytes += size;
|
|
246
307
|
const release = () => {
|
|
247
308
|
state.queuedBytes -= size;
|
|
248
309
|
};
|
|
310
|
+
const settle = (r) => {
|
|
311
|
+
if (verifications.length === 0)
|
|
312
|
+
return;
|
|
313
|
+
const status = r.statusCode;
|
|
314
|
+
if (typeof status !== "number") {
|
|
315
|
+
// Dropped by the engine's rate limiter or lost on the network.
|
|
316
|
+
fail(Date.now() < (state.rateLimitedUntil ?? 0) ? "BLV_SEND_RATE_LIMITED" : "BLV_SEND_NETWORK");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const code = diagnosticForStatus(status);
|
|
320
|
+
if (code)
|
|
321
|
+
fail(code, status);
|
|
322
|
+
else
|
|
323
|
+
for (const v of verifications)
|
|
324
|
+
noteVerificationOutcome(v.id, v.check, { kind: "sent", status });
|
|
325
|
+
};
|
|
249
326
|
return Promise.resolve(inner.send(kept)).then((r) => {
|
|
250
327
|
release();
|
|
328
|
+
settle(r);
|
|
251
329
|
return r;
|
|
252
330
|
}, () => {
|
|
253
331
|
release();
|
|
332
|
+
settle({});
|
|
254
333
|
return {};
|
|
255
334
|
});
|
|
256
335
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { VERIFICATION_CONTEXT, VERIFICATION_ERROR_TYPE, VERIFICATION_ID_RE, VERIFICATION_TAG } from "./constants.js";
|
|
2
|
+
export { VERIFICATION_CONTEXT, VERIFICATION_ERROR_TYPE, VERIFICATION_ID_RE, VERIFICATION_TAG };
|
|
3
|
+
export type VerificationSurface = "browser" | "server";
|
|
4
|
+
export type VerificationCheck = "delivery" | "uncaught";
|
|
5
|
+
export type VerificationContext = {
|
|
6
|
+
v: 1;
|
|
7
|
+
surface: VerificationSurface;
|
|
8
|
+
check: VerificationCheck;
|
|
9
|
+
hook?: "ok" | "missing" | "unknown";
|
|
10
|
+
uncaught_expected?: boolean;
|
|
11
|
+
};
|
|
12
|
+
declare const MARK = "__belvadorVerification";
|
|
13
|
+
/** The error a verification captures or throws; carries its id and context. */
|
|
14
|
+
export declare class BelvadorVerificationError extends Error {
|
|
15
|
+
readonly [MARK]: {
|
|
16
|
+
id: string;
|
|
17
|
+
context: VerificationContext;
|
|
18
|
+
};
|
|
19
|
+
constructor(id: string, context: VerificationContext);
|
|
20
|
+
}
|
|
21
|
+
export declare function newVerificationId(): string;
|
|
22
|
+
/** The marker of a verification error, if `value` is one. */
|
|
23
|
+
export declare function verificationOf(value: unknown): {
|
|
24
|
+
id: string;
|
|
25
|
+
context: VerificationContext;
|
|
26
|
+
} | null;
|
|
27
|
+
export type SendOutcome = {
|
|
28
|
+
kind: "sent";
|
|
29
|
+
status: number;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "failed";
|
|
32
|
+
code: string;
|
|
33
|
+
status?: number;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "dropped";
|
|
36
|
+
};
|
|
37
|
+
export declare function noteVerificationOutcome(id: string, check: string, outcome: SendOutcome): void;
|
|
38
|
+
export declare function verificationOutcome(id: string, check: string): SendOutcome | undefined;
|
|
39
|
+
/** The verification id/check of an event, read from its tags and Belvador context. */
|
|
40
|
+
export declare function verificationOfEvent(event: {
|
|
41
|
+
tags?: Record<string, unknown>;
|
|
42
|
+
contexts?: Record<string, unknown>;
|
|
43
|
+
}): {
|
|
44
|
+
id: string;
|
|
45
|
+
check: string;
|
|
46
|
+
} | null;
|
|
47
|
+
export type HookKind = "middleware" | "wrapper";
|
|
48
|
+
/** Called by Belvador's request/function middleware and Worker wrapper. Never throws. */
|
|
49
|
+
export declare function markHooked(kind: HookKind): void;
|
|
50
|
+
/** Hooks around the code running now (the current request). */
|
|
51
|
+
export declare function currentHooks(): ReadonlySet<HookKind>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Verification marker and per-request hook marks (build integration-health-1.0
|
|
2
|
+
// spec 02 §3). A verification is an ordinary error event with a reserved tag; the
|
|
3
|
+
// SDK attaches the tag and context in beforeSend from the thrown error itself, so
|
|
4
|
+
// captured and uncaught verifications take the same path as real errors.
|
|
5
|
+
import { getIsolationScope } from "@sentry/core";
|
|
6
|
+
import { VERIFICATION_CONTEXT, VERIFICATION_ERROR_TYPE, VERIFICATION_ID_RE, VERIFICATION_TAG, } from "./constants.js";
|
|
7
|
+
export { VERIFICATION_CONTEXT, VERIFICATION_ERROR_TYPE, VERIFICATION_ID_RE, VERIFICATION_TAG };
|
|
8
|
+
const MARK = "__belvadorVerification";
|
|
9
|
+
/** The error a verification captures or throws; carries its id and context. */
|
|
10
|
+
export class BelvadorVerificationError extends Error {
|
|
11
|
+
constructor(id, context) {
|
|
12
|
+
super(context.check === "uncaught"
|
|
13
|
+
? "Belvador verification (uncaught, expected) — safe to ignore"
|
|
14
|
+
: "Belvador verification (delivery) — safe to ignore");
|
|
15
|
+
this.name = VERIFICATION_ERROR_TYPE;
|
|
16
|
+
this[MARK] = { id, context };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function newVerificationId() {
|
|
20
|
+
const bytes = new Uint8Array(16);
|
|
21
|
+
crypto.getRandomValues(bytes);
|
|
22
|
+
let hex = "";
|
|
23
|
+
for (const b of bytes)
|
|
24
|
+
hex += b.toString(16).padStart(2, "0");
|
|
25
|
+
return `bv1_${hex}`;
|
|
26
|
+
}
|
|
27
|
+
/** The marker of a verification error, if `value` is one. */
|
|
28
|
+
export function verificationOf(value) {
|
|
29
|
+
try {
|
|
30
|
+
if (value === null || typeof value !== "object")
|
|
31
|
+
return null;
|
|
32
|
+
const m = value[MARK];
|
|
33
|
+
if (!m || typeof m.id !== "string" || !VERIFICATION_ID_RE.test(m.id))
|
|
34
|
+
return null;
|
|
35
|
+
if (!m.context || typeof m.context !== "object")
|
|
36
|
+
return null;
|
|
37
|
+
return m;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const outcomes = new Map();
|
|
44
|
+
const key = (id, check) => `${id}:${check}`;
|
|
45
|
+
export function noteVerificationOutcome(id, check, outcome) {
|
|
46
|
+
if (outcomes.size > 50)
|
|
47
|
+
outcomes.clear();
|
|
48
|
+
outcomes.set(key(id, check), outcome);
|
|
49
|
+
}
|
|
50
|
+
export function verificationOutcome(id, check) {
|
|
51
|
+
return outcomes.get(key(id, check));
|
|
52
|
+
}
|
|
53
|
+
/** The verification id/check of an event, read from its tags and Belvador context. */
|
|
54
|
+
export function verificationOfEvent(event) {
|
|
55
|
+
try {
|
|
56
|
+
const id = event.tags?.[VERIFICATION_TAG];
|
|
57
|
+
const ctx = event.contexts?.[VERIFICATION_CONTEXT];
|
|
58
|
+
if (typeof id !== "string" || !VERIFICATION_ID_RE.test(id))
|
|
59
|
+
return null;
|
|
60
|
+
return { id, check: typeof ctx?.check === "string" ? ctx.check : "delivery" };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const hooked = new WeakMap();
|
|
67
|
+
/** Called by Belvador's request/function middleware and Worker wrapper. Never throws. */
|
|
68
|
+
export function markHooked(kind) {
|
|
69
|
+
try {
|
|
70
|
+
const scope = getIsolationScope();
|
|
71
|
+
const set = hooked.get(scope) ?? new Set();
|
|
72
|
+
set.add(kind);
|
|
73
|
+
hooked.set(scope, set);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* no scope */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Hooks around the code running now (the current request). */
|
|
80
|
+
export function currentHooks() {
|
|
81
|
+
try {
|
|
82
|
+
return hooked.get(getIsolationScope()) ?? new Set();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return new Set();
|
|
86
|
+
}
|
|
87
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { VERIFY_LIMITS } from "./constants.js";
|
|
2
|
+
import { type HookKind, type VerificationSurface } from "./verification.js";
|
|
3
|
+
export { VERIFY_LIMITS };
|
|
4
|
+
export type VerifyOptions = {
|
|
5
|
+
/** Whole run, default 20 000 ms, at most 30 000. */
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
/** Browser: also prove that uncaught errors are reported (default true). */
|
|
8
|
+
uncaught?: boolean;
|
|
9
|
+
/** Ask Belvador whether the events arrived (default true). */
|
|
10
|
+
readback?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type CheckName = "init" | "hook" | "delivery" | "uncaught";
|
|
13
|
+
export type CheckStatus = "confirmed" | "accepted" | "missing" | "failed" | "skipped";
|
|
14
|
+
export type VerificationResult = {
|
|
15
|
+
verificationId: string;
|
|
16
|
+
surface: VerificationSurface;
|
|
17
|
+
sdk: {
|
|
18
|
+
name: string;
|
|
19
|
+
version: string;
|
|
20
|
+
};
|
|
21
|
+
/** Project id from the DSN, or null when there is none. */
|
|
22
|
+
project: number | null;
|
|
23
|
+
checks: Array<{
|
|
24
|
+
check: CheckName;
|
|
25
|
+
status: CheckStatus;
|
|
26
|
+
code?: string;
|
|
27
|
+
}>;
|
|
28
|
+
outcome: "verified" | "problem" | "unconfirmed";
|
|
29
|
+
node?: "connected" | "offline";
|
|
30
|
+
docsUrl: string;
|
|
31
|
+
};
|
|
32
|
+
/** What a runtime package tells the runner. */
|
|
33
|
+
export type VerifyRuntime = {
|
|
34
|
+
surface: VerificationSurface;
|
|
35
|
+
/** Browser: global handlers installed. Server: hooks around the current request. */
|
|
36
|
+
hook: () => {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
};
|
|
39
|
+
/** Server: which hook kinds count (TanStack Start: middleware; Workers: wrapper or middleware). */
|
|
40
|
+
serverHooks?: readonly HookKind[];
|
|
41
|
+
/** Test seams. */
|
|
42
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
43
|
+
sleep?: (ms: number) => Promise<void>;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
};
|
|
46
|
+
/** Runs the verification for one runtime. Concurrent calls share one run. */
|
|
47
|
+
export declare function runVerification(runtime: VerifyRuntime, options?: VerifyOptions): Promise<VerificationResult>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// verifyIntegration (build integration-health-1.0 spec 02 §3): proves that one
|
|
2
|
+
// runtime of this app reaches the right Belvador project. Only runs when the app
|
|
3
|
+
// calls it; ≤ 2 events and ≤ 10 read-backs per call; always resolves, never throws.
|
|
4
|
+
import { captureException as engineCaptureException, flush as engineFlush, getClient } from "@sentry/core";
|
|
5
|
+
import { VERIFY_DOCS_URL, diagnosticDocsUrl, reportDiagnostic, } from "./diagnostics.js";
|
|
6
|
+
import { VERIFY_LIMITS } from "./constants.js";
|
|
7
|
+
import { checkDsn } from "./dsn.js";
|
|
8
|
+
import { getDiagnostics, isActive } from "./facade.js";
|
|
9
|
+
import { BelvadorVerificationError, newVerificationId, verificationOutcome, } from "./verification.js";
|
|
10
|
+
export { VERIFY_LIMITS };
|
|
11
|
+
const running = new Map();
|
|
12
|
+
function clampTimeout(ms) {
|
|
13
|
+
return typeof ms === "number" && Number.isFinite(ms)
|
|
14
|
+
? Math.min(VERIFY_LIMITS.timeoutMaxMs, Math.max(1000, ms))
|
|
15
|
+
: VERIFY_LIMITS.timeoutDefaultMs;
|
|
16
|
+
}
|
|
17
|
+
function line(r) {
|
|
18
|
+
const parts = r.checks
|
|
19
|
+
.filter((c) => c.check !== "init" || c.status !== "confirmed")
|
|
20
|
+
.map((c) => `${c.check} ${c.status}${c.code ? ` (${c.code})` : ""}`);
|
|
21
|
+
return `[belvador] verify ${r.surface}: ${r.outcome} — ${parts.join(", ")} — ${r.verificationId} — ${r.docsUrl}`;
|
|
22
|
+
}
|
|
23
|
+
function print(r) {
|
|
24
|
+
try {
|
|
25
|
+
(r.outcome === "verified" ? console.info : console.warn)(line(r));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* no console */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Runs the verification for one runtime. Concurrent calls share one run. */
|
|
32
|
+
export function runVerification(runtime, options = {}) {
|
|
33
|
+
const current = running.get(runtime.surface);
|
|
34
|
+
if (current)
|
|
35
|
+
return current;
|
|
36
|
+
const p = execute(runtime, options)
|
|
37
|
+
.catch(() => fallback(runtime.surface))
|
|
38
|
+
.then((r) => {
|
|
39
|
+
print(r);
|
|
40
|
+
return r;
|
|
41
|
+
})
|
|
42
|
+
.finally(() => running.delete(runtime.surface));
|
|
43
|
+
running.set(runtime.surface, p);
|
|
44
|
+
return p;
|
|
45
|
+
}
|
|
46
|
+
function fallback(surface) {
|
|
47
|
+
return {
|
|
48
|
+
verificationId: "",
|
|
49
|
+
surface,
|
|
50
|
+
sdk: getDiagnostics().sdk,
|
|
51
|
+
project: null,
|
|
52
|
+
checks: [{ check: "init", status: "failed", code: "BLV_NOT_INITIALIZED" }],
|
|
53
|
+
outcome: "problem",
|
|
54
|
+
docsUrl: VERIFY_DOCS_URL,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function execute(runtime, options) {
|
|
58
|
+
const now = runtime.now ?? Date.now;
|
|
59
|
+
const started = now();
|
|
60
|
+
const deadline = started + clampTimeout(options.timeoutMs);
|
|
61
|
+
const sleep = runtime.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
62
|
+
const id = newVerificationId();
|
|
63
|
+
const snapshot = getDiagnostics();
|
|
64
|
+
const dsnValue = getClient()?.getOptions()?.dsn;
|
|
65
|
+
const dsn = checkDsn(dsnValue);
|
|
66
|
+
const result = {
|
|
67
|
+
verificationId: id,
|
|
68
|
+
surface: runtime.surface,
|
|
69
|
+
sdk: snapshot.sdk,
|
|
70
|
+
project: dsn?.projectId ?? snapshot.dsn?.project ?? null,
|
|
71
|
+
checks: [],
|
|
72
|
+
outcome: "problem",
|
|
73
|
+
docsUrl: VERIFY_DOCS_URL,
|
|
74
|
+
};
|
|
75
|
+
// 1. init
|
|
76
|
+
if (!isActive() || !dsn) {
|
|
77
|
+
const code = ["BLV_DSN_MISSING", "BLV_DSN_INVALID", "BLV_INIT_CONFLICT"].find((c) => (snapshot.counts[c] ?? 0) > 0);
|
|
78
|
+
result.checks.push({ check: "init", status: "failed", code: code ?? "BLV_NOT_INITIALIZED" });
|
|
79
|
+
result.docsUrl = diagnosticDocsUrl(code ?? "BLV_NOT_INITIALIZED");
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
result.checks.push({ check: "init", status: "confirmed" });
|
|
83
|
+
// 2. hook
|
|
84
|
+
const hookOk = runtime.hook().ok;
|
|
85
|
+
if (!hookOk)
|
|
86
|
+
reportDiagnostic("BLV_HOOK_MISSING");
|
|
87
|
+
result.checks.push(hookOk ? { check: "hook", status: "confirmed" } : { check: "hook", status: "missing", code: "BLV_HOOK_MISSING" });
|
|
88
|
+
// 3. delivery (+ 4. uncaught in the browser)
|
|
89
|
+
const wantUncaught = runtime.surface === "browser" && options.uncaught !== false;
|
|
90
|
+
engineCaptureException(new BelvadorVerificationError(id, {
|
|
91
|
+
v: 1,
|
|
92
|
+
surface: runtime.surface,
|
|
93
|
+
check: "delivery",
|
|
94
|
+
...(runtime.surface === "server"
|
|
95
|
+
? { hook: hookOk ? "ok" : "missing" }
|
|
96
|
+
: { uncaught_expected: wantUncaught }),
|
|
97
|
+
}), { level: "info" });
|
|
98
|
+
if (wantUncaught) {
|
|
99
|
+
// The page's own timer, so the throw reaches window.onerror like any app error.
|
|
100
|
+
const w = globalThis.window;
|
|
101
|
+
const schedule = w?.setTimeout ? w.setTimeout.bind(w) : setTimeout;
|
|
102
|
+
schedule(() => {
|
|
103
|
+
throw new BelvadorVerificationError(id, { v: 1, surface: "browser", check: "uncaught" });
|
|
104
|
+
}, 0);
|
|
105
|
+
// Let the global handler see it before flushing.
|
|
106
|
+
await sleep(50);
|
|
107
|
+
}
|
|
108
|
+
await engineFlush(Math.max(1000, Math.min(5000, deadline - now()))).catch(() => false);
|
|
109
|
+
const local = (check) => {
|
|
110
|
+
const o = verificationOutcome(id, check);
|
|
111
|
+
if (!o)
|
|
112
|
+
return { status: "failed", code: "BLV_DROPPED_BY_APP" };
|
|
113
|
+
if (o.kind === "dropped")
|
|
114
|
+
return { status: "failed", code: "BLV_DROPPED_BY_APP" };
|
|
115
|
+
if (o.kind === "failed")
|
|
116
|
+
return { status: "failed", code: o.code };
|
|
117
|
+
return { status: "accepted" };
|
|
118
|
+
};
|
|
119
|
+
const delivery = local("delivery");
|
|
120
|
+
const uncaught = wantUncaught ? local("uncaught") : { status: "skipped" };
|
|
121
|
+
// 5. read back
|
|
122
|
+
let readback = null;
|
|
123
|
+
const sendsOk = delivery.status === "accepted";
|
|
124
|
+
if (options.readback !== false && sendsOk) {
|
|
125
|
+
const doFetch = runtime.fetch ?? ((u, i) => globalThis.fetch(u, i));
|
|
126
|
+
const url = `${dsn.protocol}//${dsn.host}/api/${dsn.projectId}/verification/${id}/?sentry_key=${dsn.publicKey}`;
|
|
127
|
+
for (let i = 0; i < VERIFY_LIMITS.readbackMaxRequests; i++) {
|
|
128
|
+
const wait = VERIFY_LIMITS.readbackDelaysMs[Math.min(i, VERIFY_LIMITS.readbackDelaysMs.length - 1)];
|
|
129
|
+
if (now() + wait > deadline)
|
|
130
|
+
break;
|
|
131
|
+
await sleep(wait);
|
|
132
|
+
try {
|
|
133
|
+
const res = await doFetch(url, { method: "GET", credentials: "omit" });
|
|
134
|
+
if (res.status === 200) {
|
|
135
|
+
const body = (await res.json());
|
|
136
|
+
readback = { node: body.node === "offline" ? "offline" : "connected", checks: Array.isArray(body.checks) ? body.checks : [] };
|
|
137
|
+
const has = (c) => readback.checks.some((x) => x.surface === runtime.surface && x.check === c);
|
|
138
|
+
if (has("delivery") && (!wantUncaught || has("uncaught") || uncaught.status !== "accepted"))
|
|
139
|
+
break;
|
|
140
|
+
if (readback.node === "offline")
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
else if (res.status === 429 || res.status === 401 || res.status === 403) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
/* retry within the deadline */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const confirmed = (check) => readback?.checks.some((x) => x.surface === runtime.surface && x.check === check) ?? false;
|
|
153
|
+
const finalize = (check, s) => {
|
|
154
|
+
if (s.status !== "accepted")
|
|
155
|
+
return s;
|
|
156
|
+
if (confirmed(check))
|
|
157
|
+
return { status: "confirmed" };
|
|
158
|
+
// Read back worked and the node is up, yet nothing arrived: the event is missing.
|
|
159
|
+
if (readback && readback.node === "connected")
|
|
160
|
+
return { status: "missing" };
|
|
161
|
+
return s;
|
|
162
|
+
};
|
|
163
|
+
const d = finalize("delivery", delivery);
|
|
164
|
+
result.checks.push({ check: "delivery", ...d });
|
|
165
|
+
if (runtime.surface === "browser")
|
|
166
|
+
result.checks.push({ check: "uncaught", ...finalize("uncaught", uncaught) });
|
|
167
|
+
if (readback)
|
|
168
|
+
result.node = readback.node;
|
|
169
|
+
const all = result.checks.filter((c) => c.status !== "skipped");
|
|
170
|
+
if (all.some((c) => c.status === "failed" || c.status === "missing"))
|
|
171
|
+
result.outcome = "problem";
|
|
172
|
+
else if (all.every((c) => c.status === "confirmed") && !result.checks.some((c) => c.status === "skipped"))
|
|
173
|
+
result.outcome = "verified";
|
|
174
|
+
else
|
|
175
|
+
result.outcome = "unconfirmed";
|
|
176
|
+
const first = result.checks.find((c) => c.code);
|
|
177
|
+
if (first?.code)
|
|
178
|
+
result.docsUrl = diagnosticDocsUrl(first.code);
|
|
179
|
+
return result;
|
|
180
|
+
}
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// One version for every @belvador/* package (lockstep). Changed only by
|
|
2
2
|
// `bun scripts/set-version.ts`; tests compare it with every package.json and
|
|
3
3
|
// with src/content/sdk-matrix.ts in the web app.
|
|
4
|
-
export const SDK_VERSION = "0.
|
|
4
|
+
export const SDK_VERSION = "0.2.0";
|
|
5
5
|
/** The audited engine version every package pins exactly (research/05-decisions.md D3). */
|
|
6
6
|
export const ENGINE_VERSION = "10.76.2";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@belvador/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Internal building blocks of the Belvador SDK (privacy, sending policy). Install @belvador/browser, @belvador/react, @belvador/cloudflare or @belvador/tanstack-start instead.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Belvador (https://belvador.dev)",
|