diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 82459661bec..1e95d321f71 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -22,6 +22,7 @@ import { type OrchestrationErrorCode, } from '@/lib/core/orchestration/types' import type { HttpError } from '@/lib/core/utils/http-error' +import { describeResponseError } from '@/lib/core/utils/response-error' import type { RateLimitResult } from '@/app/api/v1/middleware' /** @@ -217,7 +218,7 @@ export function v2Error( const retryAfterSeconds = options.omitRetryAfter ? undefined : RETRY_AFTER_SECONDS_BY_STATUS[status] - return NextResponse.json( + const response = NextResponse.json( { error }, { status, @@ -229,6 +230,15 @@ export function v2Error( }, } ) + const reason = detailCode(options.details) + describeResponseError(response, { errorCode: code, ...(reason ? { errorReason: reason } : {}) }) + return response +} + +/** The cause a refusal names in `details.code`, a closed vocabulary safe to log. */ +function detailCode(details: unknown): string | undefined { + if (typeof details !== 'object' || details === null || !('code' in details)) return undefined + return typeof details.code === 'string' ? details.code : undefined } /** Renders a trusted typed HTTP error without changing the v2 envelope. */ @@ -267,9 +277,17 @@ export function v2ValidationError(error: ZodError, slice?: RequestSlice): NextRe ? issueMessage.slice(fieldLabel.length) : issueMessage const location = [slice, ...path].filter((part) => part !== undefined && part !== '').join('.') - return v2Error('BAD_REQUEST', location ? `${location}: ${message}` : message, { + const response = v2Error('BAD_REQUEST', location ? `${location}: ${message}` : message, { details: serializeZodIssues(error), }) + // Deeper segments can be record keys the caller wrote (an import's CSV headers); no slice is itself a record. + const field = [slice, path[0]].filter((part) => part !== undefined && part !== '').join('.') + describeResponseError(response, { + errorCode: 'BAD_REQUEST', + ...(field ? { errorField: field } : {}), + ...(error.issues[0] ? { errorReason: error.issues[0].code } : {}), + }) + return response } /** diff --git a/apps/sim/lib/core/utils/response-error.ts b/apps/sim/lib/core/utils/response-error.ts new file mode 100644 index 00000000000..a7445a0e68b --- /dev/null +++ b/apps/sim/lib/core/utils/response-error.ts @@ -0,0 +1,30 @@ +/** + * The machine-readable part of an error response, kept beside the response so + * the route handler's closing log line can be grouped by why a request was + * refused without reading the body. Never the message, which can quote user + * input. + */ +export interface ResponseErrorSummary { + /** The stable error code the body carries, e.g. `BAD_REQUEST`. */ + errorCode: string + /** For a validation failure, the request slice and its declared top-level field, e.g. `body.mapping`. */ + errorField?: string + /** The cause the refusal named (`READ_ONLY_API_KEY`) or the validation issue kind (`invalid_type`). */ + errorReason?: string +} + +/** + * Keyed by the response object, so only a response a route actually returns is + * ever read back. One built only to extract its message is never logged. + */ +const summaries = new WeakMap() + +/** Records what `response` tells its caller; a later call for the same response replaces it. */ +export function describeResponseError(response: Response, summary: ResponseErrorSummary): void { + summaries.set(response, summary) +} + +/** What `describeResponseError` recorded for `response`, if anything. */ +export function readResponseError(response: Response): ResponseErrorSummary | undefined { + return summaries.get(response) +} diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 7a5fc3889c4..7de2af3d220 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -15,6 +15,7 @@ import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-fa import { FILE_DELIVERY_CSP_PATH_PATTERN, getMainCSPPolicy } from '@/lib/core/security/csp' import { HttpError } from '@/lib/core/utils/http-error' import { generateRequestId } from '@/lib/core/utils/request' +import { readResponseError } from '@/lib/core/utils/response-error' import { MAX_CALL_CHAIN_DEPTH, parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { trackInFlightRequest } from '@/lib/monitoring/in-flight-requests' import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' @@ -274,9 +275,9 @@ export function withRouteHandler( const duration = Date.now() - startTime if (status >= 500) { - logger.error('Server error response', { status, duration }) + logger.error('Server error response', { status, duration, ...readResponseError(response) }) } else if (status >= 400) { - logger.warn('Client error response', { status, duration }) + logger.warn('Client error response', { status, duration, ...readResponseError(response) }) } else if (status > 0) { logger.info('OK', { status, duration }) }