@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.
@@ -1,6 +1,7 @@
1
1
  import http2 from "http2";
2
2
  import { settings } from "./settings.js";
3
3
  import { refH2SessionForActiveRequest } from "./h2ref.js";
4
+ import { recordH2Fallback } from "./h2stats.js";
4
5
  // Statuses the Response constructor refuses to pair with a body. Handing it one
5
6
  // throws, so a 204 (e.g. from a DELETE) would blow up instead of resolving.
6
7
  const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
@@ -158,6 +159,7 @@ export async function withUploadSlot(domain, fn) {
158
159
  export function createH2Fetch(session) {
159
160
  return (input) => {
160
161
  if (session.closed || session.destroyed) {
162
+ recordH2Fallback(new URL(input.url).hostname, "session-unusable");
161
163
  return globalThis.fetch(input);
162
164
  }
163
165
  return _h2Request(session, input);
@@ -223,6 +225,7 @@ async function h2GatewayAttempt(pool, domain, send, fallback) {
223
225
  let h2RequestCreated = false;
224
226
  try {
225
227
  return await send(session, {
228
+ domain,
226
229
  onH2RequestCreated: () => {
227
230
  h2RequestCreated = true;
228
231
  },
@@ -241,6 +244,7 @@ async function h2GatewayAttempt(pool, domain, send, fallback) {
241
244
  // No usable session: free the slot before falling back over a different
242
245
  // connection (no stream opens on the shared session).
243
246
  rel();
247
+ recordH2Fallback(domain, "no-session");
244
248
  return await fallback();
245
249
  }
246
250
  catch (err) {
@@ -280,6 +284,7 @@ function h2RequestDirectInternal(session, url, init, options) {
280
284
  // Pre-flight fallback (session unusable): no stream opens on the shared
281
285
  // session, so free any held slot before going over globalThis.fetch.
282
286
  options?.releaseSlot?.();
287
+ recordH2Fallback(options?.domain ?? new URL(url).hostname, "session-unusable");
283
288
  return globalThis.fetch(url, init);
284
289
  }
285
290
  const parsed = new URL(url);
@@ -322,6 +327,7 @@ function h2RequestDirectInternal(session, url, init, options) {
322
327
  // nothing has been sent on the wire yet). No stream opens on the shared
323
328
  // session, so free any held slot before falling back.
324
329
  options?.releaseSlot?.();
330
+ recordH2Fallback(options?.domain ?? parsed.hostname, "unsupported-body");
325
331
  return globalThis.fetch(url, init);
326
332
  }
327
333
  if (!h2Headers["content-length"]) {
@@ -401,6 +407,7 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
401
407
  // H2 frames were sent. No stream opened on the shared session, so free
402
408
  // the slot before retrying over globalThis.fetch.
403
409
  releaseSlot();
410
+ recordH2Fallback(options?.domain ?? new URL(fallbackUrl).hostname, session.closed || session.destroyed ? "session-unusable" : "request-rejected");
404
411
  globalThis.fetch(fallbackUrl, fallbackInit).then(resolve, reject);
405
412
  return;
406
413
  }
@@ -1,3 +1,4 @@
1
+ import { recordH2EstablishFailure } from "./h2stats.js";
1
2
  const DEFAULT_MAX_IDLE_MS = 5_000;
2
3
  const DEFAULT_PING_TIMEOUT_MS = 500;
3
4
  /**
@@ -39,6 +40,22 @@ export class H2Pool {
39
40
  this.attachEvictionListeners(domain, session);
40
41
  return session;
41
42
  }
43
+ startEstablish(domain) {
44
+ const pending = this.establish(domain)
45
+ .then((session) => {
46
+ this.cache(domain, session);
47
+ return session;
48
+ })
49
+ .catch((error) => {
50
+ recordH2EstablishFailure(domain, error);
51
+ return null;
52
+ })
53
+ .finally(() => {
54
+ this.inflight.delete(domain);
55
+ });
56
+ this.inflight.set(domain, pending);
57
+ return pending;
58
+ }
42
59
  attachEvictionListeners(domain, session) {
43
60
  const evict = () => {
44
61
  // Only evict if this specific session is still the cached one.
@@ -157,16 +174,7 @@ export class H2Pool {
157
174
  return;
158
175
  if (this.inflight.has(domain))
159
176
  return;
160
- const p = this.establish(domain)
161
- .then((session) => {
162
- this.cache(domain, session);
163
- return session;
164
- })
165
- .catch(() => null)
166
- .finally(() => {
167
- this.inflight.delete(domain);
168
- });
169
- this.inflight.set(domain, p);
177
+ void this.startEstablish(domain);
170
178
  }
171
179
  /**
172
180
  * Synchronous cache check. Returns a live cached session or `null`.
@@ -214,17 +222,7 @@ export class H2Pool {
214
222
  const freshCached = this.tryGet(domain);
215
223
  if (freshCached)
216
224
  return freshCached;
217
- const p = this.establish(domain)
218
- .then((session) => {
219
- this.cache(domain, session);
220
- return session;
221
- })
222
- .catch(() => null)
223
- .finally(() => {
224
- this.inflight.delete(domain);
225
- });
226
- this.inflight.set(domain, p);
227
- return p;
225
+ return this.startEstablish(domain);
228
226
  }
229
227
  /** Close all sessions (for cleanup). */
230
228
  closeAll() {
@@ -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;