@blaxel/core 0.3.7-preview.228 → 0.3.7-preview.230

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.
@@ -49,8 +49,8 @@ let autoloaded = false;
49
49
  * populates `BL_ENV` if the user has a dev workspace configured).
50
50
  * - Re-apply the control-plane and sandbox clients' `baseUrl` so they
51
51
  * target the now-env-aware endpoint.
52
- * - Initialize the lightweight Sentry client (registers
53
- * `uncaughtExceptionMonitor` and patches `console.error` in Node).
52
+ * - Initialize the lightweight Sentry client (registers a composable
53
+ * `uncaughtExceptionMonitor` handler in Node).
54
54
  * - Pre-warm the edge H2 connection pool for `settings.region`.
55
55
  *
56
56
  * These used to run at module load, but that meant `import "@blaxel/core"`
@@ -4,39 +4,165 @@ exports.initSentry = initSentry;
4
4
  exports.flushSentry = flushSentry;
5
5
  exports.isSentryInitialized = isSentryInitialized;
6
6
  const settings_js_1 = require("./settings.js");
7
- // Lightweight Sentry client using fetch - only captures SDK errors
7
+ const PACKAGE_LAYOUT_MARKERS = [
8
+ "/src/common/sentry.ts",
9
+ "/dist/cjs/common/sentry.js",
10
+ "/dist/esm/common/sentry.js",
11
+ "/dist/cjs-browser/common/sentry.js",
12
+ "/dist/esm-browser/common/sentry.js",
13
+ ];
14
+ const OWNED_PACKAGE_DIRECTORIES = [
15
+ "/src/",
16
+ "/dist/cjs/",
17
+ "/dist/esm/",
18
+ "/dist/cjs-browser/",
19
+ "/dist/esm-browser/",
20
+ ];
21
+ const SAFE_ERROR_NAMES = new Set([
22
+ "Error",
23
+ "EvalError",
24
+ "RangeError",
25
+ "ReferenceError",
26
+ "SyntaxError",
27
+ "TypeError",
28
+ "URIError",
29
+ "AggregateError",
30
+ ]);
31
+ const MAX_IN_FLIGHT_EVENTS = 20;
32
+ const DELIVERY_TIMEOUT_MS = 500;
33
+ const SAFE_FILENAME_SEGMENT = /^[A-Za-z0-9._-]+$/;
8
34
  let sentryInitialized = false;
9
- const capturedExceptions = new Set();
10
35
  let handlersRegistered = false;
11
- // Parsed DSN components
12
36
  let sentryConfig = null;
13
- // SDK path patterns to identify errors originating from our SDK
14
- const SDK_PATTERNS = [
15
- "@blaxel/",
16
- "blaxel-sdk",
17
- "/node_modules/@blaxel/",
18
- "/@blaxel/core/",
19
- "/@blaxel/telemetry/",
20
- ];
37
+ let flushPromise = null;
38
+ const capturedExceptions = new WeakSet();
39
+ const inFlightDeliveries = new Set();
40
+ /**
41
+ * Normalize stack filenames without resolving or exposing host filesystem data.
42
+ */
43
+ function normalizeFilename(filename) {
44
+ return filename.replace(/\\/g, "/").replace(/[?#].*$/, "");
45
+ }
21
46
  /**
22
- * Check if an error originated from the SDK based on its stack trace.
23
- * Returns true if the stack trace contains any SDK-related paths.
47
+ * Parse V8/Node and browser stack-frame formats in call order (newest first).
24
48
  */
25
- function isFromSDK(error) {
26
- const stack = error.stack || "";
27
- return SDK_PATTERNS.some((pattern) => stack.includes(pattern));
49
+ function parseStackTrace(stack) {
50
+ const frames = [];
51
+ // V8 starts with an error header (which does not match a frame), while
52
+ // Firefox-style stacks can start directly with the first frame.
53
+ for (const line of stack.split("\n")) {
54
+ const v8WithFunction = line.match(/^\s*at\s+(.+?)\s+\((.+):(\d+):(\d+)\)\s*$/);
55
+ const v8WithoutFunction = line.match(/^\s*at\s+(.+):(\d+):(\d+)\s*$/);
56
+ const browser = line.match(/^\s*(.*?)@(.+):(\d+):(\d+)\s*$/);
57
+ const match = v8WithFunction ?? v8WithoutFunction ?? browser;
58
+ if (!match)
59
+ continue;
60
+ if (match === v8WithFunction) {
61
+ frames.push({
62
+ function: match[1],
63
+ filename: normalizeFilename(match[2]),
64
+ lineno: Number.parseInt(match[3], 10),
65
+ colno: Number.parseInt(match[4], 10),
66
+ });
67
+ continue;
68
+ }
69
+ if (match === browser) {
70
+ frames.push({
71
+ function: match[1] || "<anonymous>",
72
+ filename: normalizeFilename(match[2]),
73
+ lineno: Number.parseInt(match[3], 10),
74
+ colno: Number.parseInt(match[4], 10),
75
+ });
76
+ continue;
77
+ }
78
+ frames.push({
79
+ function: "<anonymous>",
80
+ filename: normalizeFilename(match[1]),
81
+ lineno: Number.parseInt(match[2], 10),
82
+ colno: Number.parseInt(match[3], 10),
83
+ });
84
+ }
85
+ return frames;
28
86
  }
29
87
  /**
30
- * Parse a Sentry DSN into its components.
31
- * DSN format: https://{public_key}@{host}/{project_id}
88
+ * Discover this exact @blaxel/core package root from this module's own frame.
89
+ * This avoids trusting a generic "@blaxel" substring in an arbitrary stack.
90
+ */
91
+ function discoverPackageRoot(stack) {
92
+ for (const frame of parseStackTrace(stack)) {
93
+ for (const marker of PACKAGE_LAYOUT_MARKERS) {
94
+ if (frame.filename.endsWith(marker)) {
95
+ return frame.filename.slice(0, -marker.length);
96
+ }
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+ const sdkPackageRoot = discoverPackageRoot(new Error().stack ?? "");
102
+ function isRuntimeFrame(filename) {
103
+ return filename.startsWith("node:") || filename.startsWith("internal/");
104
+ }
105
+ function ownedRelativeFilename(filename) {
106
+ if (!sdkPackageRoot)
107
+ return null;
108
+ for (const directory of OWNED_PACKAGE_DIRECTORIES) {
109
+ const prefix = `${sdkPackageRoot}${directory}`;
110
+ if (!filename.startsWith(prefix))
111
+ continue;
112
+ const relativeFilename = filename.slice(sdkPackageRoot.length).replace(/^\/+/, "");
113
+ const segments = relativeFilename.split("/");
114
+ if (segments.length > 0 &&
115
+ segments.every((segment) => segment !== "." && segment !== ".." && SAFE_FILENAME_SEGMENT.test(segment))) {
116
+ return relativeFilename;
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+ function isOwnedFilename(filename) {
122
+ return ownedRelativeFilename(filename) !== null;
123
+ }
124
+ /**
125
+ * Attribute an exception only when its first non-runtime frame is inside the
126
+ * exact installed @blaxel/core package. Application and dependency frames are
127
+ * never included in the event.
128
+ */
129
+ function getOwnedFrames(error) {
130
+ const frames = parseStackTrace(error.stack ?? "");
131
+ const firstRelevantFrame = frames.find((frame) => !isRuntimeFrame(frame.filename));
132
+ if (!firstRelevantFrame || !isOwnedFilename(firstRelevantFrame.filename)) {
133
+ return [];
134
+ }
135
+ return frames.filter((frame) => isOwnedFilename(frame.filename));
136
+ }
137
+ function sanitizeFunctionName(name) {
138
+ const normalized = name.trim();
139
+ if (!normalized || normalized.length > 120 || !/^[\w.$<> /-]+$/u.test(normalized)) {
140
+ return "<sdk>";
141
+ }
142
+ return normalized;
143
+ }
144
+ function sanitizeOwnedFrame(frame) {
145
+ const relativeFilename = ownedRelativeFilename(frame.filename) ?? "unknown";
146
+ return {
147
+ function: sanitizeFunctionName(frame.function),
148
+ filename: `@blaxel/core/${relativeFilename}`,
149
+ lineno: frame.lineno,
150
+ colno: frame.colno,
151
+ };
152
+ }
153
+ function sanitizeErrorName(name) {
154
+ return SAFE_ERROR_NAMES.has(name) ? name : "Error";
155
+ }
156
+ /**
157
+ * Parse a Sentry DSN into the public transport components used by envelopes.
32
158
  */
33
159
  function parseDsn(dsn) {
34
160
  try {
35
161
  const url = new URL(dsn);
36
162
  const publicKey = url.username;
37
163
  const host = url.host;
38
- const projectId = url.pathname.slice(1); // Remove leading slash
39
- if (!publicKey || !host || !projectId) {
164
+ const projectId = url.pathname.slice(1);
165
+ if (!publicKey || !host || !projectId || !["http:", "https:"].includes(url.protocol)) {
40
166
  return null;
41
167
  }
42
168
  return { publicKey, host, projectId };
@@ -45,21 +171,18 @@ function parseDsn(dsn) {
45
171
  return null;
46
172
  }
47
173
  }
48
- /**
49
- * Generate a UUID v4
50
- */
51
174
  function generateEventId() {
52
- return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, (c) => {
53
- const r = (Math.random() * 16) | 0;
54
- const v = c === "x" ? r : (r & 0x3) | 0x8;
55
- return v.toString(16);
175
+ return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, (character) => {
176
+ const random = (Math.random() * 16) | 0;
177
+ const value = character === "x" ? random : (random & 0x3) | 0x8;
178
+ return value.toString(16);
56
179
  });
57
180
  }
58
181
  /**
59
- * Convert an Error to a Sentry event payload.
182
+ * Build an allowlisted event. Raw messages, response bodies, workspace/resource
183
+ * context, and absolute host paths are deliberately excluded.
60
184
  */
61
- function errorToSentryEvent(error) {
62
- const frames = parseStackTrace(error.stack || "");
185
+ function errorToSentryEvent(error, ownedFrames) {
63
186
  return {
64
187
  event_id: generateEventId(),
65
188
  timestamp: Date.now() / 1000,
@@ -68,64 +191,43 @@ function errorToSentryEvent(error) {
68
191
  environment: settings_js_1.settings.env,
69
192
  release: `sdk-typescript@${settings_js_1.settings.version}`,
70
193
  tags: {
71
- "blaxel.workspace": settings_js_1.settings.workspace,
72
194
  "blaxel.version": settings_js_1.settings.version,
73
195
  "blaxel.commit": settings_js_1.settings.commit,
196
+ "blaxel.error_source": "unhandled-sdk-exception",
74
197
  },
75
198
  exception: {
76
199
  values: [
77
200
  {
78
- type: error.name,
79
- value: error.message,
201
+ type: sanitizeErrorName(error.name),
202
+ value: "Unhandled SDK exception",
80
203
  stacktrace: {
81
- frames,
204
+ // Sentry expects oldest-to-newest frame order.
205
+ frames: ownedFrames.map(sanitizeOwnedFrame).reverse(),
82
206
  },
83
207
  },
84
208
  ],
85
209
  },
86
210
  };
87
211
  }
88
- /**
89
- * Parse a stack trace string into Sentry-compatible frames.
90
- */
91
- function parseStackTrace(stack) {
92
- const lines = stack.split("\n").slice(1); // Skip first line (error message)
93
- const frames = [];
94
- for (const line of lines) {
95
- // Match patterns like "at functionName (filename:line:col)" or "at filename:line:col"
96
- const match = line.match(/at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?/);
97
- if (match) {
98
- frames.unshift({
99
- function: match[1] || "<anonymous>",
100
- filename: match[2],
101
- lineno: parseInt(match[3], 10),
102
- colno: parseInt(match[4], 10),
103
- });
104
- }
105
- }
106
- return frames;
107
- }
108
- /**
109
- * Send an event to Sentry using fetch.
110
- */
111
212
  async function sendToSentry(event) {
112
213
  if (!sentryConfig)
113
214
  return;
114
215
  const { publicKey, host, projectId } = sentryConfig;
115
216
  const envelopeUrl = `https://${host}/api/${projectId}/envelope/`;
116
- // Create envelope header
117
217
  const envelopeHeader = JSON.stringify({
118
218
  event_id: event.event_id,
119
219
  sent_at: new Date().toISOString(),
120
220
  dsn: `https://${publicKey}@${host}/${projectId}`,
121
221
  });
122
- // Create item header
123
222
  const itemHeader = JSON.stringify({
124
223
  type: "event",
125
224
  content_type: "application/json",
126
225
  });
127
- // Create envelope body
128
226
  const envelope = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(event)}`;
227
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
228
+ const abortTimer = controller
229
+ ? setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS)
230
+ : null;
129
231
  try {
130
232
  await fetch(envelopeUrl, {
131
233
  method: "POST",
@@ -134,170 +236,114 @@ async function sendToSentry(event) {
134
236
  "X-Sentry-Auth": `Sentry sentry_version=7, sentry_client=blaxel-sdk/${settings_js_1.settings.version}, sentry_key=${publicKey}`,
135
237
  },
136
238
  body: envelope,
239
+ keepalive: true,
240
+ signal: controller?.signal,
137
241
  });
138
242
  }
139
243
  catch {
140
- // Silently fail - error reporting should never break the SDK
244
+ // Error reporting must never affect the host application.
141
245
  }
142
- }
143
- // Queue for pending events
144
- const pendingEvents = [];
145
- let flushPromise = null;
146
- /**
147
- * Register browser/edge environment error handlers.
148
- * Separated to isolate dynamic globalThis access.
149
- */
150
- function registerBrowserHandlers() {
151
- const g = globalThis;
152
- if (g && typeof g.addEventListener === "function") {
153
- g.addEventListener("error", (event) => {
154
- const e = event;
155
- if (e.error instanceof Error && isFromSDK(e.error)) {
156
- captureException(e.error);
157
- }
158
- });
159
- g.addEventListener("unhandledrejection", (event) => {
160
- const e = event;
161
- const error = e.reason instanceof Error ? e.reason : new Error(String(e.reason));
162
- if (isFromSDK(error)) {
163
- captureException(error);
164
- }
165
- });
246
+ finally {
247
+ if (abortTimer)
248
+ clearTimeout(abortTimer);
166
249
  }
167
250
  }
168
- /**
169
- * Initialize the lightweight Sentry client for SDK error tracking.
170
- */
171
- function initSentry() {
251
+ function scheduleDelivery(event) {
252
+ if (inFlightDeliveries.size >= MAX_IN_FLIGHT_EVENTS)
253
+ return;
254
+ // Contain rejections from setup that occurs before sendToSentry's internal
255
+ // fetch guard (for example, a host-provided AbortController implementation).
256
+ const delivery = sendToSentry(event).catch(() => undefined);
257
+ inFlightDeliveries.add(delivery);
258
+ void delivery.then(() => inFlightDeliveries.delete(delivery));
259
+ }
260
+ function captureException(error) {
261
+ if (!sentryInitialized || !sentryConfig || capturedExceptions.has(error)) {
262
+ return;
263
+ }
172
264
  try {
173
- // Check if tracking is disabled
174
- if (!settings_js_1.settings.tracking) {
175
- return;
176
- }
177
- const dsn = settings_js_1.settings.sentryDsn;
178
- if (!dsn) {
179
- return;
180
- }
181
- // Parse DSN
182
- sentryConfig = parseDsn(dsn);
183
- if (!sentryConfig) {
265
+ const ownedFrames = getOwnedFrames(error);
266
+ if (ownedFrames.length === 0)
184
267
  return;
185
- }
186
- // Only allow dev/prod environments
187
- if (settings_js_1.settings.env !== "dev" && settings_js_1.settings.env !== "prod") {
188
- return;
189
- }
190
- sentryInitialized = true;
191
- // Register error handlers only once
192
- if (!handlersRegistered) {
193
- handlersRegistered = true;
194
- // Node.js specific handlers
195
- if (typeof process !== "undefined" && typeof process.on === "function") {
196
- // Monitor uncaught exceptions to capture SDK errors without
197
- // preventing Node.js default crash behavior (print + exit).
198
- const uncaughtExceptionMonitorHandler = (error) => {
199
- if (isFromSDK(error)) {
200
- captureException(error);
201
- }
202
- };
203
- process.on("uncaughtExceptionMonitor", uncaughtExceptionMonitorHandler);
204
- // Intercept console.error to capture SDK errors that are caught and logged
205
- const originalConsoleError = console.error;
206
- console.error = function (...args) {
207
- originalConsoleError.apply(console, args);
208
- for (const arg of args) {
209
- if (arg instanceof Error && isFromSDK(arg)) {
210
- captureException(arg);
211
- break;
212
- }
213
- }
214
- };
215
- }
216
- else {
217
- // Browser/Edge environment handlers
218
- registerBrowserHandlers();
219
- }
220
- }
268
+ capturedExceptions.add(error);
269
+ scheduleDelivery(errorToSentryEvent(error, ownedFrames));
221
270
  }
222
- catch (error) {
223
- // Silently fail - Sentry initialization should never break the SDK
224
- if (settings_js_1.settings.env !== "production") {
225
- console.error("[Blaxel SDK] Error initializing Sentry:", error);
226
- }
271
+ catch {
272
+ // Error reporting must never affect the host application.
227
273
  }
228
274
  }
275
+ function registerBrowserHandlers() {
276
+ const host = globalThis;
277
+ if (typeof host.addEventListener !== "function")
278
+ return;
279
+ host.addEventListener.call(host, "error", (event) => {
280
+ const error = event.error;
281
+ if (error instanceof Error)
282
+ captureException(error);
283
+ });
284
+ host.addEventListener.call(host, "unhandledrejection", (event) => {
285
+ const reason = event.reason;
286
+ // A primitive rejection carries no attributable stack, so do not guess.
287
+ if (reason instanceof Error)
288
+ captureException(reason);
289
+ });
290
+ }
229
291
  /**
230
- * Capture an exception to Sentry.
231
- * Only errors originating from SDK code will be captured.
232
- *
233
- * @param error - The error to capture
292
+ * Initialize opt-in SDK error tracking. Registration composes with host global
293
+ * handlers and never replaces console, process, or browser callbacks.
234
294
  */
235
- function captureException(error) {
236
- if (!sentryInitialized || !sentryConfig) {
237
- return;
238
- }
239
- // Double-check that error is from SDK (defense in depth)
240
- if (!isFromSDK(error)) {
241
- return;
242
- }
295
+ function initSentry() {
243
296
  try {
244
- // Create a unique identifier for this exception to avoid duplicates
245
- const errorKey = `${error.name}:${error.message}:${error.stack?.slice(0, 200)}`;
246
- if (capturedExceptions.has(errorKey)) {
297
+ if (!settings_js_1.settings.tracking || !settings_js_1.settings.sentryDsn || !sdkPackageRoot)
298
+ return;
299
+ const config = parseDsn(settings_js_1.settings.sentryDsn);
300
+ if (!config || (settings_js_1.settings.env !== "dev" && settings_js_1.settings.env !== "prod"))
301
+ return;
302
+ sentryConfig = config;
303
+ sentryInitialized = true;
304
+ if (handlersRegistered)
305
+ return;
306
+ handlersRegistered = true;
307
+ if (typeof process !== "undefined" &&
308
+ process.versions?.node &&
309
+ typeof process.on === "function") {
310
+ process.on("uncaughtExceptionMonitor", (error) => {
311
+ captureException(error);
312
+ });
247
313
  return;
248
314
  }
249
- capturedExceptions.add(errorKey);
250
- // Clean up old exception keys to prevent memory leak
251
- if (capturedExceptions.size > 1000) {
252
- capturedExceptions.clear();
253
- }
254
- // Convert error to Sentry event and queue it
255
- const event = errorToSentryEvent(error);
256
- pendingEvents.push(event);
257
- // Send immediately (fire and forget)
258
- sendToSentry(event).catch(() => {
259
- // Silently fail
260
- });
315
+ registerBrowserHandlers();
261
316
  }
262
317
  catch {
263
- // Silently fail - error capturing should never break the SDK
318
+ // Initialization is intentionally silent and cannot break the SDK.
264
319
  }
265
320
  }
266
321
  /**
267
- * Flush pending Sentry events.
268
- * This should be called before the process exits to ensure all events are sent.
269
- *
270
- * @param timeout - Maximum time in milliseconds to wait for flush (default: 2000)
322
+ * Await only deliveries already in flight, bounded by the supplied timeout.
323
+ * Events are sent exactly once; flush never re-enqueues them.
271
324
  */
272
- async function flushSentry(timeout = 2000) {
273
- if (!sentryInitialized || pendingEvents.length === 0) {
325
+ async function flushSentry(timeout = DELIVERY_TIMEOUT_MS) {
326
+ if (!sentryInitialized || inFlightDeliveries.size === 0)
274
327
  return;
275
- }
276
- // If already flushing, wait for it
277
328
  if (flushPromise) {
278
329
  await flushPromise;
279
330
  return;
280
331
  }
332
+ let timeoutHandle;
281
333
  try {
282
- // Send all pending events
283
- const eventsToSend = [...pendingEvents];
284
- pendingEvents.length = 0;
285
- flushPromise = Promise.race([
286
- Promise.all(eventsToSend.map((event) => sendToSentry(event))).then(() => { }),
287
- new Promise((resolve) => setTimeout(resolve, timeout)),
288
- ]);
334
+ const deliveries = Promise.allSettled([...inFlightDeliveries]).then(() => undefined);
335
+ const timeoutPromise = new Promise((resolve) => {
336
+ timeoutHandle = setTimeout(resolve, Math.max(0, timeout));
337
+ });
338
+ flushPromise = Promise.race([deliveries, timeoutPromise]);
289
339
  await flushPromise;
290
340
  }
291
- catch {
292
- // Silently fail
293
- }
294
341
  finally {
342
+ if (timeoutHandle)
343
+ clearTimeout(timeoutHandle);
295
344
  flushPromise = null;
296
345
  }
297
346
  }
298
- /**
299
- * Check if Sentry is initialized and available.
300
- */
301
347
  function isSentryInitialized() {
302
348
  return sentryInitialized;
303
349
  }