Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 1 addition & 348 deletions bun.lock

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"@anthropic-ai/sdk": "^0.39.0",
"@biomejs/biome": "2.3.8",
"@clack/prompts": "^0.11.0",
"@mastra/client-js": "^1.4.0",
"@sentry/api": "^0.94.0",
"@sentry/node-core": "10.47.0",
"@sentry/sqlish": "^1.0.0",
Expand Down
10 changes: 5 additions & 5 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* sentry init
*
* Initialize Sentry in a project using the remote wizard workflow.
* Communicates with the Mastra API via suspend/resume to perform
* local filesystem operations and interactive prompts.
* Streams progress from the init API and performs any requested
* local filesystem operations or interactive prompts on the CLI side.
*
* Supports two optional positionals with smart disambiguation:
* sentry init — auto-detect everything, dir = cwd
Expand Down Expand Up @@ -262,9 +262,9 @@ export const initCommand = buildCommand<
await resolveTarget(targetArg);

// 5. Start background org detection when org is not yet known.
// The prefetch runs concurrently with the preamble, the wizard startup,
// and all early suspend/resume rounds — by the time the wizard needs the
// org (inside createSentryProject), the result is already cached.
// The prefetch runs concurrently with the preamble, wizard startup,
// and early streamed action round-trips — by the time the wizard needs
// the org (inside createSentryProject), the result is already cached.
if (!explicitOrg) {
warmOrgDetection(targetDir);
}
Expand Down
14 changes: 5 additions & 9 deletions src/lib/init/constants.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
export const MASTRA_API_URL =
process.env.MASTRA_API_URL ??
"https://sentry-init-agent.getsentry.workers.dev";

export const WORKFLOW_ID = "sentry-wizard";
export const INIT_API_URL =
process.env.INIT_API_URL ?? "https://sentry-init-agent.getsentry.workers.dev";

export const SENTRY_DOCS_URL = "https://docs.sentry.io/platforms/";

export const MAX_FILE_BYTES = 262_144; // 256KB per file
export const MAX_OUTPUT_BYTES = 65_536; // 64KB stdout/stderr truncation
export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; // 2 minutes
export const API_TIMEOUT_MS = 120_000; // 2 minutes timeout for Mastra API calls
export const API_TIMEOUT_MS = 120_000; // 2 minutes timeout for init API calls
export const STREAM_CONNECT_TIMEOUT_MS = 30_000; // 30 seconds to establish/re-establish the stream
export const MAX_STREAM_RECONNECTS = 8;

// Exit codes returned by the remote workflow
export const EXIT_PLATFORM_NOT_DETECTED = 20;
export const EXIT_DEPENDENCY_INSTALL_FAILED = 30;
export const EXIT_VERIFICATION_FAILED = 50;

// Step ID used in dry-run special-case logic
export const VERIFY_CHANGES_STEP = "verify-changes";

// The feature that is always included in every setup
export const REQUIRED_FEATURE = "errorMonitoring";
18 changes: 8 additions & 10 deletions src/lib/init/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
EXIT_PLATFORM_NOT_DETECTED,
EXIT_VERIFICATION_FAILED,
} from "./constants.js";
import type { WizardOutput, WorkflowRunResult } from "./types.js";
import type { InitErrorEvent, WizardOutput } from "./types.js";

type ChangedFile = NonNullable<WizardOutput["changedFiles"]>[number];

Expand Down Expand Up @@ -160,8 +160,7 @@ function buildSummary(output: WizardOutput): string {
return sections.join("\n\n");
}

export function formatResult(result: WorkflowRunResult): void {
const output: WizardOutput = result.result ?? {};
export function formatResult(output: WizardOutput): void {
const md = buildSummary(output);

if (md.length > 0) {
Expand All @@ -182,11 +181,10 @@ export function formatResult(result: WorkflowRunResult): void {
outro("Sentry SDK installed successfully!");
}

export function formatError(result: WorkflowRunResult): void {
const inner = result.result;
const message =
result.error ?? inner?.message ?? "Wizard failed with an unknown error";
const exitCode = inner?.exitCode ?? 1;
export function formatError(error: InitErrorEvent): void {
const output = error.output;
const message = error.message || "Wizard failed with an unknown error";
const exitCode = error.exitCode ?? output?.exitCode ?? 1;

log.error(String(message));

Expand All @@ -195,7 +193,7 @@ export function formatError(result: WorkflowRunResult): void {
"Hint: Could not detect your project's platform. Check that the directory contains a valid project."
);
} else if (exitCode === EXIT_DEPENDENCY_INSTALL_FAILED) {
const commands = inner?.commands;
const commands = error.commands ?? output?.commands;
if (commands?.length) {
log.warn(
`You can install dependencies manually:\n${commands.map((cmd) => ` $ ${cmd}`).join("\n")}`
Expand All @@ -205,7 +203,7 @@ export function formatError(result: WorkflowRunResult): void {
log.warn("Hint: Fix the verification issues and run 'sentry init' again.");
}

const docsUrl = inner?.docsUrl;
const docsUrl = error.docsUrl ?? output?.docsUrl;
if (docsUrl) {
log.info(`Docs: ${terminalLink(docsUrl)}`);
}
Expand Down
260 changes: 260 additions & 0 deletions src/lib/init/transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { getTraceData } from "@sentry/node-core/light";
import {
API_TIMEOUT_MS,
INIT_API_URL,
STREAM_CONNECT_TIMEOUT_MS,
} from "./constants.js";
import type { InitActionResumeBody, InitEvent, InitStartInput } from "./types.js";

type InitTransportOptions = {
baseUrl?: string;
fetchImpl?: typeof fetch;
requestTimeoutMs?: number;
streamConnectTimeoutMs?: number;
};

export type InitStreamConnection = {
response: Response;
runId?: string;
};

type FetchHeaders = Record<string, string>;

const RUN_ID_HEADERS = [
"x-workflow-run-id",
"x-vercel-workflow-run-id",
"x-init-run-id",
] as const;

function buildApiUrl(baseUrl: string, pathname: string): string {
return new URL(pathname, baseUrl).toString();
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function isWizardOutput(value: unknown): boolean {
return isRecord(value);
}

function assertInitEvent(raw: unknown): InitEvent {
if (!isRecord(raw) || typeof raw.type !== "string") {
throw new Error("Invalid init event");
}

switch (raw.type) {
case "status":
if (typeof raw.message !== "string") {
throw new Error("Invalid status event");
}
return raw as InitEvent;
case "action_request":
if (
typeof raw.actionId !== "string" ||
(raw.kind !== "tool" && raw.kind !== "prompt") ||
typeof raw.name !== "string"
) {
throw new Error("Invalid action_request event");
}
return raw as InitEvent;
case "action_result":
if (typeof raw.actionId !== "string" || typeof raw.ok !== "boolean") {
throw new Error("Invalid action_result event");
}
return raw as InitEvent;
case "warning":
if (typeof raw.message !== "string") {
throw new Error("Invalid warning event");
}
return raw as InitEvent;
case "summary":
if (!isWizardOutput(raw.output)) {
throw new Error("Invalid summary event");
}
return raw as InitEvent;
case "error":
if (typeof raw.message !== "string") {
throw new Error("Invalid error event");
}
return raw as InitEvent;
case "done":
if (typeof raw.ok !== "boolean") {
throw new Error("Invalid done event");
}
return raw as InitEvent;
default:
throw new Error(`Unknown init event type: ${String(raw.type)}`);
}
}

function readRunId(response: Response): string | undefined {
for (const headerName of RUN_ID_HEADERS) {
const runId = response.headers.get(headerName)?.trim();
if (runId) {
return runId;
}
}
return;
}

function createFetchHeaders(contentType = false): FetchHeaders {
const traceData = getTraceData();

return {
...(contentType ? { "content-type": "application/json" } : {}),
...(traceData["sentry-trace"] && {
"sentry-trace": traceData["sentry-trace"],
}),
...(traceData.baggage && { baggage: traceData.baggage }),
};
}

async function fetchWithTimeout(
fetchImpl: typeof fetch,
input: string | URL | Request,
init: RequestInit,
ms: number,
label: string
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(label), ms);

try {
return await fetchImpl(input, { ...init, signal: controller.signal });
} catch (error) {
if ((error as Error).name === "AbortError") {
throw new Error(`${label} timed out after ${ms / 1000}s`);
}
throw error;
} finally {
clearTimeout(timer);
}
}

async function throwIfNotOk(response: Response, label: string): Promise<void> {
if (response.ok) {
return;
}

const text = await response.text();
throw new Error(
`${label} failed (${response.status}): ${text || response.statusText}`
);
}

export async function startInitStream(
input: InitStartInput,
options: InitTransportOptions = {}
): Promise<InitStreamConnection> {
const baseUrl = options.baseUrl ?? INIT_API_URL;
const fetchImpl = options.fetchImpl ?? fetch;
const response = await fetchWithTimeout(
fetchImpl,
buildApiUrl(baseUrl, "/api/init"),
{
method: "POST",
headers: createFetchHeaders(true),
body: JSON.stringify(input),
},
options.requestTimeoutMs ?? API_TIMEOUT_MS,
"Init start"
);

await throwIfNotOk(response, "Init start");
return { response, runId: readRunId(response) };
}

export async function reconnectInitStream(
runId: string,
startIndex: number,
options: InitTransportOptions = {}
): Promise<Response> {
const baseUrl = options.baseUrl ?? INIT_API_URL;
const fetchImpl = options.fetchImpl ?? fetch;
const response = await fetchWithTimeout(
fetchImpl,
buildApiUrl(
baseUrl,
`/api/init/${encodeURIComponent(runId)}/stream?startIndex=${startIndex}`
),
{
method: "GET",
headers: createFetchHeaders(),
},
options.streamConnectTimeoutMs ?? STREAM_CONNECT_TIMEOUT_MS,
"Init stream connection"
);

await throwIfNotOk(response, "Init stream");
return response;
}

export async function resumeInitAction(
actionId: string,
body: InitActionResumeBody,
options: InitTransportOptions = {}
): Promise<void> {
const baseUrl = options.baseUrl ?? INIT_API_URL;
const fetchImpl = options.fetchImpl ?? fetch;
const response = await fetchWithTimeout(
fetchImpl,
buildApiUrl(baseUrl, `/api/init/actions/${encodeURIComponent(actionId)}`),
{
method: "POST",
headers: createFetchHeaders(true),
body: JSON.stringify(body),
},
options.requestTimeoutMs ?? API_TIMEOUT_MS,
`Resume action ${actionId}`
);

await throwIfNotOk(response, `Resume action ${actionId}`);
}

export async function readNdjsonStream(
response: Response,
onEvent: (event: InitEvent) => Promise<void>
): Promise<number> {
if (!response.body) {
throw new Error("Init stream response had no body");
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventCount = 0;

try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}

buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");

while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
await onEvent(assertInitEvent(JSON.parse(line)));
eventCount += 1;
}
newlineIndex = buffer.indexOf("\n");
}
}

buffer += decoder.decode();
const trailing = buffer.trim();
if (trailing) {
await onEvent(assertInitEvent(JSON.parse(trailing)));
eventCount += 1;
}
} finally {
reader.releaseLock();
}

return eventCount;
}
Loading
Loading