@blaxel/core 0.3.12-preview.254 → 0.3.12-preview.256

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.
@@ -0,0 +1,112 @@
1
+ import { logger } from "./logger.js";
2
+ import { reportH2TransportDegradation } from "./sentry.js";
3
+ const emptyReasons = () => ({
4
+ "no-session": 0,
5
+ "request-rejected": 0,
6
+ "session-unusable": 0,
7
+ "unsupported-body": 0,
8
+ });
9
+ const MAX_TRACKED_DOMAINS = 100;
10
+ const H2_DEBUG_STATS_SYMBOL = Symbol.for("blaxel.h2stats");
11
+ const h2DebugStatsEnabled = typeof process !== "undefined" && process.env?.BL_H2_DEBUG_STATS === "1";
12
+ function publishDebugSnapshot(snapshot) {
13
+ if (!h2DebugStatsEnabled)
14
+ return;
15
+ try {
16
+ globalThis[H2_DEBUG_STATS_SYMBOL] = snapshot;
17
+ }
18
+ catch {
19
+ // Debug diagnostics must never change transport behavior.
20
+ }
21
+ }
22
+ const emptyDomainStats = () => ({
23
+ establishFailures: 0,
24
+ fetchFallbacks: 0,
25
+ fallbacksByReason: emptyReasons(),
26
+ });
27
+ class H2TransportStatsStore {
28
+ totals = emptyDomainStats();
29
+ byDomain = new Map();
30
+ snapshot() {
31
+ return {
32
+ ...this.clone(this.totals),
33
+ byDomain: Object.fromEntries([...this.byDomain].map(([domain, stats]) => [domain, this.clone(stats)])),
34
+ };
35
+ }
36
+ reset() {
37
+ this.totals = emptyDomainStats();
38
+ this.byDomain.clear();
39
+ publishDebugSnapshot(this.snapshot());
40
+ }
41
+ /** @internal */
42
+ recordEstablishFailure(domain, error) {
43
+ this.totals.establishFailures++;
44
+ this.forDomain(domain).establishFailures++;
45
+ try {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ logger.debug(`H2 session establishment failed for ${domain}: ${message}`);
48
+ }
49
+ catch {
50
+ // Diagnostics must never change transport behavior.
51
+ }
52
+ reportH2TransportDegradation(domain, "establish-failure");
53
+ publishDebugSnapshot(this.snapshot());
54
+ }
55
+ /** @internal */
56
+ recordFallback(domain, reason) {
57
+ this.totals.fetchFallbacks++;
58
+ this.totals.fallbacksByReason[reason]++;
59
+ const stats = this.forDomain(domain);
60
+ stats.fetchFallbacks++;
61
+ stats.fallbacksByReason[reason]++;
62
+ try {
63
+ logger.debug(`H2 transport falling back to fetch for ${domain}: ${reason}`);
64
+ }
65
+ catch {
66
+ // Diagnostics must never change transport behavior.
67
+ }
68
+ reportH2TransportDegradation(domain, reason);
69
+ publishDebugSnapshot(this.snapshot());
70
+ }
71
+ forDomain(domain) {
72
+ let stats = this.byDomain.get(domain);
73
+ if (stats) {
74
+ this.byDomain.delete(domain);
75
+ }
76
+ else {
77
+ if (this.byDomain.size >= MAX_TRACKED_DOMAINS) {
78
+ const oldest = this.byDomain.keys().next().value;
79
+ if (oldest !== undefined)
80
+ this.byDomain.delete(oldest);
81
+ }
82
+ stats = emptyDomainStats();
83
+ }
84
+ this.byDomain.set(domain, stats);
85
+ return stats;
86
+ }
87
+ clone(stats) {
88
+ return {
89
+ establishFailures: stats.establishFailures,
90
+ fetchFallbacks: stats.fetchFallbacks,
91
+ fallbacksByReason: { ...stats.fallbacksByReason },
92
+ };
93
+ }
94
+ }
95
+ const store = new H2TransportStatsStore();
96
+ publishDebugSnapshot(store.snapshot());
97
+ /** @internal */
98
+ export function snapshotH2TransportStats() {
99
+ return store.snapshot();
100
+ }
101
+ /** @internal */
102
+ export function resetH2TransportStats() {
103
+ store.reset();
104
+ }
105
+ /** @internal */
106
+ export function recordH2EstablishFailure(domain, error) {
107
+ store.recordEstablishFailure(domain, error);
108
+ }
109
+ /** @internal */
110
+ export function recordH2Fallback(domain, reason) {
111
+ store.recordFallback(domain, reason);
112
+ }
@@ -1,3 +1,4 @@
1
+ import { crypto } from "./node.js";
1
2
  import { settings } from "./settings.js";
2
3
  const PACKAGE_LAYOUT_MARKERS = [
3
4
  "/src/common/sentry.ts",
@@ -24,7 +25,11 @@ const SAFE_ERROR_NAMES = new Set([
24
25
  "AggregateError",
25
26
  ]);
26
27
  const MAX_IN_FLIGHT_EVENTS = 20;
28
+ const MAX_ACTIVE_H2_ROLLUPS = 20;
29
+ const MAX_CACHED_DOMAIN_TAGS = 100;
30
+ const MAX_H2_EVENTS_PER_PROCESS = 100;
27
31
  const DELIVERY_TIMEOUT_MS = 500;
32
+ const H2_ROLLUP_WINDOW_MS = 60_000;
28
33
  const SAFE_FILENAME_SEGMENT = /^[A-Za-z0-9._-]+$/;
29
34
  let sentryInitialized = false;
30
35
  let handlersRegistered = false;
@@ -32,6 +37,9 @@ let sentryConfig = null;
32
37
  let flushPromise = null;
33
38
  const capturedExceptions = new WeakSet();
34
39
  const inFlightDeliveries = new Set();
40
+ const domainTagCache = new Map();
41
+ const h2DegradationRollups = new Map();
42
+ let h2EventsEmitted = 0;
35
43
  /**
36
44
  * Normalize stack filenames without resolving or exposing host filesystem data.
37
45
  */
@@ -204,6 +212,91 @@ function errorToSentryEvent(error, ownedFrames) {
204
212
  },
205
213
  };
206
214
  }
215
+ function runtimeTag() {
216
+ const host = globalThis;
217
+ if (host.process?.versions?.bun)
218
+ return `bun/${host.process.versions.bun}`;
219
+ if (host.Deno?.version?.deno)
220
+ return `deno/${host.Deno.version.deno}`;
221
+ if (host.process?.versions?.node)
222
+ return `node/${host.process.versions.node}`;
223
+ return "browser";
224
+ }
225
+ function normalizeDomain(domain) {
226
+ const normalized = domain.trim().toLowerCase().replace(/\.$/, "");
227
+ return normalized.length > 0 ? normalized : null;
228
+ }
229
+ function blaxelEdgeDomainTag(domain) {
230
+ const edgeSuffixes = [
231
+ { environment: "prod", suffix: ".bl.run" },
232
+ { environment: "dev", suffix: ".runv2.blaxel.dev" },
233
+ ];
234
+ for (const { environment, suffix } of edgeSuffixes) {
235
+ if (!domain.endsWith(suffix))
236
+ continue;
237
+ const region = domain.slice(0, -suffix.length).split(".").at(-1);
238
+ if (region && /^[a-z0-9-]+$/.test(region)) {
239
+ return `blaxel-edge:${environment}:${region}`;
240
+ }
241
+ return null;
242
+ }
243
+ return null;
244
+ }
245
+ function uncachedDomainTag(domain) {
246
+ const stableBlaxelDomains = {
247
+ "api.blaxel.ai": "blaxel-api:prod",
248
+ "api.blaxel.dev": "blaxel-api:dev",
249
+ "run.blaxel.ai": "blaxel-run:prod",
250
+ "run.blaxel.dev": "blaxel-run:dev",
251
+ };
252
+ const stableTag = stableBlaxelDomains[domain];
253
+ if (stableTag)
254
+ return stableTag;
255
+ const edgeTag = blaxelEdgeDomainTag(domain);
256
+ if (edgeTag)
257
+ return edgeTag;
258
+ if (!crypto)
259
+ return null;
260
+ try {
261
+ return `sha256:${crypto.createHash("sha256").update(domain).digest("hex")}`;
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ }
267
+ function domainToTag(domain) {
268
+ if (domainTagCache.has(domain))
269
+ return domainTagCache.get(domain) ?? null;
270
+ const domainTag = uncachedDomainTag(domain);
271
+ if (domainTagCache.size >= MAX_CACHED_DOMAIN_TAGS) {
272
+ const oldestDomain = domainTagCache.keys().next().value;
273
+ if (oldestDomain !== undefined)
274
+ domainTagCache.delete(oldestDomain);
275
+ }
276
+ domainTagCache.set(domain, domainTag);
277
+ return domainTag;
278
+ }
279
+ function h2DegradationToSentryEvent(reason, domainTag, count) {
280
+ return {
281
+ event_id: generateEventId(),
282
+ timestamp: Date.now() / 1000,
283
+ platform: "javascript",
284
+ level: "warning",
285
+ environment: settings.env,
286
+ release: `sdk-typescript@${settings.version}`,
287
+ message: `h2 transport degradation: ${reason}`,
288
+ fingerprint: ["h2-degradation", reason, domainTag],
289
+ tags: {
290
+ "blaxel.version": settings.version,
291
+ "blaxel.commit": settings.commit,
292
+ "blaxel.error_source": "h2-transport-degradation",
293
+ "blaxel.runtime": runtimeTag(),
294
+ reason,
295
+ domainTag,
296
+ },
297
+ extra: { count },
298
+ };
299
+ }
207
300
  async function sendToSentry(event) {
208
301
  if (!sentryConfig)
209
302
  return;
@@ -245,12 +338,71 @@ async function sendToSentry(event) {
245
338
  }
246
339
  function scheduleDelivery(event) {
247
340
  if (inFlightDeliveries.size >= MAX_IN_FLIGHT_EVENTS)
248
- return;
341
+ return false;
249
342
  // Contain rejections from setup that occurs before sendToSentry's internal
250
343
  // fetch guard (for example, a host-provided AbortController implementation).
251
344
  const delivery = sendToSentry(event).catch(() => undefined);
252
345
  inFlightDeliveries.add(delivery);
253
346
  void delivery.then(() => inFlightDeliveries.delete(delivery));
347
+ return true;
348
+ }
349
+ function emitH2DegradationRollup(key) {
350
+ const rollup = h2DegradationRollups.get(key);
351
+ if (!rollup)
352
+ return;
353
+ h2DegradationRollups.delete(key);
354
+ clearTimeout(rollup.timer);
355
+ if (h2EventsEmitted >= MAX_H2_EVENTS_PER_PROCESS)
356
+ return;
357
+ if (scheduleDelivery(h2DegradationToSentryEvent(rollup.reason, rollup.domainTag, rollup.count))) {
358
+ h2EventsEmitted++;
359
+ }
360
+ }
361
+ function flushH2DegradationRollups() {
362
+ for (const key of [...h2DegradationRollups.keys()]) {
363
+ emitH2DegradationRollup(key);
364
+ }
365
+ }
366
+ /** @internal */
367
+ export function reportH2TransportDegradation(domain, reason) {
368
+ if (!sentryInitialized || !sentryConfig)
369
+ return;
370
+ try {
371
+ if (h2EventsEmitted >= MAX_H2_EVENTS_PER_PROCESS)
372
+ return;
373
+ const normalizedDomain = normalizeDomain(domain);
374
+ if (!normalizedDomain)
375
+ return;
376
+ const domainTag = domainToTag(normalizedDomain);
377
+ if (!domainTag)
378
+ return;
379
+ const key = `${reason}\0${domainTag}`;
380
+ const existing = h2DegradationRollups.get(key);
381
+ if (existing) {
382
+ existing.count++;
383
+ return;
384
+ }
385
+ if (h2DegradationRollups.size >= MAX_ACTIVE_H2_ROLLUPS)
386
+ return;
387
+ const timer = setTimeout(() => {
388
+ try {
389
+ emitH2DegradationRollup(key);
390
+ }
391
+ catch {
392
+ // Telemetry must never change transport behavior.
393
+ }
394
+ }, H2_ROLLUP_WINDOW_MS);
395
+ timer.unref?.();
396
+ h2DegradationRollups.set(key, {
397
+ count: 1,
398
+ domainTag,
399
+ reason,
400
+ timer,
401
+ });
402
+ }
403
+ catch {
404
+ // Telemetry must never change transport behavior.
405
+ }
254
406
  }
255
407
  function captureException(error) {
256
408
  if (!sentryInitialized || !sentryConfig || capturedExceptions.has(error)) {
@@ -318,7 +470,15 @@ export function initSentry() {
318
470
  * Events are sent exactly once; flush never re-enqueues them.
319
471
  */
320
472
  export async function flushSentry(timeout = DELIVERY_TIMEOUT_MS) {
321
- if (!sentryInitialized || inFlightDeliveries.size === 0)
473
+ if (!sentryInitialized)
474
+ return;
475
+ try {
476
+ flushH2DegradationRollups();
477
+ }
478
+ catch {
479
+ // Flushing telemetry must never affect application shutdown.
480
+ }
481
+ if (inFlightDeliveries.size === 0)
322
482
  return;
323
483
  if (flushPromise) {
324
484
  await flushPromise;
@@ -41,12 +41,28 @@ function makeTraversalStackError() {
41
41
  error.stack = ["Error: application secret", forgedFrame].join("\n");
42
42
  return error;
43
43
  }
44
+ function createFetchMock() {
45
+ return vi.fn((input, init) => {
46
+ void input;
47
+ void init;
48
+ return Promise.resolve({ ok: true });
49
+ });
50
+ }
44
51
  function eventFromFetch(fetchMock) {
45
52
  const request = fetchMock.mock.calls[0]?.[1];
46
53
  if (typeof request?.body !== "string")
47
54
  throw new Error("Expected a string Sentry envelope");
48
55
  return JSON.parse(request.body.split("\n")[2]);
49
56
  }
57
+ function eventsFromFetch(fetchMock) {
58
+ return fetchMock.mock.calls.map((call) => {
59
+ const request = call[1];
60
+ if (typeof request?.body !== "string") {
61
+ throw new Error("Expected a string Sentry envelope");
62
+ }
63
+ return JSON.parse(request.body.split("\n")[2]);
64
+ });
65
+ }
50
66
  function emitUncaughtExceptionMonitor(error) {
51
67
  const processWithStringEvents = process;
52
68
  return processWithStringEvents.emit("uncaughtExceptionMonitor", error, "uncaughtException");
@@ -61,6 +77,8 @@ describe("SDK Sentry boundary", () => {
61
77
  originalMonitorListeners = process.listeners("uncaughtExceptionMonitor");
62
78
  });
63
79
  afterEach(() => {
80
+ vi.useRealTimers();
81
+ vi.doUnmock("./node.js");
64
82
  vi.unstubAllGlobals();
65
83
  for (const listener of process.listeners("uncaughtExceptionMonitor")) {
66
84
  if (!originalMonitorListeners.includes(listener)) {
@@ -70,7 +88,7 @@ describe("SDK Sentry boundary", () => {
70
88
  vi.restoreAllMocks();
71
89
  });
72
90
  it("does not replace console.error or report caught errors", async () => {
73
- const fetchMock = vi.fn().mockResolvedValue({ ok: true });
91
+ const fetchMock = createFetchMock();
74
92
  const hostConsoleError = vi.fn();
75
93
  vi.stubGlobal("fetch", fetchMock);
76
94
  vi.spyOn(console, "error").mockImplementation(hostConsoleError);
@@ -83,7 +101,7 @@ describe("SDK Sentry boundary", () => {
83
101
  expect(fetchMock).not.toHaveBeenCalled();
84
102
  });
85
103
  it("composes with host handlers and reports one sanitized SDK-owned event", async () => {
86
- const fetchMock = vi.fn().mockResolvedValue({ ok: true });
104
+ const fetchMock = createFetchMock();
87
105
  const hostMonitor = vi.fn();
88
106
  const hostRejection = vi.fn();
89
107
  vi.stubGlobal("fetch", fetchMock);
@@ -101,7 +119,7 @@ describe("SDK Sentry boundary", () => {
101
119
  expect(process.listeners("unhandledRejection")).toEqual(rejectionListeners);
102
120
  expect(fetchMock).toHaveBeenCalledOnce();
103
121
  const event = eventFromFetch(fetchMock);
104
- expect(event.exception.values[0]).toMatchObject({
122
+ expect(event.exception?.values[0]).toMatchObject({
105
123
  type: "TypeError",
106
124
  value: "Unhandled SDK exception",
107
125
  });
@@ -111,7 +129,7 @@ describe("SDK Sentry boundary", () => {
111
129
  "blaxel.error_source": "unhandled-sdk-exception",
112
130
  });
113
131
  expect(event.tags).not.toHaveProperty("blaxel.workspace");
114
- const frames = event.exception.values[0].stacktrace.frames;
132
+ const frames = event.exception?.values[0]?.stacktrace.frames ?? [];
115
133
  expect(frames.length).toBeGreaterThan(0);
116
134
  for (const frame of frames) {
117
135
  expect(frame.filename).toBe("@blaxel/core/src/common/sentry.test.ts");
@@ -128,7 +146,7 @@ describe("SDK Sentry boundary", () => {
128
146
  }
129
147
  });
130
148
  it("does not attribute an application-owned exception with a later SDK frame", async () => {
131
- const fetchMock = vi.fn().mockResolvedValue({ ok: true });
149
+ const fetchMock = createFetchMock();
132
150
  vi.stubGlobal("fetch", fetchMock);
133
151
  const { initSentry } = await import("./sentry.js");
134
152
  initSentry();
@@ -136,7 +154,7 @@ describe("SDK Sentry boundary", () => {
136
154
  expect(fetchMock).not.toHaveBeenCalled();
137
155
  });
138
156
  it("rejects a forged owned path containing parent traversal", async () => {
139
- const fetchMock = vi.fn().mockResolvedValue({ ok: true });
157
+ const fetchMock = createFetchMock();
140
158
  vi.stubGlobal("fetch", fetchMock);
141
159
  const { initSentry } = await import("./sentry.js");
142
160
  initSentry();
@@ -144,7 +162,7 @@ describe("SDK Sentry boundary", () => {
144
162
  expect(fetchMock).not.toHaveBeenCalled();
145
163
  });
146
164
  it("contains delivery setup failures without creating an unhandled rejection", async () => {
147
- const fetchMock = vi.fn().mockResolvedValue({ ok: true });
165
+ const fetchMock = createFetchMock();
148
166
  vi.stubGlobal("fetch", fetchMock);
149
167
  vi.stubGlobal("AbortController", class FailingAbortController {
150
168
  constructor() {
@@ -158,7 +176,7 @@ describe("SDK Sentry boundary", () => {
158
176
  expect(fetchMock).not.toHaveBeenCalled();
159
177
  });
160
178
  it("composes with browser handlers and ignores primitive rejections", async () => {
161
- const fetchMock = vi.fn().mockResolvedValue({ ok: true });
179
+ const fetchMock = createFetchMock();
162
180
  const listeners = new Map();
163
181
  const addEventListener = vi.fn((type, listener) => {
164
182
  listeners.set(type, listener);
@@ -177,13 +195,150 @@ describe("SDK Sentry boundary", () => {
177
195
  expect(JSON.stringify(eventFromFetch(fetchMock))).not.toContain("raw rejection secret");
178
196
  });
179
197
  it("does not initialize when tracking is disabled", async () => {
180
- const fetchMock = vi.fn().mockResolvedValue({ ok: true });
198
+ const fetchMock = createFetchMock();
181
199
  vi.stubGlobal("fetch", fetchMock);
182
200
  mockSettings.tracking = false;
183
- const { initSentry, isSentryInitialized } = await import("./sentry.js");
201
+ const { flushSentry, initSentry, isSentryInitialized, reportH2TransportDegradation, } = await import("./sentry.js");
184
202
  initSentry();
185
203
  emitUncaughtExceptionMonitor(makeSdkError());
204
+ reportH2TransportDegradation("sbx-test-workspace.us-pdx-1.bl.run", "no-session");
205
+ await flushSentry();
186
206
  expect(isSentryInitialized()).toBe(false);
187
207
  expect(fetchMock).not.toHaveBeenCalled();
188
208
  });
209
+ it("rolls a 100-fallback burst into one warning event", async () => {
210
+ vi.useFakeTimers();
211
+ const fetchMock = createFetchMock();
212
+ vi.stubGlobal("fetch", fetchMock);
213
+ const { flushSentry, initSentry, reportH2TransportDegradation } = await import("./sentry.js");
214
+ initSentry();
215
+ const domain = "sbx-test-workspace.us-pdx-1.bl.run";
216
+ const domainTag = "blaxel-edge:prod:us-pdx-1";
217
+ for (let index = 0; index < 100; index++) {
218
+ reportH2TransportDegradation(domain, "no-session");
219
+ }
220
+ expect(fetchMock).not.toHaveBeenCalled();
221
+ await vi.advanceTimersByTimeAsync(60_000);
222
+ await flushSentry();
223
+ expect(fetchMock).toHaveBeenCalledOnce();
224
+ expect(eventFromFetch(fetchMock)).toMatchObject({
225
+ level: "warning",
226
+ message: "h2 transport degradation: no-session",
227
+ fingerprint: ["h2-degradation", "no-session", domainTag],
228
+ tags: {
229
+ "blaxel.version": "9.9.9",
230
+ "blaxel.commit": "abcdef0",
231
+ "blaxel.error_source": "h2-transport-degradation",
232
+ "blaxel.runtime": `node/${process.versions.node}`,
233
+ reason: "no-session",
234
+ domainTag,
235
+ },
236
+ extra: { count: 100 },
237
+ });
238
+ });
239
+ it("groups keys independently and hashes external domains", async () => {
240
+ const fetchMock = createFetchMock();
241
+ vi.stubGlobal("fetch", fetchMock);
242
+ const { flushSentry, initSentry, reportH2TransportDegradation } = await import("./sentry.js");
243
+ initSentry();
244
+ const externalDomain = "customer.internal.example";
245
+ const blaxelDomain = "sbx-test-workspace.us-pdx-1.bl.run";
246
+ reportH2TransportDegradation(externalDomain, "no-session");
247
+ reportH2TransportDegradation(externalDomain, "no-session");
248
+ reportH2TransportDegradation(blaxelDomain, "request-rejected");
249
+ await flushSentry();
250
+ expect(fetchMock).toHaveBeenCalledTimes(2);
251
+ const events = eventsFromFetch(fetchMock);
252
+ const externalEvent = events.find((event) => event.tags.reason === "no-session");
253
+ const blaxelEvent = events.find((event) => event.tags.reason === "request-rejected");
254
+ expect(externalEvent).toMatchObject({ extra: { count: 2 } });
255
+ expect(externalEvent?.tags.domainTag).toMatch(/^sha256:[a-f0-9]{64}$/);
256
+ expect(JSON.stringify(externalEvent)).not.toContain(externalDomain);
257
+ expect(blaxelEvent).toMatchObject({
258
+ tags: { domainTag: "blaxel-edge:prod:us-pdx-1" },
259
+ extra: { count: 1 },
260
+ });
261
+ });
262
+ it("groups Blaxel edge hosts by environment and region", async () => {
263
+ const fetchMock = createFetchMock();
264
+ vi.stubGlobal("fetch", fetchMock);
265
+ const { flushSentry, initSentry, reportH2TransportDegradation } = await import("./sentry.js");
266
+ initSentry();
267
+ reportH2TransportDegradation("sbx-first-workspace.us-pdx-1.bl.run", "no-session");
268
+ reportH2TransportDegradation("sbx-second-workspace.us-pdx-1.bl.run", "no-session");
269
+ await flushSentry();
270
+ expect(fetchMock).toHaveBeenCalledOnce();
271
+ expect(eventFromFetch(fetchMock)).toMatchObject({
272
+ fingerprint: [
273
+ "h2-degradation",
274
+ "no-session",
275
+ "blaxel-edge:prod:us-pdx-1",
276
+ ],
277
+ extra: { count: 2 },
278
+ });
279
+ });
280
+ it("keeps stable Blaxel control-plane domains identifiable", async () => {
281
+ const fetchMock = createFetchMock();
282
+ vi.stubGlobal("fetch", fetchMock);
283
+ const { flushSentry, initSentry, reportH2TransportDegradation } = await import("./sentry.js");
284
+ initSentry();
285
+ reportH2TransportDegradation("api.blaxel.ai", "no-session");
286
+ await flushSentry();
287
+ expect(eventFromFetch(fetchMock)).toMatchObject({
288
+ tags: { domainTag: "blaxel-api:prod" },
289
+ fingerprint: ["h2-degradation", "no-session", "blaxel-api:prod"],
290
+ });
291
+ });
292
+ it("caps active rollup keys at 20", async () => {
293
+ const fetchMock = createFetchMock();
294
+ vi.stubGlobal("fetch", fetchMock);
295
+ const { flushSentry, initSentry, reportH2TransportDegradation } = await import("./sentry.js");
296
+ initSentry();
297
+ for (let index = 0; index < 21; index++) {
298
+ reportH2TransportDegradation(`sbx-test-workspace.us-test-${index}.bl.run`, "no-session");
299
+ }
300
+ await flushSentry();
301
+ expect(fetchMock).toHaveBeenCalledTimes(20);
302
+ });
303
+ it("caps H2 degradation events at 100 per process", async () => {
304
+ const fetchMock = createFetchMock();
305
+ vi.stubGlobal("fetch", fetchMock);
306
+ const { flushSentry, initSentry, reportH2TransportDegradation } = await import("./sentry.js");
307
+ initSentry();
308
+ for (let index = 0; index < 101; index++) {
309
+ reportH2TransportDegradation("sbx-test-workspace.us-pdx-1.bl.run", "no-session");
310
+ await flushSentry();
311
+ }
312
+ expect(fetchMock).toHaveBeenCalledTimes(100);
313
+ });
314
+ it("fails closed when an external domain cannot be hashed", async () => {
315
+ const fetchMock = createFetchMock();
316
+ vi.stubGlobal("fetch", fetchMock);
317
+ vi.doMock("./node.js", () => ({
318
+ crypto: {
319
+ createHash: () => {
320
+ throw new Error("hash unavailable");
321
+ },
322
+ },
323
+ }));
324
+ const { flushSentry, initSentry, reportH2TransportDegradation } = await import("./sentry.js");
325
+ initSentry();
326
+ expect(() => reportH2TransportDegradation("private.customer.example", "no-session")).not.toThrow();
327
+ await flushSentry();
328
+ expect(fetchMock).not.toHaveBeenCalled();
329
+ });
330
+ it("reports degradations through the H2 statistics hooks", async () => {
331
+ const fetchMock = createFetchMock();
332
+ vi.stubGlobal("fetch", fetchMock);
333
+ const { flushSentry, initSentry } = await import("./sentry.js");
334
+ initSentry();
335
+ const { recordH2Fallback } = await import("./h2stats.js");
336
+ recordH2Fallback("sbx-test-workspace.us-pdx-1.bl.run", "unsupported-body");
337
+ await flushSentry();
338
+ expect(fetchMock).toHaveBeenCalledOnce();
339
+ expect(eventFromFetch(fetchMock)).toMatchObject({
340
+ message: "h2 transport degradation: unsupported-body",
341
+ extra: { count: 1 },
342
+ });
343
+ });
189
344
  });
@@ -24,8 +24,8 @@ function missingCredentialsMessage() {
24
24
  return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
25
25
  }
26
26
  // Build info - these placeholders are replaced at build time by build:replace-imports
27
- const BUILD_VERSION = "0.3.12-preview.254";
28
- const BUILD_COMMIT = "c57f438aac364b3b4137e7e4ec37d446fec85006";
27
+ const BUILD_VERSION = "0.3.12-preview.256";
28
+ const BUILD_COMMIT = "d1beb333d98608709a5a26ace333ff0db5673694";
29
29
  const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
30
30
  const BLAXEL_API_VERSION = "2026-04-28";
31
31
  // Bun < 1.3.11 never sends connection-level WINDOW_UPDATE: the pooled h2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blaxel/core",
3
- "version": "0.3.12-preview.254",
3
+ "version": "0.3.12-preview.256",
4
4
  "description": "Blaxel Core SDK for TypeScript",
5
5
  "license": "MIT",
6
6
  "author": "Blaxel, INC (https://blaxel.ai)",