@belvador/core 0.2.0 → 0.4.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 CHANGED
@@ -8,3 +8,5 @@ export declare function isAbortError(error: unknown): boolean;
8
8
  export declare function noteClientAbort(error: unknown, signal: AbortSignal | null | undefined): void;
9
9
  /** True for an AbortError or an error thrown while its request's client had gone. */
10
10
  export declare function isClientAbort(error: unknown): boolean;
11
+ /** A DOMException (AbortError/InvalidStateError) with a standard View Transition message. */
12
+ export declare function isViewTransitionSkip(error: unknown): boolean;
package/dist/aborts.js CHANGED
@@ -39,3 +39,36 @@ export function isClientAbort(error) {
39
39
  return true;
40
40
  return error !== null && typeof error === "object" && clientAborted.has(error);
41
41
  }
42
+ /**
43
+ * Messages browsers give a View Transition that was skipped or aborted on purpose
44
+ * (observability-1.0 spec 03 §8): the page was hidden, another transition started, the
45
+ * viewport changed, or `skipTransition()` was called. Exact texts of Chromium, WebKit and
46
+ * Gecko, matched whole — never a broad pattern.
47
+ */
48
+ const VIEW_TRANSITION_MESSAGES = [
49
+ /^Transition was skipped$/,
50
+ /^Transition was aborted because of invalid state$/,
51
+ /^Skipped ViewTransition due to (?:document being hidden|another transition starting|hidden document|viewport size change)\.?$/,
52
+ /^Skipping view transition because (?:document visibility state has become hidden|viewport size changed|the document is hidden|another transition started)\.?$/,
53
+ /^The (?:previous|old) transition was skipped(?: because a new transition started)?\.?$/,
54
+ /^View transition was skipped because document visibility state is hidden\.?$/,
55
+ ];
56
+ /** A DOMException (AbortError/InvalidStateError) with a standard View Transition message. */
57
+ export function isViewTransitionSkip(error) {
58
+ try {
59
+ if (error === null || typeof error !== "object")
60
+ return false;
61
+ const name = error.name;
62
+ const message = error.message;
63
+ if (name !== "AbortError" && name !== "InvalidStateError")
64
+ return false;
65
+ if (typeof message !== "string")
66
+ return false;
67
+ if (typeof DOMException !== "undefined" && !(error instanceof DOMException))
68
+ return false;
69
+ return VIEW_TRANSITION_MESSAGES.some((re) => re.test(message.trim()));
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
@@ -1,4 +1,4 @@
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"];
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", "BLV_MONITOR_INVALID_SLUG", "BLV_MONITOR_INVALID_REASON", "BLV_MONITOR_CONFIG_INVALID", "BLV_CHECK_IN_DROPPED", "BLV_TRACE_TARGET_INVALID", "BLV_OTEL_INVALID", "BLV_TRACE_OWNER_OTEL"];
2
2
  export type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[number];
3
3
  /** Public page explaining every code and the verification (one anchor per code). */
4
4
  export declare const VERIFY_DOCS_URL = "https://belvador.dev/en/docs/sdk/verify";
@@ -16,6 +16,13 @@ export const DIAGNOSTIC_CODES = [
16
16
  "BLV_EVENT_TOO_LARGE",
17
17
  "BLV_HOOK_MISSING",
18
18
  "BLV_DROPPED_BY_APP",
19
+ "BLV_MONITOR_INVALID_SLUG",
20
+ "BLV_MONITOR_INVALID_REASON",
21
+ "BLV_MONITOR_CONFIG_INVALID",
22
+ "BLV_CHECK_IN_DROPPED",
23
+ "BLV_TRACE_TARGET_INVALID",
24
+ "BLV_OTEL_INVALID",
25
+ "BLV_TRACE_OWNER_OTEL",
19
26
  ];
20
27
  /** Public page explaining every code and the verification (one anchor per code). */
21
28
  export const VERIFY_DOCS_URL = "https://belvador.dev/en/docs/sdk/verify";
@@ -35,6 +42,13 @@ const MESSAGES = {
35
42
  BLV_EVENT_TOO_LARGE: "an event or attachment is over the size limit — dropped",
36
43
  BLV_HOOK_MISSING: "this server code does not run inside Belvador's middleware or wrapper",
37
44
  BLV_DROPPED_BY_APP: "the event was dropped before sending (beforeSend, ignoreErrors or sampleRate)",
45
+ BLV_MONITOR_INVALID_SLUG: "a monitor slug is not valid (lowercase letters, digits, - and _, at most 64) — no check-in was sent",
46
+ BLV_MONITOR_INVALID_REASON: "a run reason is not a short code (lowercase letters, digits, . - _, at most 64) — it was left out",
47
+ BLV_MONITOR_CONFIG_INVALID: "a monitor schedule or setting is not valid — the check-in was sent without it",
48
+ BLV_CHECK_IN_DROPPED: "a check-in was dropped (too many waiting, rate limited or not sent)",
49
+ BLV_TRACE_TARGET_INVALID: "a tracePropagation target is not a string or RegExp — it was ignored",
50
+ BLV_OTEL_INVALID: "otel.api is not @opentelemetry/api — ignored",
51
+ BLV_TRACE_OWNER_OTEL: "otel is set, so tracePropagation is ignored",
38
52
  };
39
53
  const config = {
40
54
  onDiagnostic: undefined,
package/dist/index.d.ts CHANGED
@@ -1,8 +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
+ export { isAbortError, isClientAbort, isViewTransitionSkip, noteClientAbort } from "./aborts.js";
4
+ export { INTERVAL_UNITS, MONITOR_CONFIG_LIMITS, MONITOR_CONTEXT, MONITOR_SLUG_RE, RUN_CONTEXT, RUN_REASON_RE, buildCheckIn, captureCheckIn, heartbeat, monitorConfigOf, runIdOf, withMonitor, type CheckInInput, type IntervalUnit, type MonitorConfigInput, type MonitorSchedule, type RunHandle, type WithMonitorOptions, } from "./monitor.js";
5
+ export { OTEL_OPTION, otelApiOf, otelBridgeIntegration, otelTraceContext, type OtelApiLike, type OtelOptions, } from "./otel.js";
6
+ export { TRACE_OPTION, installTracePropagation, sentryTraceFromTraceparent, traceConfigOf, urlMatches, withIncomingTraceparent, type TraceConfig, type TracePropagationOptions, } from "./trace.js";
4
7
  export { FILTERED, SENSITIVE_KEY_PARTS, isSensitiveKey, scrubBreadcrumb, scrubEvent, scrubParams, scrubText, scrubValue, } from "./privacy.js";
5
- export { SEND_LIMITS, SENT_ITEM_TYPES, filterEnvelope, makeBelvadorTransport, sendWithRetry, type FetchLike, type SendPolicy, type SendState, } from "./transport.js";
8
+ export { MAX_QUEUED_CHECK_INS, SEND_LIMITS, SENT_ITEM_TYPES, filterEnvelope, makeBelvadorTransport, sendWithRetry, type FetchLike, type SendPolicy, type SendState, } from "./transport.js";
6
9
  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";
7
10
  export { addBreadcrumb, captureException, captureMessage, close, flush, getDiagnostics, isActive, lastEventId, setContext, setTag, setTags, setUser, withScope, type Scope, type SeverityLevel, } from "./facade.js";
8
11
  export { DIAGNOSTIC_CODES, DIAGNOSTIC_INTERVAL_MS, VERIFY_DOCS_URL, configureDiagnostics, diagnosticDocsUrl, diagnosticMessage, reportDiagnostic, resetDiagnostics, type Diagnostic, type DiagnosticCode, type DiagnosticsSnapshot, } from "./diagnostics.js";
package/dist/index.js CHANGED
@@ -3,9 +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
+ export { isAbortError, isClientAbort, isViewTransitionSkip, noteClientAbort } from "./aborts.js";
7
+ export { INTERVAL_UNITS, MONITOR_CONFIG_LIMITS, MONITOR_CONTEXT, MONITOR_SLUG_RE, RUN_CONTEXT, RUN_REASON_RE, buildCheckIn, captureCheckIn, heartbeat, monitorConfigOf, runIdOf, withMonitor, } from "./monitor.js";
8
+ export { OTEL_OPTION, otelApiOf, otelBridgeIntegration, otelTraceContext, } from "./otel.js";
9
+ export { TRACE_OPTION, installTracePropagation, sentryTraceFromTraceparent, traceConfigOf, urlMatches, withIncomingTraceparent, } from "./trace.js";
7
10
  export { FILTERED, SENSITIVE_KEY_PARTS, isSensitiveKey, scrubBreadcrumb, scrubEvent, scrubParams, scrubText, scrubValue, } from "./privacy.js";
8
- export { SEND_LIMITS, SENT_ITEM_TYPES, filterEnvelope, makeBelvadorTransport, sendWithRetry, } from "./transport.js";
11
+ export { MAX_QUEUED_CHECK_INS, SEND_LIMITS, SENT_ITEM_TYPES, filterEnvelope, makeBelvadorTransport, sendWithRetry, } from "./transport.js";
9
12
  export { BELVADOR_MARKER, DEFAULT_MAX_BREADCRUMBS, MAX_BREADCRUMBS, MAX_VALUE_LENGTH, buildEngineOptions, warnOnce, } from "./options.js";
10
13
  export { addBreadcrumb, captureException, captureMessage, close, flush, getDiagnostics, isActive, lastEventId, setContext, setTag, setTags, setUser, withScope, } from "./facade.js";
11
14
  export { DIAGNOSTIC_CODES, DIAGNOSTIC_INTERVAL_MS, VERIFY_DOCS_URL, configureDiagnostics, diagnosticDocsUrl, diagnosticMessage, reportDiagnostic, resetDiagnostics, } from "./diagnostics.js";
@@ -0,0 +1,89 @@
1
+ import type { Client, SerializedCheckIn } from "@sentry/core";
2
+ export declare const MONITOR_SLUG_RE: RegExp;
3
+ export declare const RUN_REASON_RE: RegExp;
4
+ export declare const RUN_CONTEXT = "belvador_run";
5
+ export declare const MONITOR_CONTEXT = "monitor";
6
+ export declare const MONITOR_CONFIG_LIMITS: {
7
+ readonly intervalValueMax: 1000;
8
+ readonly checkinMarginMax: 1440;
9
+ readonly maxRuntimeMax: 10080;
10
+ readonly thresholdMax: 720;
11
+ readonly environmentMax: 64;
12
+ readonly attemptMax: 1000;
13
+ };
14
+ export declare const INTERVAL_UNITS: readonly ["minute", "hour", "day", "week", "month", "year"];
15
+ export type IntervalUnit = (typeof INTERVAL_UNITS)[number];
16
+ export type MonitorSchedule = {
17
+ type: "crontab";
18
+ value: string;
19
+ } | {
20
+ type: "interval";
21
+ value: number;
22
+ unit: IntervalUnit;
23
+ };
24
+ /** Settings sent as `monitor_config` (creates or updates the monitor from code). */
25
+ export type MonitorConfigInput = {
26
+ schedule: MonitorSchedule;
27
+ /** IANA time zone of a crontab, default UTC. */
28
+ timezone?: string;
29
+ /** Minutes a check-in may be late before it counts as missed (default 5). */
30
+ checkinMargin?: number;
31
+ /** Minutes a run may take before it counts as timed out (default 30). */
32
+ maxRuntime?: number;
33
+ /** Failing runs in a row before an incident opens (default 1). */
34
+ failureIssueThreshold?: number;
35
+ /** Healthy runs in a row before it closes (default 1). */
36
+ recoveryThreshold?: number;
37
+ };
38
+ export type WithMonitorOptions = Partial<MonitorConfigInput> & {
39
+ environment?: string;
40
+ /** Your id of the logical run (e.g. a queue message id); retries with the same id form one run. */
41
+ runId?: string;
42
+ /** 1 for the first try. */
43
+ attempt?: number;
44
+ /** True (or a function of the error) when your queue will try again after this error. */
45
+ willRetry?: boolean | ((error: unknown) => boolean);
46
+ };
47
+ export type RunHandle = {
48
+ readonly checkInId: string;
49
+ readonly runId: string;
50
+ /** The run did nothing on purpose (healthy). `reason` is a short code. */
51
+ skip(reason: string): void;
52
+ /** The run ended but its effect is unknown (e.g. a provider timed out after a side effect). */
53
+ unknown(reason: string): void;
54
+ };
55
+ export type CheckInInput = {
56
+ monitorSlug: string;
57
+ status: "in_progress" | "ok" | "error";
58
+ /** The id of the start check-in this one finishes; omitted = a new attempt. */
59
+ checkInId?: string;
60
+ /** Seconds. */
61
+ duration?: number;
62
+ environment?: string;
63
+ runId?: string;
64
+ attempt?: number;
65
+ willRetry?: boolean;
66
+ outcome?: "skipped" | "unknown";
67
+ reason?: string;
68
+ };
69
+ /** A UUID as 32 lowercase hex; any other id maps to a stable UUID-shaped hash (grouping only). */
70
+ export declare function runIdOf(value: string): string;
71
+ /** Wire `monitor_config`, or null (with a diagnostic) when anything is invalid. */
72
+ export declare function monitorConfigOf(input: Partial<MonitorConfigInput> | undefined): Record<string, unknown> | null;
73
+ /** The check-in item exactly as sent (exported for the golden test). */
74
+ export declare function buildCheckIn(input: CheckInInput, client: Pick<Client, "getOptions">, config: Record<string, unknown> | null, trace: {
75
+ trace_id: string;
76
+ span_id: string;
77
+ } | null): SerializedCheckIn & Record<string, unknown>;
78
+ /** Sends one check-in; returns its id ("" when the SDK is off or the slug is invalid). Never throws. */
79
+ export declare function captureCheckIn(checkIn: CheckInInput, monitorConfig?: MonitorConfigInput): string;
80
+ /** A healthy check-in for an interval monitor ("still alive"). */
81
+ export declare function heartbeat(slug: string, options?: {
82
+ environment?: string;
83
+ }): void;
84
+ /**
85
+ * Runs `fn` as one monitored attempt: `in_progress` first, then `ok` (or skipped/unknown
86
+ * via the handle) when it returns, or the error captured as a normal issue and `error`
87
+ * when it throws — the same value or error goes back to the caller, unchanged.
88
+ */
89
+ export declare function withMonitor<T>(slug: string, fn: (run: RunHandle) => T, options?: WithMonitorOptions): T;
@@ -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,5 +1,7 @@
1
1
  import type { Breadcrumb, BreadcrumbHint, ErrorEvent, EventHint } from "@sentry/core";
2
2
  import { type Diagnostic } from "./diagnostics.js";
3
+ import { type OtelOptions } from "./otel.js";
4
+ import { type TracePropagationOptions } from "./trace.js";
3
5
  import { type SendPolicy } from "./transport.js";
4
6
  export type BelvadorEvent = ErrorEvent;
5
7
  export type BelvadorEventHint = EventHint;
@@ -27,6 +29,23 @@ export type BelvadorOptions = {
27
29
  * a cancellation and is always reported.
28
30
  */
29
31
  captureAborted?: boolean;
32
+ /**
33
+ * Browsers: also report a View Transition the browser skipped on purpose (page hidden,
34
+ * another transition started, `skipTransition()`). Default false — these are expected.
35
+ */
36
+ captureViewTransitionSkips?: boolean;
37
+ /**
38
+ * Link errors and job check-ins across services with one trace id. Off by default:
39
+ * requests whose URL matches `targets` get `traceparent` + `sentry-trace` headers
40
+ * (the receiving API must allow them in CORS). No spans or transactions are sent.
41
+ */
42
+ tracePropagation?: TracePropagationOptions;
43
+ /**
44
+ * The app already runs OpenTelemetry: pass `{ api }` from `@opentelemetry/api` and errors
45
+ * captured inside an active span carry that span's trace and span ids. OpenTelemetry then
46
+ * owns propagation, so `tracePropagation` is ignored. Belvador creates no spans.
47
+ */
48
+ otel?: OtelOptions;
30
49
  /** Send attachments added with scope.addAttachment (each ≤ 5 MB). Default false. */
31
50
  attachments?: boolean;
32
51
  /** Runs before Belvador's own scrubbing; return null to drop the event. */
@@ -73,6 +92,8 @@ export declare function buildEngineOptions(opts: BelvadorOptions, runtime: Runti
73
92
  defaultIntegrations: false;
74
93
  transport: (options: import("@sentry/core").BaseTransportOptions) => import("@sentry/core").Transport;
75
94
  _belvador: boolean;
95
+ _belvadorTrace: import("./trace.js").TraceConfig | null;
96
+ _belvadorOtel: import("./otel.js").OtelApiLike | null;
76
97
  _metadata: {
77
98
  sdk: {
78
99
  name: string;
package/dist/options.js CHANGED
@@ -1,8 +1,10 @@
1
- import { isClientAbort } from "./aborts.js";
1
+ import { isClientAbort, isViewTransitionSkip } from "./aborts.js";
2
2
  import { configureDiagnostics, reportDiagnostic } from "./diagnostics.js";
3
3
  import { checkDsn } from "./dsn.js";
4
4
  import { scrubBreadcrumb, scrubEvent } from "./privacy.js";
5
5
  import { VERIFICATION_CONTEXT, VERIFICATION_TAG, noteVerificationOutcome, verificationOf, } from "./verification.js";
6
+ import { OTEL_OPTION, otelApiOf } from "./otel.js";
7
+ import { TRACE_OPTION, traceConfigOf } from "./trace.js";
6
8
  import { makeBelvadorTransport } from "./transport.js";
7
9
  import { ENGINE_VERSION, SDK_VERSION } from "./version.js";
8
10
  /** Engine package behind each runtime (for `sdk.packages`). */
@@ -33,13 +35,18 @@ export function warnOnce(message) {
33
35
  function clamp(n, min, max, fallback) {
34
36
  return typeof n === "number" && Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
35
37
  }
36
- function chainBeforeSend(user, captureAborted) {
38
+ function chainBeforeSend(user, captureAborted, captureViewTransitionSkips) {
37
39
  return (event, hint) => {
38
40
  // Integration health: a verification error carries its own tag and context; they are
39
41
  // set before the app's beforeSend and restored after scrubbing (ids are not user data).
40
42
  const v = verificationOf(hint?.originalException);
43
+ const original = hint?.originalException;
44
+ // A skipped View Transition is the standard working as designed (spec 03 §8).
45
+ if (!v && !captureViewTransitionSkips && isViewTransitionSkip(original))
46
+ return null;
41
47
  // Cancelled by the client: expected, dropped before the app's hook (not a diagnostic).
42
- if (!v && !captureAborted && isClientAbort(hint?.originalException))
48
+ // An opted-in View Transition skip is reported even though it is an AbortError.
49
+ if (!v && !captureAborted && isClientAbort(original) && !(captureViewTransitionSkips && isViewTransitionSkip(original)))
43
50
  return null;
44
51
  const mark = (e) => {
45
52
  if (!v)
@@ -110,6 +117,10 @@ export function buildEngineOptions(opts, runtime, send) {
110
117
  return null;
111
118
  }
112
119
  const attachments = opts.attachments === true;
120
+ const otel = otelApiOf(opts.otel);
121
+ // One owner of propagation: with OpenTelemetry present, Belvador adds no headers of its own.
122
+ if (otel && opts.tracePropagation)
123
+ reportDiagnostic("BLV_TRACE_OWNER_OTEL");
113
124
  return {
114
125
  dsn: opts.dsn,
115
126
  release: typeof opts.release === "string" && opts.release ? opts.release : undefined,
@@ -127,12 +138,16 @@ export function buildEngineOptions(opts, runtime, send) {
127
138
  // Never read SENTRY_TUNNEL / spotlight from the environment; events go only to the DSN.
128
139
  tunnel: "",
129
140
  spotlight: false,
130
- beforeSend: chainBeforeSend(opts.beforeSend, opts.captureAborted === true),
141
+ beforeSend: chainBeforeSend(opts.beforeSend, opts.captureAborted === true, opts.captureViewTransitionSkips === true),
131
142
  beforeBreadcrumb: chainBeforeBreadcrumb(opts.beforeBreadcrumb),
132
143
  beforeSendTransaction: () => null,
133
144
  defaultIntegrations: false,
134
145
  transport: makeBelvadorTransport({ ...send, attachments, debug: opts.debug === true }),
135
146
  [BELVADOR_MARKER]: true,
147
+ // Checked trace propagation of this client (null = off); the engine itself never adds headers.
148
+ [TRACE_OPTION]: otel ? null : traceConfigOf(opts.tracePropagation),
149
+ // The checked `@opentelemetry/api` of this client (null = no bridge); runtimes add the integration.
150
+ [OTEL_OPTION]: otel,
136
151
  _metadata: {
137
152
  sdk: {
138
153
  name: sdkName,
package/dist/otel.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { Integration } from "@sentry/core";
2
+ /** The part of `@opentelemetry/api` the bridge reads (structural, any 1.x version fits). */
3
+ export type OtelApiLike = {
4
+ trace: {
5
+ getActiveSpan(): {
6
+ spanContext(): {
7
+ traceId: string;
8
+ spanId: string;
9
+ };
10
+ } | undefined;
11
+ };
12
+ };
13
+ export type OtelOptions = {
14
+ /** `import * as api from "@opentelemetry/api"` — the same copy your OpenTelemetry SDK uses. */
15
+ api: OtelApiLike;
16
+ };
17
+ /** Engine-options key carrying the checked API of one client (null = bridge off). */
18
+ export declare const OTEL_OPTION = "_belvadorOtel";
19
+ /** The API when it looks like `@opentelemetry/api`, else null (a wrong value is reported). */
20
+ export declare function otelApiOf(opts: OtelOptions | undefined): OtelApiLike | null;
21
+ /** Ids of the active OpenTelemetry span, or null (none, invalid, all-zero, or the API threw). */
22
+ export declare function otelTraceContext(api: OtelApiLike): {
23
+ trace_id: string;
24
+ span_id: string;
25
+ } | null;
26
+ /**
27
+ * Stamps `contexts.trace` from the active span when the error is captured. `preprocessEvent`
28
+ * runs synchronously inside the capture call, while the app's span is still current; the
29
+ * engine keeps an event's own trace context over the scope's.
30
+ */
31
+ export declare function otelBridgeIntegration(api: OtelApiLike): Integration;
package/dist/otel.js ADDED
@@ -0,0 +1,44 @@
1
+ import { reportDiagnostic } from "./diagnostics.js";
2
+ /** Engine-options key carrying the checked API of one client (null = bridge off). */
3
+ export const OTEL_OPTION = "_belvadorOtel";
4
+ /** The API when it looks like `@opentelemetry/api`, else null (a wrong value is reported). */
5
+ export function otelApiOf(opts) {
6
+ if (!opts)
7
+ return null;
8
+ const api = opts.api;
9
+ if (typeof api?.trace?.getActiveSpan === "function")
10
+ return api;
11
+ reportDiagnostic("BLV_OTEL_INVALID");
12
+ return null;
13
+ }
14
+ // 32 + 16 lowercase hex, neither all zeros (W3C Trace Context invalid ids).
15
+ const IDS = /^(?!0{32})[0-9a-f]{32}-(?!0{16})[0-9a-f]{16}$/;
16
+ /** Ids of the active OpenTelemetry span, or null (none, invalid, all-zero, or the API threw). */
17
+ export function otelTraceContext(api) {
18
+ try {
19
+ const c = api.trace.getActiveSpan()?.spanContext();
20
+ const ids = c ? `${c.traceId}-${c.spanId}`.toLowerCase() : "";
21
+ return IDS.test(ids) ? { trace_id: ids.slice(0, 32), span_id: ids.slice(33) } : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /**
28
+ * Stamps `contexts.trace` from the active span when the error is captured. `preprocessEvent`
29
+ * runs synchronously inside the capture call, while the app's span is still current; the
30
+ * engine keeps an event's own trace context over the scope's.
31
+ */
32
+ export function otelBridgeIntegration(api) {
33
+ return {
34
+ name: "BelvadorOtelBridge",
35
+ preprocessEvent(event) {
36
+ const ids = !event.type && otelTraceContext(api); // errors only
37
+ if (!ids)
38
+ return;
39
+ const trace = { ...event.contexts?.trace, ...ids };
40
+ delete trace.parent_span_id; // the scope's parent is not this span's parent
41
+ event.contexts = { ...event.contexts, trace };
42
+ },
43
+ };
44
+ }
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
- /** "name": "value" inside JSON-looking text. */
90
- const JSON_PAIR = /"([^"\\]{1,128})"(\s*:\s*)"(?:[^"\\]|\\.)*"/g;
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
- .replace(PARAM, (match, name) => isSensitiveKey(decodeName(name)) ? `${name}=${FILTERED}` : match);
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(JSON_PAIR, (match, name, sep) => isSensitiveKey(name) ? `"${name}"${sep}"${FILTERED}"` : match)
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)
@@ -348,6 +368,25 @@ export function scrubBreadcrumb(crumb) {
348
368
  return null;
349
369
  }
350
370
  }
371
+ // W3C trace/span ids are random hex, not personal data; an all-digit span id can look like a
372
+ // card number, so exact-format ids under `contexts.trace` survive (parity with the node).
373
+ const HEX = /^[0-9a-f]+$/;
374
+ function scrubContexts(contexts) {
375
+ const out = scrubValue(contexts, 0);
376
+ try {
377
+ const t = contexts.trace;
378
+ if (t && out?.trace && typeof out.trace === "object")
379
+ for (const k of ["trace_id", "span_id", "parent_span_id"]) {
380
+ const id = t[k];
381
+ if (typeof id === "string" && id.length === (k === "trace_id" ? 32 : 16) && HEX.test(id))
382
+ out.trace[k] = id;
383
+ }
384
+ }
385
+ catch {
386
+ /* a throwing getter: keep the scrubbed copy */
387
+ }
388
+ return out;
389
+ }
351
390
  /**
352
391
  * Whole-event scrub, run after the app's own `beforeSend` and before any byte
353
392
  * leaves the app. Returns a new event, or null (drop) when scrubbing fails —
@@ -370,6 +409,8 @@ export function scrubEvent(event) {
370
409
  next = scrubUser(v);
371
410
  else if (key === "exception")
372
411
  next = scrubException(v);
412
+ else if (key === "contexts")
413
+ next = scrubContexts(v);
373
414
  else if (key === "breadcrumbs")
374
415
  next = Array.isArray(v)
375
416
  ? v.flatMap((b) => {
@@ -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;
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
+ }
@@ -14,6 +14,8 @@ export declare const SEND_LIMITS: {
14
14
  };
15
15
  /** Item types the node stores (audit §2); everything else never leaves the app. */
16
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;
17
19
  export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
18
20
  export type SendPolicy = {
19
21
  attachments: boolean;
@@ -31,6 +33,8 @@ export type SendState = {
31
33
  keepaliveCount: number;
32
34
  /** Epoch ms until which the edge asked us to wait (429), for diagnostics. */
33
35
  rateLimitedUntil?: number;
36
+ /** Check-in envelopes waiting or in flight (≤ MAX_QUEUED_CHECK_INS). */
37
+ checkIns?: number;
34
38
  };
35
39
  /** Integration health spec 02 §2.1: what one HTTP answer means for the app. */
36
40
  export declare function diagnosticForStatus(status: number): DiagnosticCode | null;
package/dist/transport.js CHANGED
@@ -18,7 +18,9 @@ export const SEND_LIMITS = {
18
18
  maxRetryAfterMs: 60000,
19
19
  };
20
20
  /** Item types the node stores (audit §2); everything else never leaves the app. */
21
- 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;
22
24
  const RETRY_STATUS = new Set([500, 502, 503, 504]);
23
25
  const STOP_STATUS = new Set([401, 403]);
24
26
  // Browsers cap keepalive bodies at 64 KiB in flight (fetch spec); stay below it.
@@ -179,6 +181,14 @@ export async function sendWithRetry(fetchImpl, url, init, state, sleep = default
179
181
  throw new Error("aborted");
180
182
  }
181
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
+ }
182
192
  /** Keeps only item types the node stores; reports the rest as dropped. */
183
193
  export function filterEnvelope(envelope, policy, record) {
184
194
  const kept = [];
@@ -213,6 +223,7 @@ export function makeBelvadorTransport(policy) {
213
223
  keepaliveBytes: 0,
214
224
  keepaliveCount: 0,
215
225
  rateLimitedUntil: 0,
226
+ checkIns: 0,
216
227
  };
217
228
  const fetchImpl = policy.fetch ?? globalThis.fetch.bind(globalThis);
218
229
  const maxEnvelope = policy.attachments
@@ -297,15 +308,25 @@ export function makeBelvadorTransport(policy) {
297
308
  fail("BLV_EVENT_TOO_LARGE");
298
309
  return Promise.resolve({});
299
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");
315
+ return Promise.resolve({});
316
+ }
300
317
  if (state.queuedBytes + size > maxQueued) {
301
318
  recordAll(kept, "queue_overflow", options.recordDroppedEvent);
302
- reportDiagnostic("BLV_QUEUE_FULL");
319
+ reportDiagnostic(checkIn ? "BLV_CHECK_IN_DROPPED" : "BLV_QUEUE_FULL");
303
320
  fail("BLV_QUEUE_FULL");
304
321
  return Promise.resolve({});
305
322
  }
306
323
  state.queuedBytes += size;
324
+ if (checkIn)
325
+ state.checkIns = (state.checkIns ?? 0) + 1;
307
326
  const release = () => {
308
327
  state.queuedBytes -= size;
328
+ if (checkIn)
329
+ state.checkIns = Math.max(0, (state.checkIns ?? 1) - 1);
309
330
  };
310
331
  const settle = (r) => {
311
332
  if (verifications.length === 0)
@@ -326,6 +347,9 @@ export function makeBelvadorTransport(policy) {
326
347
  return Promise.resolve(inner.send(kept)).then((r) => {
327
348
  release();
328
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");
329
353
  return r;
330
354
  }, () => {
331
355
  release();
package/dist/version.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export declare const SDK_VERSION = "0.2.0";
1
+ export declare const SDK_VERSION = "0.4.0";
2
2
  /** The audited engine version every package pins exactly (research/05-decisions.md D3). */
3
3
  export declare const ENGINE_VERSION = "10.76.2";
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.2.0";
4
+ export const SDK_VERSION = "0.4.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.2.0",
3
+ "version": "0.4.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)",