diff --git a/src/cli/telemetry/error-reporter.ts b/src/cli/telemetry/error-reporter.ts index 174135ad..7cd75090 100644 --- a/src/cli/telemetry/error-reporter.ts +++ b/src/cli/telemetry/error-reporter.ts @@ -1,7 +1,7 @@ import { release, type } from "node:os"; import { determineAgent } from "@vercel/detect-agent"; import { nanoid } from "nanoid"; -import { isCLIError, isUserError } from "@/core/errors.js"; +import { ApiError, isCLIError, isUserError } from "@/core/errors.js"; import packageJson from "../../../package.json"; import { getPostHogClient, isTelemetryEnabled } from "./posthog.js"; @@ -20,10 +20,6 @@ export interface ErrorContext { options: Record; }; appId?: string; - api?: { - statusCode?: number; - errorBody?: unknown; - }; } export class ErrorReporter { @@ -68,12 +64,24 @@ export class ErrorReporter { } private buildProperties(error?: Error): Record { - const { user, command, appId, api } = this.context; + const { user, command, appId } = this.context; // Extract CLIError-specific properties if applicable const errorCode = error && isCLIError(error) ? error.code : undefined; const userError = error ? isUserError(error) : undefined; + // Extract API request/response data from ApiError instances + const apiProps = + error instanceof ApiError + ? { + api_status_code: error.statusCode, + api_request_url: error.requestUrl, + api_request_method: error.requestMethod, + api_request_body: error.requestBody, + api_response_body: error.responseBody, + } + : {}; + return { // Session session_id: this.sessionId, @@ -97,9 +105,8 @@ export class ErrorReporter { is_user_error: userError, }), - // API error - api_status_code: api?.statusCode, - api_error_body: api?.errorBody, + // API error (auto-extracted from ApiError) + ...apiProps, // System cli_version: packageJson.version, diff --git a/src/core/clients/base44-client.ts b/src/core/clients/base44-client.ts index d945cff4..2b09aa55 100644 --- a/src/core/clients/base44-client.ts +++ b/src/core/clients/base44-client.ts @@ -16,6 +16,27 @@ import { getAppConfig } from "@/core/project/index.js"; // Track requests that have already been retried to prevent infinite loops const retriedRequests = new WeakSet(); +/** + * Captures request body for error reporting. Clones the request and reads the + * clone's body so the original is not consumed. Body is stored in options.context.__requestBody + * so it is available on HTTPError.options when ApiError.fromHttpError runs (for telemetry). + */ +async function captureRequestBody( + request: KyRequest, + options: NormalizedOptions +): Promise { + if (request.body == null) { + return; + } + try { + const cloned = request.clone(); + const text = await cloned.text(); + options.context.__requestBody = text; + } catch { + // Ignore capture failures; request will still succeed + } +} + /** * Handles 401 responses by refreshing the token and retrying the request. * Only retries once per request to prevent infinite loops. @@ -62,6 +83,7 @@ export const base44Client = ky.create({ }, hooks: { beforeRequest: [ + captureRequestBody, async (request) => { try { const auth = await readAuth(); diff --git a/src/core/clients/schemas.ts b/src/core/clients/schemas.ts index 505f7ba5..a39a1cba 100644 --- a/src/core/clients/schemas.ts +++ b/src/core/clients/schemas.ts @@ -12,8 +12,10 @@ export const ApiErrorResponseSchema = z.object({ z.record(z.string(), z.unknown()), z.array(z.unknown()), ]) + .nullable() .optional(), - traceback: z.string().optional(), + traceback: z.string().nullable().optional(), + extra_data: z.string().optional().nullable(), }); export type ApiErrorResponse = z.infer; diff --git a/src/core/errors.ts b/src/core/errors.ts index 5c63711d..f29470ef 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -245,30 +245,43 @@ export class InvalidInputError extends UserError { // System Errors // ============================================================================ +export interface ApiErrorOptions extends CLIErrorOptions { + statusCode?: number; + requestUrl?: string; + requestMethod?: string; + requestBody?: unknown; + responseBody?: unknown; +} + /** * Thrown when an API request fails. */ export class ApiError extends SystemError { readonly code = "API_ERROR"; readonly statusCode?: number; + readonly requestUrl?: string; + readonly requestMethod?: string; + readonly requestBody?: unknown; + readonly responseBody?: unknown; - constructor( - message: string, - options?: CLIErrorOptions & { statusCode?: number } - ) { + constructor(message: string, options?: ApiErrorOptions) { const hints = options?.hints ?? ApiError.getDefaultHints(options?.statusCode); super(message, { hints, cause: options?.cause }); this.statusCode = options?.statusCode; + this.requestUrl = options?.requestUrl; + this.requestMethod = options?.requestMethod; + this.requestBody = options?.requestBody; + this.responseBody = options?.responseBody; } /** * Creates an ApiError from a caught error (typically HTTPError from ky). - * Extracts status code and formats the error message from the response body. + * Extracts status code, request info, and response body for error reporting. * * @param error - The caught error (HTTPError, Error, or unknown) * @param context - Description of what operation failed (e.g., "syncing agents") - * @returns ApiError with formatted message and status code (if available) + * @returns ApiError with formatted message, status code, and request/response data * * @example * try { @@ -283,15 +296,22 @@ export class ApiError extends SystemError { ): Promise { if (error instanceof HTTPError) { let message: string; + let responseBody: unknown; try { - const body: unknown = await error.response.clone().json(); - message = formatApiError(body); + responseBody = await error.response.clone().json(); + message = formatApiError(responseBody); } catch { message = error.message; } + const requestBody = error.options.context?.__requestBody; + return new ApiError(`Error ${context}: ${message}`, { statusCode: error.response.status, + requestUrl: error.request.url, + requestMethod: error.request.method, + requestBody, + responseBody, cause: error, }); } diff --git a/tests/core/errors.spec.ts b/tests/core/errors.spec.ts index e84f884f..39373bfc 100644 --- a/tests/core/errors.spec.ts +++ b/tests/core/errors.spec.ts @@ -152,6 +152,112 @@ describe("SystemError subclasses", () => { ); }); + it("ApiError stores request and response data", () => { + const responseBody = { error: "Bad Request", detail: "Invalid field" }; + const requestBody = '{"name":"test"}'; + const error = new ApiError("API failed", { + statusCode: 400, + requestUrl: "https://api.base44.com/v1/entities", + requestMethod: "POST", + requestBody, + responseBody, + }); + + expect(error.statusCode).toBe(400); + expect(error.requestUrl).toBe("https://api.base44.com/v1/entities"); + expect(error.requestMethod).toBe("POST"); + expect(error.requestBody).toBe(requestBody); + expect(error.responseBody).toEqual(responseBody); + }); + + it("ApiError has undefined request/response fields when not provided", () => { + const error = new ApiError("API failed", { statusCode: 500 }); + + expect(error.statusCode).toBe(500); + expect(error.requestUrl).toBeUndefined(); + expect(error.requestMethod).toBeUndefined(); + expect(error.requestBody).toBeUndefined(); + expect(error.responseBody).toBeUndefined(); + }); + + it("ApiError.fromHttpError extracts request and response data from HTTPError", async () => { + const { HTTPError } = await import("ky"); + const responseBody = { message: "Not Found" }; + const response = new Response(JSON.stringify(responseBody), { + status: 404, + statusText: "Not Found", + }); + const request = new Request("https://api.base44.com/v1/apps/123", { + method: "GET", + }); + + const httpError = new HTTPError(response, request, {} as never); + const apiError = await ApiError.fromHttpError(httpError, "fetching app"); + + expect(apiError.statusCode).toBe(404); + expect(apiError.requestUrl).toBe("https://api.base44.com/v1/apps/123"); + expect(apiError.requestMethod).toBe("GET"); + expect(apiError.requestBody).toBeUndefined(); + expect(apiError.responseBody).toEqual(responseBody); + expect(apiError.message).toContain("fetching app"); + expect(apiError.message).toContain("Not Found"); + }); + + it("ApiError.fromHttpError includes request body from options.context.__requestBody when present", async () => { + const { HTTPError } = await import("ky"); + const responseBody = { message: "Bad Request" }; + const response = new Response(JSON.stringify(responseBody), { + status: 400, + statusText: "Bad Request", + }); + const request = new Request("https://api.base44.com/v1/entities", { + method: "POST", + }); + const options = { + context: { __requestBody: '{"entities":[]}' }, + } as never; + + const httpError = new HTTPError(response, request, options); + const apiError = await ApiError.fromHttpError( + httpError, + "pushing entities" + ); + + expect(apiError.requestBody).toBe('{"entities":[]}'); + }); + + it("ApiError.fromHttpError handles non-JSON response body", async () => { + const { HTTPError } = await import("ky"); + const response = new Response("Internal Server Error", { + status: 500, + statusText: "Internal Server Error", + }); + const request = new Request("https://api.base44.com/v1/deploy", { + method: "POST", + }); + + const httpError = new HTTPError(response, request, {} as never); + const apiError = await ApiError.fromHttpError(httpError, "deploying"); + + expect(apiError.statusCode).toBe(500); + expect(apiError.requestUrl).toBe("https://api.base44.com/v1/deploy"); + expect(apiError.requestMethod).toBe("POST"); + expect(apiError.responseBody).toBeUndefined(); + }); + + it("ApiError.fromHttpError handles plain Error", async () => { + const error = new Error("Network timeout"); + const apiError = await ApiError.fromHttpError(error, "connecting"); + + expect(apiError.statusCode).toBeUndefined(); + expect(apiError.requestUrl).toBeUndefined(); + expect(apiError.requestMethod).toBeUndefined(); + expect(apiError.requestBody).toBeUndefined(); + expect(apiError.responseBody).toBeUndefined(); + expect(apiError.message).toContain("connecting"); + expect(apiError.message).toContain("Network timeout"); + }); + it("FileNotFoundError has correct defaults", () => { const error = new FileNotFoundError("File not found: /path/to/file"); expect(error.code).toBe("FILE_NOT_FOUND");