@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/trace.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Trace propagation (build observability-1.0 spec 06 §5, SEC-ING-22): off by default.
|
|
2
|
+
// Requests to the app's own targets get `traceparent` (W3C, flags 00 — nothing sampled)
|
|
3
|
+
// and `sentry-trace`; `baggage` only when asked for. Errors-only: no spans, no
|
|
4
|
+
// transactions. Incoming `traceparent` is read on the server so one trace id links
|
|
5
|
+
// the browser error, the server error and the job's check-ins.
|
|
6
|
+
import { addFetchInstrumentationHandler, dynamicSamplingContextToSentryBaggageHeader, generateSpanId, getActiveSpan, getClient, getCurrentScope, getDynamicSamplingContextFromScope, isMatchingPattern, spanToTraceContext, } from "@sentry/core";
|
|
7
|
+
import { reportDiagnostic } from "./diagnostics.js";
|
|
8
|
+
/** Engine-options key carrying the checked config of one client. */
|
|
9
|
+
export const TRACE_OPTION = "_belvadorTrace";
|
|
10
|
+
/** Valid targets only (others reported once a minute); null = propagation off. */
|
|
11
|
+
export function traceConfigOf(opts) {
|
|
12
|
+
try {
|
|
13
|
+
if (!opts || !Array.isArray(opts.targets))
|
|
14
|
+
return null;
|
|
15
|
+
const targets = [];
|
|
16
|
+
for (const t of opts.targets) {
|
|
17
|
+
if ((typeof t === "string" && t.length > 0) || t instanceof RegExp)
|
|
18
|
+
targets.push(t);
|
|
19
|
+
else
|
|
20
|
+
reportDiagnostic("BLV_TRACE_TARGET_INVALID");
|
|
21
|
+
}
|
|
22
|
+
return targets.length ? { targets, baggage: opts.baggage === true } : null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/;
|
|
29
|
+
const SENTRY_TRACE = /^[0-9a-f]{32}-[0-9a-f]{16}(-[01])?$/;
|
|
30
|
+
/** W3C `traceparent` → `sentry-trace` (sampling left to the receiver), or null when invalid. */
|
|
31
|
+
export function sentryTraceFromTraceparent(value) {
|
|
32
|
+
if (typeof value !== "string")
|
|
33
|
+
return null;
|
|
34
|
+
const m = TRACEPARENT.exec(value.trim().toLowerCase());
|
|
35
|
+
if (!m || /^0+$/.test(m[1]) || /^0+$/.test(m[2]))
|
|
36
|
+
return null;
|
|
37
|
+
return `${m[1]}-${m[2]}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Server side: a request carrying a valid `traceparent` but no `sentry-trace` gets the
|
|
41
|
+
* equivalent `sentry-trace`, so the engine continues the same trace. Anything else is
|
|
42
|
+
* returned unchanged (same object).
|
|
43
|
+
*/
|
|
44
|
+
export function withIncomingTraceparent(request) {
|
|
45
|
+
try {
|
|
46
|
+
if (!request || typeof request.headers?.get !== "function")
|
|
47
|
+
return request;
|
|
48
|
+
const existing = request.headers.get("sentry-trace");
|
|
49
|
+
if (existing && SENTRY_TRACE.test(existing.trim()))
|
|
50
|
+
return request;
|
|
51
|
+
const converted = sentryTraceFromTraceparent(request.headers.get("traceparent"));
|
|
52
|
+
if (!converted)
|
|
53
|
+
return request;
|
|
54
|
+
const headers = new Headers(request.headers);
|
|
55
|
+
headers.set("sentry-trace", converted);
|
|
56
|
+
// Never trust an incoming baggage without its sentry-trace (it would set sampling).
|
|
57
|
+
headers.delete("baggage");
|
|
58
|
+
return new Request(request, { headers });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return request;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function originOf() {
|
|
65
|
+
try {
|
|
66
|
+
const loc = globalThis.location;
|
|
67
|
+
return typeof loc?.href === "string" ? loc.href : null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Sentry's rule: the whole URL, or (browsers) the path of a same-origin URL. */
|
|
74
|
+
export function urlMatches(url, targets) {
|
|
75
|
+
const test = (s) => targets.some((t) => isMatchingPattern(s, t, false));
|
|
76
|
+
if (test(url))
|
|
77
|
+
return true;
|
|
78
|
+
const base = originOf();
|
|
79
|
+
if (!base)
|
|
80
|
+
return false;
|
|
81
|
+
try {
|
|
82
|
+
const resolved = new URL(url, base);
|
|
83
|
+
const page = new URL(base);
|
|
84
|
+
if (resolved.origin !== page.origin)
|
|
85
|
+
return false;
|
|
86
|
+
return test(resolved.href) || test(resolved.pathname);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function clientConfig() {
|
|
93
|
+
try {
|
|
94
|
+
const client = getClient();
|
|
95
|
+
const cfg = client?.getOptions()?.[TRACE_OPTION];
|
|
96
|
+
if (!client || !cfg)
|
|
97
|
+
return null;
|
|
98
|
+
const dsn = client.getDsn();
|
|
99
|
+
const ingestOrigin = dsn ? `${dsn.protocol}://${dsn.host}${dsn.port ? `:${dsn.port}` : ""}` : "";
|
|
100
|
+
return { cfg, ingestOrigin };
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The current trace as headers: the active span's ids when there is one (a server request),
|
|
108
|
+
* else the scope's trace with a fresh span id. Errors-only, so the sampling flag is never set
|
|
109
|
+
* (`traceparent` flags 00, `sentry-trace` without the third part): the receiver decides.
|
|
110
|
+
*/
|
|
111
|
+
export function traceHeaders(baggage) {
|
|
112
|
+
const scope = getCurrentScope();
|
|
113
|
+
const span = getActiveSpan();
|
|
114
|
+
const ctx = span ? spanToTraceContext(span) : null;
|
|
115
|
+
const pc = scope.getPropagationContext();
|
|
116
|
+
const traceId = ctx?.trace_id ?? pc.traceId;
|
|
117
|
+
const spanId = ctx?.span_id ?? pc.propagationSpanId ?? generateSpanId();
|
|
118
|
+
if (!/^[0-9a-f]{32}$/.test(traceId) || !/^[0-9a-f]{16}$/.test(spanId))
|
|
119
|
+
return null;
|
|
120
|
+
const out = {
|
|
121
|
+
"sentry-trace": `${traceId}-${spanId}`,
|
|
122
|
+
traceparent: `00-${traceId}-${spanId}-00`,
|
|
123
|
+
};
|
|
124
|
+
if (baggage) {
|
|
125
|
+
const client = getClient();
|
|
126
|
+
const header = client
|
|
127
|
+
? dynamicSamplingContextToSentryBaggageHeader(getDynamicSamplingContextFromScope(client, scope))
|
|
128
|
+
: undefined;
|
|
129
|
+
if (header)
|
|
130
|
+
out["baggage"] = header;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function addHeaders(data, cfg) {
|
|
135
|
+
const trace = traceHeaders(cfg.baggage);
|
|
136
|
+
const sentryTrace = trace?.["sentry-trace"];
|
|
137
|
+
if (!trace || !sentryTrace)
|
|
138
|
+
return;
|
|
139
|
+
const input = data.args[0];
|
|
140
|
+
const init = { ...(data.args[1] ?? {}) };
|
|
141
|
+
const from = init["headers"] ??
|
|
142
|
+
(typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined);
|
|
143
|
+
const headers = new Headers(from);
|
|
144
|
+
if (!headers.has("sentry-trace"))
|
|
145
|
+
headers.set("sentry-trace", sentryTrace);
|
|
146
|
+
if (trace["traceparent"] && !headers.has("traceparent"))
|
|
147
|
+
headers.set("traceparent", trace["traceparent"]);
|
|
148
|
+
if (cfg.baggage && trace["baggage"] && !headers.has("baggage"))
|
|
149
|
+
headers.set("baggage", trace["baggage"]);
|
|
150
|
+
init["headers"] = headers;
|
|
151
|
+
data.args[1] = init;
|
|
152
|
+
}
|
|
153
|
+
let installed = false;
|
|
154
|
+
/** Installs the fetch hook once per process; each client decides with its own config. */
|
|
155
|
+
export function installTracePropagation() {
|
|
156
|
+
if (installed)
|
|
157
|
+
return;
|
|
158
|
+
installed = true;
|
|
159
|
+
try {
|
|
160
|
+
addFetchInstrumentationHandler((data) => {
|
|
161
|
+
try {
|
|
162
|
+
const d = data;
|
|
163
|
+
if (d.endTimestamp || !d.fetchData)
|
|
164
|
+
return;
|
|
165
|
+
const c = clientConfig();
|
|
166
|
+
if (!c)
|
|
167
|
+
return;
|
|
168
|
+
const url = d.fetchData.url;
|
|
169
|
+
// Never on the SDK's own sends.
|
|
170
|
+
if (c.ingestOrigin && url.startsWith(c.ingestOrigin))
|
|
171
|
+
return;
|
|
172
|
+
if (!urlMatches(url, c.cfg.targets))
|
|
173
|
+
return;
|
|
174
|
+
addHeaders(d, c.cfg);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* a request is never broken by tracing */
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
installed = false;
|
|
183
|
+
}
|
|
184
|
+
}
|
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;
|
|
@@ -13,6 +14,8 @@ export declare const SEND_LIMITS: {
|
|
|
13
14
|
};
|
|
14
15
|
/** Item types the node stores (audit §2); everything else never leaves the app. */
|
|
15
16
|
export declare const SENT_ITEM_TYPES: readonly string[];
|
|
17
|
+
/** Of the 30 queued sends, check-ins take at most this many (spec 06 §4): neither storm starves the other. */
|
|
18
|
+
export declare const MAX_QUEUED_CHECK_INS = 10;
|
|
16
19
|
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
17
20
|
export type SendPolicy = {
|
|
18
21
|
attachments: boolean;
|
|
@@ -28,7 +31,13 @@ export type SendState = {
|
|
|
28
31
|
queuedBytes: number;
|
|
29
32
|
keepaliveBytes: number;
|
|
30
33
|
keepaliveCount: number;
|
|
34
|
+
/** Epoch ms until which the edge asked us to wait (429), for diagnostics. */
|
|
35
|
+
rateLimitedUntil?: number;
|
|
36
|
+
/** Check-in envelopes waiting or in flight (≤ MAX_QUEUED_CHECK_INS). */
|
|
37
|
+
checkIns?: number;
|
|
31
38
|
};
|
|
39
|
+
/** Integration health spec 02 §2.1: what one HTTP answer means for the app. */
|
|
40
|
+
export declare function diagnosticForStatus(status: number): DiagnosticCode | null;
|
|
32
41
|
/**
|
|
33
42
|
* One request with the retry policy: network errors and 500/502/503/504 are
|
|
34
43
|
* 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,
|
|
@@ -16,12 +18,39 @@ export const SEND_LIMITS = {
|
|
|
16
18
|
maxRetryAfterMs: 60000,
|
|
17
19
|
};
|
|
18
20
|
/** Item types the node stores (audit §2); everything else never leaves the app. */
|
|
19
|
-
export const SENT_ITEM_TYPES = ["event", "client_report", "attachment"];
|
|
21
|
+
export const SENT_ITEM_TYPES = ["event", "client_report", "attachment", "check_in"];
|
|
22
|
+
/** Of the 30 queued sends, check-ins take at most this many (spec 06 §4): neither storm starves the other. */
|
|
23
|
+
export const MAX_QUEUED_CHECK_INS = 10;
|
|
20
24
|
const RETRY_STATUS = new Set([500, 502, 503, 504]);
|
|
21
25
|
const STOP_STATUS = new Set([401, 403]);
|
|
22
26
|
// Browsers cap keepalive bodies at 64 KiB in flight (fetch spec); stay below it.
|
|
23
27
|
const KEEPALIVE_MAX_BYTES = 60000;
|
|
24
28
|
const KEEPALIVE_MAX_COUNT = 15;
|
|
29
|
+
/** Integration health spec 02 §2.1: what one HTTP answer means for the app. */
|
|
30
|
+
export function diagnosticForStatus(status) {
|
|
31
|
+
if (status >= 200 && status < 300)
|
|
32
|
+
return null;
|
|
33
|
+
if (status === 401 || status === 403)
|
|
34
|
+
return "BLV_SEND_UNAUTHORIZED";
|
|
35
|
+
if (status === 429)
|
|
36
|
+
return "BLV_SEND_RATE_LIMITED";
|
|
37
|
+
if (status >= 500)
|
|
38
|
+
return "BLV_SEND_SERVER";
|
|
39
|
+
return "BLV_SEND_REJECTED";
|
|
40
|
+
}
|
|
41
|
+
/** Verification events in an envelope (id + check), read from tags and context. */
|
|
42
|
+
function verificationsIn(envelope) {
|
|
43
|
+
const out = [];
|
|
44
|
+
forEachEnvelopeItem(envelope, (item, type) => {
|
|
45
|
+
if (type !== "event")
|
|
46
|
+
return;
|
|
47
|
+
const payload = item[1];
|
|
48
|
+
const v = payload && typeof payload === "object" ? verificationOfEvent(payload) : null;
|
|
49
|
+
if (v)
|
|
50
|
+
out.push(v);
|
|
51
|
+
});
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
25
54
|
function utf8Length(s) {
|
|
26
55
|
let n = 0;
|
|
27
56
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -152,6 +181,14 @@ export async function sendWithRetry(fetchImpl, url, init, state, sleep = default
|
|
|
152
181
|
throw new Error("aborted");
|
|
153
182
|
}
|
|
154
183
|
}
|
|
184
|
+
function isCheckInEnvelope(envelope) {
|
|
185
|
+
let found = false;
|
|
186
|
+
forEachEnvelopeItem(envelope, (_item, type) => {
|
|
187
|
+
if (type === "check_in")
|
|
188
|
+
found = true;
|
|
189
|
+
});
|
|
190
|
+
return found;
|
|
191
|
+
}
|
|
155
192
|
/** Keeps only item types the node stores; reports the rest as dropped. */
|
|
156
193
|
export function filterEnvelope(envelope, policy, record) {
|
|
157
194
|
const kept = [];
|
|
@@ -185,6 +222,8 @@ export function makeBelvadorTransport(policy) {
|
|
|
185
222
|
queuedBytes: 0,
|
|
186
223
|
keepaliveBytes: 0,
|
|
187
224
|
keepaliveCount: 0,
|
|
225
|
+
rateLimitedUntil: 0,
|
|
226
|
+
checkIns: 0,
|
|
188
227
|
};
|
|
189
228
|
const fetchImpl = policy.fetch ?? globalThis.fetch.bind(globalThis);
|
|
190
229
|
const maxEnvelope = policy.attachments
|
|
@@ -209,11 +248,29 @@ export function makeBelvadorTransport(policy) {
|
|
|
209
248
|
...(options.headers ? { headers: options.headers } : {}),
|
|
210
249
|
...(policy.browser ? { referrerPolicy: "strict-origin", keepalive } : {}),
|
|
211
250
|
};
|
|
212
|
-
|
|
251
|
+
let res;
|
|
252
|
+
try {
|
|
253
|
+
res = await suppressTracing(() => sendWithRetry(fetchImpl, options.url, init, state, policy.sleep));
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
noteSend("network");
|
|
257
|
+
reportDiagnostic("BLV_SEND_NETWORK");
|
|
258
|
+
throw e;
|
|
259
|
+
}
|
|
213
260
|
const headers = {
|
|
214
261
|
"x-sentry-rate-limits": res.headers.get("X-Sentry-Rate-Limits"),
|
|
215
262
|
"retry-after": res.headers.get("Retry-After"),
|
|
216
263
|
};
|
|
264
|
+
noteSend(res.status);
|
|
265
|
+
const code = diagnosticForStatus(res.status);
|
|
266
|
+
if (code === "BLV_SEND_RATE_LIMITED") {
|
|
267
|
+
const after = retryAfterMs(res);
|
|
268
|
+
const seconds = Math.round((after ?? 60000) / 1000);
|
|
269
|
+
state.rateLimitedUntil = Date.now() + seconds * 1000;
|
|
270
|
+
reportDiagnostic(code, { status: res.status, retryAfterS: seconds });
|
|
271
|
+
}
|
|
272
|
+
else if (code)
|
|
273
|
+
reportDiagnostic(code, { status: res.status });
|
|
217
274
|
await discard(res);
|
|
218
275
|
return { statusCode: res.status, headers };
|
|
219
276
|
}
|
|
@@ -228,29 +285,75 @@ export function makeBelvadorTransport(policy) {
|
|
|
228
285
|
return {
|
|
229
286
|
send(envelope) {
|
|
230
287
|
try {
|
|
231
|
-
|
|
288
|
+
const verifications = verificationsIn(envelope);
|
|
289
|
+
const fail = (code, status) => {
|
|
290
|
+
for (const v of verifications)
|
|
291
|
+
noteVerificationOutcome(v.id, v.check, {
|
|
292
|
+
kind: "failed",
|
|
293
|
+
code,
|
|
294
|
+
...(status !== undefined ? { status } : {}),
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
if (state.stopped) {
|
|
298
|
+
fail("BLV_SEND_UNAUTHORIZED");
|
|
232
299
|
return Promise.resolve({});
|
|
300
|
+
}
|
|
233
301
|
const kept = filterEnvelope(envelope, policy, options.recordDroppedEvent);
|
|
234
302
|
if (!kept)
|
|
235
303
|
return Promise.resolve({});
|
|
236
304
|
const size = byteLength(serializeEnvelope(kept));
|
|
237
305
|
if (size > maxEnvelope) {
|
|
238
306
|
recordAll(kept, "send_error", options.recordDroppedEvent);
|
|
307
|
+
reportDiagnostic("BLV_EVENT_TOO_LARGE");
|
|
308
|
+
fail("BLV_EVENT_TOO_LARGE");
|
|
309
|
+
return Promise.resolve({});
|
|
310
|
+
}
|
|
311
|
+
const checkIn = isCheckInEnvelope(kept);
|
|
312
|
+
if (checkIn && (state.checkIns ?? 0) >= MAX_QUEUED_CHECK_INS) {
|
|
313
|
+
recordAll(kept, "queue_overflow", options.recordDroppedEvent);
|
|
314
|
+
reportDiagnostic("BLV_CHECK_IN_DROPPED");
|
|
239
315
|
return Promise.resolve({});
|
|
240
316
|
}
|
|
241
317
|
if (state.queuedBytes + size > maxQueued) {
|
|
242
318
|
recordAll(kept, "queue_overflow", options.recordDroppedEvent);
|
|
319
|
+
reportDiagnostic(checkIn ? "BLV_CHECK_IN_DROPPED" : "BLV_QUEUE_FULL");
|
|
320
|
+
fail("BLV_QUEUE_FULL");
|
|
243
321
|
return Promise.resolve({});
|
|
244
322
|
}
|
|
245
323
|
state.queuedBytes += size;
|
|
324
|
+
if (checkIn)
|
|
325
|
+
state.checkIns = (state.checkIns ?? 0) + 1;
|
|
246
326
|
const release = () => {
|
|
247
327
|
state.queuedBytes -= size;
|
|
328
|
+
if (checkIn)
|
|
329
|
+
state.checkIns = Math.max(0, (state.checkIns ?? 1) - 1);
|
|
330
|
+
};
|
|
331
|
+
const settle = (r) => {
|
|
332
|
+
if (verifications.length === 0)
|
|
333
|
+
return;
|
|
334
|
+
const status = r.statusCode;
|
|
335
|
+
if (typeof status !== "number") {
|
|
336
|
+
// Dropped by the engine's rate limiter or lost on the network.
|
|
337
|
+
fail(Date.now() < (state.rateLimitedUntil ?? 0) ? "BLV_SEND_RATE_LIMITED" : "BLV_SEND_NETWORK");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const code = diagnosticForStatus(status);
|
|
341
|
+
if (code)
|
|
342
|
+
fail(code, status);
|
|
343
|
+
else
|
|
344
|
+
for (const v of verifications)
|
|
345
|
+
noteVerificationOutcome(v.id, v.check, { kind: "sent", status });
|
|
248
346
|
};
|
|
249
347
|
return Promise.resolve(inner.send(kept)).then((r) => {
|
|
250
348
|
release();
|
|
349
|
+
settle(r);
|
|
350
|
+
// Rate limited (category `monitor`) or lost: the job is fine, the check-in is not.
|
|
351
|
+
if (checkIn && (typeof r.statusCode !== "number" || r.statusCode >= 400))
|
|
352
|
+
reportDiagnostic("BLV_CHECK_IN_DROPPED");
|
|
251
353
|
return r;
|
|
252
354
|
}, () => {
|
|
253
355
|
release();
|
|
356
|
+
settle({});
|
|
254
357
|
return {};
|
|
255
358
|
});
|
|
256
359
|
}
|
|
@@ -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>;
|