From 5c2cdc47036a20d3835b89ee4b442134627c917c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 18:19:55 +0000 Subject: [PATCH 1/5] feat: collect API request/response data in error reporter When an ApiError is thrown, the error reporter now automatically extracts request URL, method, status code, and response body from the error and includes them in telemetry. This removes the unused manual ErrorContext.api field in favor of auto-extraction from ApiError instances in buildProperties. https://claude.ai/code/session_01WsgRcUXSBY5v1TppPoDdwZ --- src/cli/telemetry/error-reporter.ts | 24 +++++---- src/core/errors.ts | 30 ++++++++--- tests/core/errors.spec.ts | 77 +++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 17 deletions(-) diff --git a/src/cli/telemetry/error-reporter.ts b/src/cli/telemetry/error-reporter.ts index 174135ad..4e52b04a 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,23 @@ 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_response_body: error.responseBody, + } + : {}; + return { // Session session_id: this.sessionId, @@ -97,9 +104,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/errors.ts b/src/core/errors.ts index 41a4e438..4bdae554 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -245,30 +245,40 @@ export class InvalidInputError extends UserError { // System Errors // ============================================================================ +export interface ApiErrorOptions extends CLIErrorOptions { + statusCode?: number; + requestUrl?: string; + requestMethod?: string; + 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 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.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 +293,19 @@ 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; } return new ApiError(`Error ${context}: ${message}`, { statusCode: error.response.status, + requestUrl: error.request.url, + requestMethod: error.request.method, + responseBody, cause: error, }); } diff --git a/tests/core/errors.spec.ts b/tests/core/errors.spec.ts index e84f884f..00eb377a 100644 --- a/tests/core/errors.spec.ts +++ b/tests/core/errors.spec.ts @@ -152,6 +152,83 @@ describe("SystemError subclasses", () => { ); }); + it("ApiError stores request and response data", () => { + const responseBody = { error: "Bad Request", detail: "Invalid field" }; + const error = new ApiError("API failed", { + statusCode: 400, + requestUrl: "https://api.base44.com/v1/entities", + requestMethod: "POST", + responseBody, + }); + + expect(error.statusCode).toBe(400); + expect(error.requestUrl).toBe("https://api.base44.com/v1/entities"); + expect(error.requestMethod).toBe("POST"); + 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.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.responseBody).toEqual(responseBody); + expect(apiError.message).toContain("fetching app"); + expect(apiError.message).toContain("Not Found"); + }); + + 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.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"); From 7180a853063b417fe96dc2838465e62162dbd220 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 8 Feb 2026 01:28:23 +0200 Subject: [PATCH 2/5] update error schema --- src/core/clients/schemas.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/clients/schemas.ts b/src/core/clients/schemas.ts index 505f7ba5..f9a748ac 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; From 5db557ba1fe14a7edbd7a1fd391a80e1f1021be9 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 8 Feb 2026 01:33:56 +0200 Subject: [PATCH 3/5] fix lint --- src/core/clients/schemas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/clients/schemas.ts b/src/core/clients/schemas.ts index f9a748ac..a39a1cba 100644 --- a/src/core/clients/schemas.ts +++ b/src/core/clients/schemas.ts @@ -15,7 +15,7 @@ export const ApiErrorResponseSchema = z.object({ .nullable() .optional(), traceback: z.string().nullable().optional(), - extra_data: z.string().optional().nullable() + extra_data: z.string().optional().nullable(), }); export type ApiErrorResponse = z.infer; From 522cba71f73c3c1f939a15c144805c7df5ed54de Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 8 Feb 2026 17:59:28 +0200 Subject: [PATCH 4/5] report also request body --- src/cli/telemetry/error-reporter.ts | 1 + src/core/clients/base44-client.ts | 22 ++++++++++++++++++++++ src/core/errors.ts | 6 ++++++ tests/core/errors.spec.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/src/cli/telemetry/error-reporter.ts b/src/cli/telemetry/error-reporter.ts index 4e52b04a..7cd75090 100644 --- a/src/cli/telemetry/error-reporter.ts +++ b/src/cli/telemetry/error-reporter.ts @@ -77,6 +77,7 @@ export class ErrorReporter { 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, } : {}; 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/errors.ts b/src/core/errors.ts index a1a35037..f29470ef 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -249,6 +249,7 @@ export interface ApiErrorOptions extends CLIErrorOptions { statusCode?: number; requestUrl?: string; requestMethod?: string; + requestBody?: unknown; responseBody?: unknown; } @@ -260,6 +261,7 @@ export class ApiError extends SystemError { readonly statusCode?: number; readonly requestUrl?: string; readonly requestMethod?: string; + readonly requestBody?: unknown; readonly responseBody?: unknown; constructor(message: string, options?: ApiErrorOptions) { @@ -269,6 +271,7 @@ export class ApiError extends SystemError { this.statusCode = options?.statusCode; this.requestUrl = options?.requestUrl; this.requestMethod = options?.requestMethod; + this.requestBody = options?.requestBody; this.responseBody = options?.responseBody; } @@ -301,10 +304,13 @@ export class ApiError extends SystemError { 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 00eb377a..eec935ce 100644 --- a/tests/core/errors.spec.ts +++ b/tests/core/errors.spec.ts @@ -154,16 +154,19 @@ 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); }); @@ -173,6 +176,7 @@ describe("SystemError subclasses", () => { expect(error.statusCode).toBe(500); expect(error.requestUrl).toBeUndefined(); expect(error.requestMethod).toBeUndefined(); + expect(error.requestBody).toBeUndefined(); expect(error.responseBody).toBeUndefined(); }); @@ -193,11 +197,32 @@ describe("SystemError subclasses", () => { 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", { @@ -224,6 +249,7 @@ describe("SystemError subclasses", () => { 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"); From cfae6b78be9df477456657f243515d396656f6e6 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 8 Feb 2026 18:02:51 +0200 Subject: [PATCH 5/5] lint --- tests/core/errors.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/core/errors.spec.ts b/tests/core/errors.spec.ts index eec935ce..39373bfc 100644 --- a/tests/core/errors.spec.ts +++ b/tests/core/errors.spec.ts @@ -218,7 +218,10 @@ describe("SystemError subclasses", () => { } as never; const httpError = new HTTPError(response, request, options); - const apiError = await ApiError.fromHttpError(httpError, "pushing entities"); + const apiError = await ApiError.fromHttpError( + httpError, + "pushing entities" + ); expect(apiError.requestBody).toBe('{"entities":[]}'); });