Repository navigation
Log tracebacks for client error responses - #985
ramantehlan wants to merge 9 commits into
Conversation
Errors leaving the server as a non-2xx response produced only the one-line access log, so a 400, 422 or 424 could not be traced to the code that raised it. Three paths were silent: the app error handler skipped every HTTPException below 500, 22 route catch-blocks converted a caught error into a 4xx without logging it, and OpenAPI request validation returned its 400 from the defaultHook without ever throwing. Add logRequestError as the single owner of the policy. It logs the method, path, status and error chain with its stack, at error level for 5xx, debug for routine 401/403/404 rejections, and warn for everything else. Call it from the error handler, from the validation hook, and at each catch-and-convert site. Signed-off-by: Raman Tehlan <ramantehlan@gmail.com>
🦋 Changeset detectedLatest commit: 7f33462 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 303a258. Configure here.
…acebacks-for-trueforge-for-422-424-400-errors Signed-off-by: Raman Tehlan <ramantehlan@gmail.com>
Bugbot review on #985 found client errors that still bypassed the new logging. getTurnExecutionError maps turn-start failures to 400/404/422 and returns them directly, so the unknown-model 422 raised while starting a turn - the exact case in the ticket - was still silent on both the turn and schedule-run routes. The Daytona permission 422, the SandboxError download path and both sandbox-secret sync responses were unlogged for the same reason. Call logRequestError at each, passing the mapped status so the level rule still applies. Signed-off-by: Raman Tehlan <ramantehlan@gmail.com>
The 22 catch-and-convert sites each had to remember to log, and four routers had to grow a logger they otherwise had no use for. Instead they now rethrow as HTTPException with the caught error as cause, and the OpenAPI validation hook throws its ZodError rather than answering directly, so createAppErrorHandler is the single place that builds the error envelope and writes the log line. A rethrow captures the boundary, not the throw site, so extractErrorLogFields gains cause_stack: the stack of the deepest cause that carries one. The unknown-model 422 still names sessionResources.ts and a bad page token still names PageToken.ts. Router tests that asserted the JSON envelope now mount through the real error handler, since the envelope is the handler's to produce. Signed-off-by: Raman Tehlan <ramantehlan@gmail.com>
Signed-off-by: Raman Tehlan <ramantehlan@gmail.com>
Signed-off-by: Raman Tehlan <ramantehlan@gmail.com>
Signed-off-by: Raman Tehlan <ramantehlan@gmail.com>
Signed-off-by: Raman Tehlan <ramantehlan@gmail.com>
…for-422-424-400-errors
| @@ -0,0 +1,5 @@ | |||
| --- | |||
There was a problem hiding this comment.
why are there 2 change sets
|
|
||
| async function createRouters(): Promise<{ | ||
| settingsRouter: ReturnType<typeof createSettingsRouter>; | ||
| settingsRouter: ReturnType<typeof mountWithErrorHandler>; |
There was a problem hiding this comment.
why do we need to do this. and why just settings router
|
|
||
| export const zodValidationHook: Hook<unknown, object, string, Response | undefined> = (result, c) => { | ||
| /** Throws so the app error handler owns both the response and its log line. */ | ||
| export const zodValidationHook: Hook<unknown, object, string, Response | undefined> = result => { |
There was a problem hiding this comment.
where is this used exactly?
| export function getDaytonaAuthorizationErrorMessage(error: unknown): string | undefined { | ||
| if (isDaytonaAuthError(error)) { | ||
| return 'Sandbox provider rejected the API key — check the credentials'; | ||
| return SANDBOX_PROVIDER_API_KEY_REJECTED; |
There was a problem hiding this comment.
why are we only replacing some specific errors with constants? this function itself is about getting error messages, and not everything was replaced here.
If its just used here why use constants?
| return; | ||
| } | ||
| if (QUIET_CLIENT_ERROR_STATUSES.has(status)) { | ||
| params.logger.debug(message, fields); |
There was a problem hiding this comment.
@chiragjn what is the expectation here? The issue is about logging tracebacks, I assume we don't want debug logs?
However why do we want to log 400s as errors?
| return c.json({ error: { message: error.message } }, error.status); | ||
| } | ||
| params.logger.error('Unhandled error', extractErrorLogFields(error)); | ||
| logError(500, 'Unhandled error'); |
There was a problem hiding this comment.
why are we not extracting error fields

Closes AGE-2148.
Problem
Errors leaving the server as a non-2xx response produced only the one-line access log (
method,path,status,duration_ms). A 400, 422 or 424 could not be traced back to the code that raised it.The reported case: a saved agent gets an exchanged token with no provider integrations, the model lookup fails, and the request exits non-2xx with nothing in the log but the status.
Three paths were silent:
createAppErrorHandlerlogged only when anHTTPExceptionwas>= 500. Every thrown client error was dropped - 27 thrown 422s, 9 thrown 424s, 9 thrown 400s, plusZodErrorandInvalidCronError. A test even asserted this ('does not error-log a client HTTP exception').c.json({...}, 400|422|424)directly, never reaching the error handler.defaultHookwithout throwing, so it bypassed the handler too. This is the largest source of silent 400s.Change
New
packages/trueforge/src/http/requestErrorLog.tsowns the policy:It logs
{ method, path, status, ...extractErrorLogFields(error) }- reusing the existing helper, which already walkscauseand carriesstack. Level by status:>= 500error401/403/404debugwarnCall sites:
createAppErrorHandleron all four branches (ZodError,InvalidCronError,HTTPException, unhandled).zodValidationHookbecamecreateZodValidationHook(logger)and logsRequest validation failedbefore returning the prettified 400.src/apis/*.ts.loggeris threaded into the four router deps that had none (AgentsRouterDeps,ModelProvidersRouterDeps,WebSearchProvidersRouterDeps,AgentImportRouterDeps).The 403 / 404 / 409 branches in route handlers are deliberately untouched - those are expected control flow with no traceback worth keeping.
Verification
pnpm run typecheckclean, 77 suites / 675 tests pass, eslint and prettier clean on the changed files.Live against a standalone server, the reported case:
Before this change that request logged only
info request {... "status":422 ...}.Also confirmed live:
GET /api/v1/agents?page_token=garbage-> 400 with anInvalidPageTokenErrortraceback throughPageToken.ts(catch-and-convert path).warn Request validation failednamingmanifest.model(validation-hook path).notFound()raises no error.Tests
tests/unit/appErrorHandler.test.tsrewritten; the old case asserted the bug.tests/unit/http/requestErrorLog.test.tscovers the level-selection rule and thecausechain.tests/unit/zodErrorResponse.test.tscovers the validation hook.Note
A
ZodErrorlogs zod's raw JSON issue array as itserrorfield, because that is whatError.messageholds. The field path and reason are both present, just verbose. Prettifying would mean teachingextractErrorLogFieldsabout zod intrueforge-core, which felt out of scope here.Note
Low Risk
Observability and error-path refactors only; HTTP status codes and response bodies stay the same for converted routes, with slightly more warn-level log volume for 4xx.
Overview
Non-2xx API responses now emit structured logs with method, path, status, the error message chain, and a stack so 400/422/424 failures can be traced past the access log alone.
Central error handling:
createAppErrorHandlerlogs every branch (ZodError,InvalidCronError,HTTPException, unhandled) at error for 5xx, warn for most 4xx, and debug for routine 401/403/404. OpenAPI validation’szodValidationHookthrowsZodErrorinstead of returning 400 directly so the same handler builds the envelope and writes the log line.Route handlers: Catch blocks that used to
return c.json({ error }, 4xx)now rethrowHTTPExceptionwith the original error ascause(agents, sessions, turns, schedules, MCP/model/sandbox/web-search providers, import, etc.). Shared client-facing strings live inclientErrorMessages.ts.Core logging:
extractErrorLogFieldsprefers the deepestcausestack when errors are wrapped at API boundaries, so logged tracebacks point at the origin throw site. Unit tests cover cause chains, cycles, and the error handler log levels.Reviewed by Cursor Bugbot for commit 7f33462. Bugbot is set up for automated code reviews on this repo. Configure here.