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