Skip to content

Commit 27fec65

Browse files
V48 (impl-only): Richer sandbox create failure telemetry for deposit
Emit sandbox-create-failed with expanded API body, log plan-ready / run-failed source-safe create summaries, and stream create-started/failed details into execution events so Production no longer stops at bare Status code 400 is not ok.
1 parent 1312d48 commit 27fec65

5 files changed

Lines changed: 265 additions & 76 deletions

File tree

packages/generic-hosts/VercelSandbox/src/__tests__/vercel-sandbox-host.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url';
55
import { buildAssetPackSandboxHostPlan } from '@bitcode/pipeline-hosts';
66
import {
77
assertVercelSandboxAuthAvailable,
8+
formatSandboxApiError,
89
normalizeCreateOptions,
910
resolveVercelSandboxEsmEntryHref,
1011
resolveVercelSandboxPackageRoot,
@@ -420,6 +421,19 @@ describe('VercelSandboxPipelineHost', () => {
420421
expect(href.endsWith('/dist/index.js') || href.endsWith('\\dist\\index.js')).toBe(true);
421422
});
422423

424+
it('expands sandbox API 400 with response JSON for operators', () => {
425+
const apiError = Object.assign(new Error('Status code 400 is not ok'), {
426+
response: { status: 400, statusText: 'Bad Request' },
427+
json: { error: { code: 'invalid_image', message: 'image not found in project' } },
428+
});
429+
const wrapped = new Error('wrapper', { cause: apiError });
430+
const message = formatSandboxApiError(wrapped, 'Sandbox.create');
431+
expect(message).toContain('Sandbox.create failed');
432+
expect(message).toContain('HTTP 400');
433+
expect(message).toContain('invalid_image');
434+
expect(message).toContain('image not found in project');
435+
});
436+
423437
it('accepts access-token auth when OIDC is absent', () => {
424438
const previous = {
425439
VERCEL_TOKEN: process.env.VERCEL_TOKEN,

packages/generic-hosts/VercelSandbox/src/vercel-sandbox-host.ts

Lines changed: 123 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,18 @@ export class VercelSandboxPipelineHost {
4949
// be explicit false for one-shot deposit/read hosts (Snapshot Storage
5050
// is billed separately). normalizeCreateOptions enforces that + a unique name.
5151
const createOptions = normalizeCreateOptions(plan.createOptions);
52+
const createStartedAt = new Date().toISOString();
5253
await this.emit({
5354
type: 'sandbox-create-started',
54-
timestamp: new Date().toISOString(),
55+
timestamp: createStartedAt,
5556
runtime: createOptions.runtime,
56-
// Source-safe: image ref only (no tokens). Helps diagnose create 400s.
5757
image: createOptions.image ?? null,
5858
mode: plan.manifest.hostMode,
5959
hasSource: Boolean(createOptions.source),
6060
persistent: createOptions.persistent === true,
61-
} as PipelineHostEvent);
61+
name: createOptions.name,
62+
timeoutMs: createOptions.timeout,
63+
});
6264
let sandbox: SandboxSession;
6365
try {
6466
sandbox = await withTimeout(
@@ -67,7 +69,20 @@ export class VercelSandboxPipelineHost {
6769
`Vercel Sandbox create did not complete within ${this.sandboxCreateTimeoutMs}ms.`,
6870
);
6971
} catch (error) {
70-
throw new Error(formatSandboxApiError(error, 'Sandbox.create'), { cause: error });
72+
const message = formatSandboxApiError(error, 'Sandbox.create');
73+
const httpStatus = extractSandboxHttpStatus(error);
74+
await this.emit({
75+
type: 'sandbox-create-failed',
76+
timestamp: new Date().toISOString(),
77+
mode: plan.manifest.hostMode,
78+
image: createOptions.image ?? null,
79+
runtime: createOptions.runtime,
80+
hasSource: Boolean(createOptions.source),
81+
name: createOptions.name,
82+
message,
83+
httpStatus,
84+
});
85+
throw new Error(message, { cause: error });
7186
}
7287
const sandboxIdentity = resolveSandboxIdentity(sandbox, createOptions.name);
7388
await this.emit({
@@ -77,6 +92,7 @@ export class VercelSandboxPipelineHost {
7792
name: sandboxIdentity.name,
7893
persistent: createOptions.persistent === true,
7994
status: sandbox.status,
95+
image: createOptions.image ?? null,
8096
});
8197
const commands: PipelineHostCommandResult[] = [];
8298
let stopped = false;
@@ -481,60 +497,127 @@ export function normalizeCreateOptions(
481497
};
482498
}
483499

500+
type SandboxErrorShape = Error & {
501+
json?: unknown;
502+
text?: string;
503+
response?: { status?: number; statusText?: string };
504+
sandboxName?: string;
505+
cause?: unknown;
506+
};
507+
508+
function collectErrorChain(error: unknown, depth = 0): SandboxErrorShape[] {
509+
if (depth > 5 || error == null) return [];
510+
if (!(error instanceof Error)) return [];
511+
const chain = [error as SandboxErrorShape];
512+
const cause = (error as SandboxErrorShape).cause;
513+
if (cause) chain.push(...collectErrorChain(cause, depth + 1));
514+
return chain;
515+
}
516+
517+
function detailFromJsonBody(body: Record<string, unknown>): string {
518+
const msg =
519+
(typeof body.message === 'string' && body.message) ||
520+
(typeof body.error === 'string' && body.error) ||
521+
(body.error &&
522+
typeof body.error === 'object' &&
523+
typeof (body.error as { message?: string }).message === 'string' &&
524+
(body.error as { message: string }).message) ||
525+
null;
526+
const code =
527+
(typeof body.code === 'string' && body.code) ||
528+
(body.error &&
529+
typeof body.error === 'object' &&
530+
typeof (body.error as { code?: string }).code === 'string' &&
531+
(body.error as { code: string }).code) ||
532+
null;
533+
const joined = [code, msg].filter(Boolean).join(': ');
534+
if (joined) return joined;
535+
try {
536+
return JSON.stringify(body).slice(0, 500);
537+
} catch {
538+
return '';
539+
}
540+
}
541+
484542
/**
485543
* Expand Vercel Sandbox SDK APIError ("Status code 400 is not ok") with response
486544
* body fields so Production logs / UI can show the real reject reason.
545+
* Walks `error.cause` so wrappers still expose the SDK body.
487546
*/
488547
export function formatSandboxApiError(error: unknown, phase: string): string {
489548
if (!(error instanceof Error)) {
490549
return `${phase} failed: ${String(error)}`;
491550
}
492-
const anyErr = error as Error & {
493-
json?: unknown;
494-
text?: string;
495-
response?: { status?: number; statusText?: string };
496-
sandboxName?: string;
497-
};
498-
const status = anyErr.response?.status;
499-
const statusText = anyErr.response?.statusText;
551+
const chain = collectErrorChain(error);
552+
let status: number | undefined;
553+
let statusText: string | undefined;
500554
let detail = '';
501-
if (anyErr.json && typeof anyErr.json === 'object') {
502-
const body = anyErr.json as Record<string, unknown>;
503-
const msg =
504-
(typeof body.message === 'string' && body.message) ||
505-
(typeof body.error === 'string' && body.error) ||
506-
(body.error &&
507-
typeof body.error === 'object' &&
508-
typeof (body.error as { message?: string }).message === 'string' &&
509-
(body.error as { message: string }).message) ||
510-
null;
511-
const code =
512-
(typeof body.code === 'string' && body.code) ||
513-
(body.error &&
514-
typeof body.error === 'object' &&
515-
typeof (body.error as { code?: string }).code === 'string' &&
516-
(body.error as { code: string }).code) ||
517-
null;
518-
detail = [code, msg].filter(Boolean).join(': ');
519-
if (!detail) {
520-
try {
521-
detail = JSON.stringify(body).slice(0, 400);
522-
} catch {
523-
detail = '';
524-
}
555+
const messages: string[] = [];
556+
557+
for (const entry of chain) {
558+
if (entry.message?.trim()) messages.push(entry.message.trim());
559+
if (typeof entry.response?.status === 'number') {
560+
status = entry.response.status;
561+
statusText = entry.response.statusText;
562+
}
563+
if (!detail && entry.json && typeof entry.json === 'object') {
564+
detail = detailFromJsonBody(entry.json as Record<string, unknown>);
565+
}
566+
if (!detail && typeof entry.text === 'string' && entry.text.trim()) {
567+
detail = entry.text.trim().slice(0, 500);
525568
}
526-
} else if (typeof anyErr.text === 'string' && anyErr.text.trim()) {
527-
detail = anyErr.text.trim().slice(0, 400);
528569
}
529-
const base = error.message?.trim() || 'unknown error';
570+
571+
const base =
572+
messages.find((m) => !/^Status code \d+ is not ok$/.test(m)) ||
573+
messages[0] ||
574+
'unknown error';
530575
const statusPart =
531576
typeof status === 'number' ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}` : null;
532-
const parts = [`${phase} failed`, statusPart, base !== `Status code ${status} is not ok` ? base : null, detail]
577+
const parts = [`${phase} failed`, statusPart, base, detail]
533578
.filter(Boolean)
534579
.filter((part, index, arr) => arr.indexOf(part) === index);
535580
return parts.join(' — ');
536581
}
537582

583+
export function extractSandboxHttpStatus(error: unknown): number | null {
584+
for (const entry of collectErrorChain(error)) {
585+
if (typeof entry.response?.status === 'number') return entry.response.status;
586+
}
587+
return null;
588+
}
589+
590+
/** Source-safe summary of create options for always-on server logs. */
591+
export function summarizeSandboxCreateOptions(
592+
createOptions: PipelineHostPlan['createOptions'],
593+
): Record<string, unknown> {
594+
const source = createOptions.source;
595+
return {
596+
name: createOptions.name ?? null,
597+
image: createOptions.image ?? null,
598+
runtime: createOptions.runtime ?? null,
599+
persistent: createOptions.persistent === true,
600+
timeoutMs: createOptions.timeout ?? null,
601+
hasToken: Boolean(createOptions.token),
602+
hasTeamId: Boolean(createOptions.teamId),
603+
hasProjectId: Boolean(createOptions.projectId),
604+
networkPolicy:
605+
typeof createOptions.networkPolicy === 'string'
606+
? createOptions.networkPolicy
607+
: createOptions.networkPolicy
608+
? 'custom'
609+
: null,
610+
sourceType: source && typeof source === 'object' && 'type' in source ? source.type : null,
611+
sourceHasAuth: Boolean(
612+
source &&
613+
typeof source === 'object' &&
614+
'password' in source &&
615+
(source as { password?: string }).password,
616+
),
617+
envKeyCount: createOptions.env ? Object.keys(createOptions.env).length : 0,
618+
};
619+
}
620+
538621
function resolveSandboxIdentity(
539622
sandbox: SandboxSession,
540623
fallbackName?: string,

packages/host-generics/src/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,25 @@ export type PipelineHostEvent =
317317
type: 'sandbox-create-started';
318318
timestamp: string;
319319
runtime?: VercelSandboxRuntime;
320+
/** VCR image ref when using Pipeliner (source-safe). */
321+
image?: string | null;
320322
mode: PipelineHostMode;
323+
hasSource?: boolean;
324+
persistent?: boolean;
325+
name?: string;
326+
timeoutMs?: number;
327+
}
328+
| {
329+
type: 'sandbox-create-failed';
330+
timestamp: string;
331+
mode: PipelineHostMode;
332+
image?: string | null;
333+
runtime?: VercelSandboxRuntime;
334+
hasSource?: boolean;
335+
name?: string;
336+
/** Expanded API error (no secrets). */
337+
message: string;
338+
httpStatus?: number | null;
321339
}
322340
| {
323341
type: 'sandbox-created';
@@ -327,6 +345,7 @@ export type PipelineHostEvent =
327345
name?: string;
328346
persistent?: boolean;
329347
status?: string;
348+
image?: string | null;
330349
}
331350
| {
332351
type: 'sandbox-cancelled';

uapi/app/api/deposit/synthesize-options/dispatch-deposit-synthesis.ts

Lines changed: 74 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -172,45 +172,84 @@ export async function runDepositOptionSynthesis(
172172

173173
if (hostKind === 'sandbox') {
174174
await assertNotCancelled();
175-
await emitStatus(
176-
`Dispatching deposit synthesis to the sandbox host (in-box) for ${repositoryFullName}@${reference}…`,
177-
);
178-
const hostResult = await runDepositInBoxHost({
175+
const sandboxImage = process.env.BITCODE_PIPELINE_SANDBOX_IMAGE?.trim() || null;
176+
bitcodeServerTelemetry('info', 'deposit-synthesize-options', 'sandbox-dispatch', {
177+
userId: compactBitcodeServerId(userId),
179178
repositoryFullName,
179+
runId,
180+
hostKind,
181+
image: sandboxImage,
180182
revision: reference,
181-
branch: sourceBranch,
182-
commit: sourceCommit,
183-
token: auth.accessToken,
184-
obfuscations,
185-
forcedExclusions,
186-
demandContext,
187-
shouldAbort: () => isExecutionCancelled(supabaseAdmin, runId),
188-
onEvent: (event) => {
189-
void emitStatus(`sandbox: ${event.type}`);
190-
if (event.type === 'sandbox-created' && event.sandboxId) {
191-
boundSandboxId = event.sandboxId;
192-
void mergeDispatchContext({
193-
sandboxId: event.sandboxId,
194-
hostKind: 'sandbox',
195-
});
196-
}
197-
},
198183
});
199-
boundSandboxId = hostResult.sandboxId ?? boundSandboxId;
200-
if (hostResult.outcome === 'cancelled') {
201-
throw new ExecutionCancelledError(runId);
184+
await emitStatus(
185+
`Dispatching deposit synthesis to the sandbox host (in-box) for ${repositoryFullName}@${reference}` +
186+
(sandboxImage ? ` [image=${sandboxImage}]` : ' [stock runtime]') +
187+
'…',
188+
);
189+
try {
190+
const hostResult = await runDepositInBoxHost({
191+
repositoryFullName,
192+
revision: reference,
193+
branch: sourceBranch,
194+
commit: sourceCommit,
195+
token: auth.accessToken,
196+
obfuscations,
197+
forcedExclusions,
198+
demandContext,
199+
shouldAbort: () => isExecutionCancelled(supabaseAdmin, runId),
200+
onEvent: (event) => {
201+
if (event.type === 'sandbox-create-started') {
202+
void emitStatus(
203+
`sandbox: create-started image=${event.image ?? 'none'} runtime=${event.runtime ?? 'none'} source=${event.hasSource ? 'git' : 'none'}`,
204+
);
205+
} else if (event.type === 'sandbox-create-failed') {
206+
void emitStatus(`sandbox: create-failed ${event.message}`);
207+
} else if (event.type === 'sandbox-created') {
208+
void emitStatus(
209+
`sandbox: created id=${event.sandboxId ?? event.name ?? 'unknown'} image=${event.image ?? 'none'}`,
210+
);
211+
} else {
212+
void emitStatus(`sandbox: ${event.type}`);
213+
}
214+
if (event.type === 'sandbox-created' && event.sandboxId) {
215+
boundSandboxId = event.sandboxId;
216+
void mergeDispatchContext({
217+
sandboxId: event.sandboxId,
218+
hostKind: 'sandbox',
219+
image: sandboxImage,
220+
});
221+
}
222+
},
223+
});
224+
boundSandboxId = hostResult.sandboxId ?? boundSandboxId;
225+
if (hostResult.outcome === 'cancelled') {
226+
throw new ExecutionCancelledError(runId);
227+
}
228+
rawOptions = hostResult.options as Parameters<typeof validateDepositSynthesisOptions>[0];
229+
inventoryPaths = [
230+
...new Set((rawOptions || []).flatMap((option: any) => option?.coveredSourcePaths || [])),
231+
] as string[];
232+
sourceCatalog = {
233+
paths: inventoryPaths,
234+
samples: [],
235+
sources: [],
236+
totalPathCount: inventoryPaths.length,
237+
excludedPathCount: 0,
238+
};
239+
} catch (sandboxError) {
240+
const message =
241+
sandboxError instanceof Error ? sandboxError.message : String(sandboxError);
242+
bitcodeServerTelemetry('error', 'deposit-synthesize-options', 'sandbox-path-failed', {
243+
userId: compactBitcodeServerId(userId),
244+
repositoryFullName,
245+
runId,
246+
image: sandboxImage,
247+
message: message.slice(0, 600),
248+
});
249+
throw sandboxError instanceof Error
250+
? sandboxError
251+
: new Error(message);
202252
}
203-
rawOptions = hostResult.options as Parameters<typeof validateDepositSynthesisOptions>[0];
204-
inventoryPaths = [
205-
...new Set((rawOptions || []).flatMap((option: any) => option?.coveredSourcePaths || [])),
206-
] as string[];
207-
sourceCatalog = {
208-
paths: inventoryPaths,
209-
samples: [],
210-
sources: [],
211-
totalPathCount: inventoryPaths.length,
212-
excludedPathCount: 0,
213-
};
214253
} else {
215254
await assertNotCancelled();
216255
// Init is not cloning: wire a run-scoped LocalHost cloner for Setup only.

0 commit comments

Comments
 (0)