arkynChangelogGuides
arkyn
docs / guides / how-to-handle-api-responses-and-logging

Handle API responses and logging

@arkyn/server standardizes API responses around one unusual idea: both success and error responses are thrown, and a single errorHandler call converts whatever was thrown into the right Response. Once that pattern clicks, the rest of the package — outbound requests, logging, and debugging — builds on top of it consistently.

The throw-everything pattern

Every response class (Success, Created, Updated, NoContent, Found for success; BadRequest, NotFound, Conflict, UnprocessableEntity, and friends for errors) can be thrown from anywhere inside a route handler and caught once:

typescript

import { errorHandler } from "@arkyn/server/errorHandler";
import { Created } from "@arkyn/server/created";
import { NotFound } from "@arkyn/server/notFound";
import { Conflict } from "@arkyn/server/conflict";
export async function action({ request }: Route.ActionArgs) {
try {
const input = await request.json();
const existing = await findCustomerByEmail(input.email);
if (existing) throw new Conflict("A customer with this email already exists");
const customer = await createCustomer(input);
if (!customer) throw new NotFound("Customer could not be created");
throw new Created("Customer created successfully!", customer);
} catch (error) {
return errorHandler(error);
}
}
Why throw a success?
It looks unusual at first, but it means every exit point of the function — success or failure — funnels through the same catch block, so errorHandler is the single place that turns a result into an HTTP Response. You don't need a separate return response.toResponse() at every branch.
For unexpected errors that aren't one of Arkyn's response classes, errorHandler wraps them in a generic 500 ServerError, keeping the original error as cause for logging — you don't need a fallback branch of your own.
If you'd rather wrap these calls behind your own adapter (so use cases don't import a dozen response classes directly), see the pattern in the DebugService reference — just make sure to read the next section first, since wrapping these classes changes what shows up in their debug logs.

Every thrown response logs its own caller

When any BadResponse or success response class is instantiated, it automatically emits a debug console log naming the file and function that threw it — powered by DebugService's stack-trace inspection. This is free, but it has one sharp edge:
Adapters hide the real caller
If you wrap throw new Created(...) inside your own adapter function (as suggested above), the automatic log will point at the adapter file, not your actual business logic. Register the adapter file with DebugService.setIgnoreFile("httpAdapter.ts") once at startup, and the log correctly attributes the throw to whatever called the adapter instead.

typescript

import { DebugService } from "@arkyn/server/debugService";
// Once, near your adapter's definition
DebugService.setIgnoreFile("httpAdapter.ts");
See the DebugService reference for a full adapter example and the exact before/after log output.

Outbound requests with ApiService

For calls out to other services, configure one ApiService instance per base URL instead of scattering raw fetch calls:

typescript

import { ApiService } from "@arkyn/server/apiService";
const api = new ApiService({
baseUrl: "https://api.example.com",
baseToken: env.EXTERNAL_API_TOKEN,
enableDebug: process.env.NODE_ENV === "development",
});
const user = await api.get("/users/:id", { urlParams: { id: "42" } });
Prefer :param route placeholders (urlParams) over building the path with string interpolation yourself — beyond keeping the call readable, it's what lets LogService (below) group /users/123 and /users/456 under one aggregated /users/:userId log entry instead of treating every id as a distinct endpoint.
If the service you're calling isn't one of your own Arkyn-shaped endpoints — a third-party API with its own error format — use decodeRequestErrorMessage(data, response) to normalize whatever shape it returns ({ message }, { error }, { error: { message } }, or just response.statusText) into a single string, instead of writing that fallback chain yourself each time.

Centralized logging with LogService

LogService is a one-time, app-wide singleton — configure it once near your entry point, before any request handling happens:

typescript

import { LogService } from "@arkyn/server/logService";
LogService.setConfig({
trafficSourceId: env.ARKYN_TRAFFIC_SOURCE_ID,
userToken: env.ARKYN_LOG_TOKEN,
});
Silently skipped in development
Logs are not sent when NODE_ENV is "development", even with a valid config — don't spend time debugging "why don't my logs show up" locally; deploy to a non-development environment to see them land.
setConfig silently ignores every call after the first, so it's safe to import the configuration module multiple times without worrying about accidental resets — call resetConfig() explicitly if you genuinely need to reconfigure (typically only between test cases).

Redacting sensitive data before it's logged

Neither ApiService's debug output nor anything you log manually filters sensitive fields automatically — that's @arkyn/shared's job, not @arkyn/server's. Run request/response bodies through parseSensitiveData and parseLargeFields before writing them anywhere persistent:

typescript

import { parseSensitiveData } from "@arkyn/shared/parseSensitiveData";
import { parseLargeFields } from "@arkyn/shared/parseLargeFields";
function safeLog(label: string, body: unknown) {
const masked = parseSensitiveData(JSON.stringify(body));
const trimmed = parseLargeFields(masked, 500);
console.log(label, trimmed);
}
safeLog("Incoming payload", { email: "user@example.com", password: "hunter2" });
// Incoming payload {"email":"user@example.com","password":"****"}
parseSensitiveData's default key list (password, confirmPassword, creditCard) is a starting point, not a complete one — pass your own sensitiveKeys array covering anything your domain considers sensitive (tokens, national ID numbers, full card numbers) before this helper is safe to rely on for a given payload.

Putting it together

A typical request lifecycle, end to end:
  1. ApiService (with enableDebug gated by environment) makes an outbound call, using :param route templating.
  2. Your route handler validates input and throws one of @arkyn/server's response classes — success or failure — from wherever the outcome is decided.
  3. errorHandler catches it once and returns the right Response.
  4. DebugService's automatic caller log (with setIgnoreFile applied to any adapters) tells you exactly where each response was thrown from.
  5. Anything you persist beyond the console goes through LogService, after parseSensitiveData/parseLargeFields have redacted and trimmed it.

Notes

  • See Build forms with server-side validation for how UnprocessableEntity specifically carries fieldErrors back to the client — this guide covers the response/logging pattern in general, that one covers the form-validation contract in detail.
  • SchemaValidator/formParse produce the same UnprocessableEntity shape either by throwing (formValidate) or by returning a discriminated result (formParse) — pick based on whether you're already inside a try/catch for other reasons.
  • getScopedParams and decodeRequestBody are smaller utilities in the same package worth knowing about if you're parsing query strings or request bodies manually — see the server utilities for the full list.
On this page