Skip to content

Errors & Logging ​

All exceptions are handled in one place. Whatever a route, middleware or controller throws is caught at the edge of the request, reported by the exception handler and rendered in the same response format as successful results.

Throwing HTTP exceptions ​

Throw an exception to stop handling and return an error:

ts
// app/service/UserService.ts
import { ForbiddenException, inject, NotFoundException } from '@hazejs/core'
import { UserRepository } from '~/app/repository/UserRepository'

export class UserService {
  private repo = inject(UserRepository)

  async find(id: number) {
    const user = await this.repo.findById(id)
    if (!user)
      throw new NotFoundException(`User ${id} not found`)
    if (user.banned)
      throw new ForbiddenException('This account is suspended')
    return user
  }
}
json
{ "code": 404, "message": "User 9 not found", "data": null }
ClassStatusDefault message
BadRequestException400Bad Request
UnauthorizedException401Unauthorized
ForbiddenException403Forbidden
NotFoundException404Not Found
MethodNotAllowedException405Method Not Allowed
ConflictException409Conflict
CsrfTokenMismatchException419Page Expired
ValidationException422Validation Failed
TooManyRequestsException429Too Many Requests
InternalServerErrorException500Internal Server Error
BusinessException200(required)

All of them extend HttpException, which in turn extends h3's HTTPError. The second argument takes options:

ts
import { ConflictException, HttpException, TooManyRequestsException } from '@hazejs/core'

throw new ConflictException('Email already registered', {
  code: 40901, // business code in the body (defaults to the HTTP status)
  data: { field: 'email' }, // goes into `data`
})

throw new TooManyRequestsException('Slow down', { headers: { 'retry-after': '60' } })

throw new HttpException('Payment required', { status: 402, cause: error })

Business exceptions ​

BusinessException is for business-rule failures that clients should treat as a normal response with an error code. It responds with HTTP 200 by default:

ts
import { BusinessException } from '@hazejs/core'

throw new BusinessException(10001, 'Insufficient balance')
// HTTP 200: { "code": 10001, "message": "Insufficient balance", "data": null }

throw new BusinessException(10002, 'Order closed', { status: 409 })

Validation exceptions ​

ValidationException carries field-level issues, which are rendered as errors. Validation via validate() or .validate() throws it for you. You can also throw it yourself:

ts
import { ValidationException } from '@hazejs/core'

throw new ValidationException([{ path: 'email', message: 'Already taken' }])

See Requests & Validation for the response format.

Unexpected errors ​

Anything that is not an HTTPError, such as a TypeError or a database error, becomes a 500. Outside debug mode, the message is replaced with Internal Server Error so internals don't leak.

The exception handler ​

app/exception/Handler.ts customizes how errors are reported and rendered. Extend ExceptionHandler:

ts
// app/exception/Handler.ts
import type { HttpContext } from '@hazejs/core'
import { BusinessException, ExceptionHandler } from '@hazejs/core'
import { PaymentGatewayError } from '~/app/service/payment'

export default class Handler extends ExceptionHandler {
  // Never report these
  protected override dontReport = [PaymentGatewayError]

  override async report(error: unknown, ctx: HttpContext) {
    await super.report(error, ctx) // logs with ctx.logger (includes requestId)
    // if (this.shouldReport(error)) Sentry.captureException(error)
  }

  override render(error: unknown, ctx: HttpContext) {
    if (error instanceof BusinessException)
      ctx.setHeader('x-business-code', String(error.code))
    return super.render(error, ctx)
  }
}
MemberDescription
report(error, ctx)Records the error. By default, logs [METHOD] /path and the error at error level
shouldReport(error)false for dontReport types and any 4xx HTTPError. Client errors are not reported
dontReportException classes that are never reported
render(error, ctx)Sets status and headers, then returns the body: the debug page or Formatter.error()
normalize(error, ctx) (protected)Turns any error into a NormalizedError
format(error, ctx) (protected)Builds the body. Defaults to ResponseFormatter.error()

render() delegates the body shape to the response formatter, so success and error responses share one format. To change the shape of every error, override error() in app/response/Formatter.ts (see Responses) rather than render().

If report() itself throws, the failure is logged and the response is still rendered.

Debug mode ​

app.debug is on in development and off in production and tests. Unless you have a reason to, don't enable it in production. When it is on:

  • Error JSON includes a stack array, and the messages of unexpected errors are shown.
  • Browser requests (Accept: text/html) get an error page with the exception, a source snippet mapped back to your TypeScript, the stack, validation issues and request details. Sensitive headers are masked. For a 404, the page lists all routes.

API clients always get JSON, and the error page is never rendered when debug is off.

ts
// config/app.ts
import { defineConfig, env } from '@hazejs/core'

export default defineConfig(() => ({
  name: env('APP_NAME', 'my-app'),
  // debug: env('APP_DEBUG', false), // force it on or off
}))

Logging ​

Use ctx.logger inside a request. It is a child logger that already carries the request ID (and the app name with multiple apps):

ts
// app/controller/OrderController.ts
import type { HttpContext } from '@hazejs/core'

export class OrderController {
  async store(ctx: HttpContext) {
    ctx.logger.info('Creating order', { userId: 1 })
    ctx.logger.debug('Payload', await ctx.body())
    return ctx.created({ id: 1 })
  }
}

Outside a request, inject the application logger or use app.logger:

ts
// app/service/ReportService.ts
import { inject, Logger } from '@hazejs/core'

export class ReportService {
  private logger = inject(Logger).child({ service: 'report' })

  run() {
    this.logger.success('Report generated')
  }
}

Methods: error, warn, info, success, debug, trace, plus child(context) to add fields to every line.

Configuration ​

Logger settings live in config/logger.ts:

ts
// config/logger.ts
import { defineConfig, env } from '@hazejs/core'

export default defineConfig(() => ({
  level: env('LOG_LEVEL', 'info'),
  // format: 'json',
  // requests: false,
}))
OptionDescriptionDefault
levelsilent, error, warn, info, debug, traceinfo (warn in tests)
formatpretty (colored) or json (one JSON object per line)json in production, otherwise pretty
requestsAccess log line per requeston, except in tests
tagTag shown in outputhazejs
writeCustom sink for JSON lines, (line, level) => voiderror to stderr, the rest to stdout
channelChannel name(s) to write to, see Channels'console'
channelsChannel definitionsconsole and json are built in

In development the output is colored, with access lines such as GET /api/hello 200 3ms. In production, each line is JSON with time, level, tag, msg, requestId, any context fields, and err (name, message, stack) when an error is logged. This works well with log collectors. When OpenTelemetry is enabled and a span is active, JSON lines also carry trace_id and span_id.

Channels ​

Like Laravel's logging channels, a log line can go to several destinations at once. Everything above describes the default console channel. Define more channels and pick them with channel:

ts
// config/logger.ts
import { defineConfig, env } from '@hazejs/core'

export default defineConfig(() => ({
  level: env('LOG_LEVEL', 'info'),
  channel: 'production',
  channels: {
    production: { driver: 'stack', channels: ['console', 'file', 'loki'] },
    file: { driver: 'file', path: 'storage/logs/app.log', daily: true, maxFiles: 14 },
    loki: {
      driver: 'http',
      url: env('LOKI_URL', 'http://localhost:3100/loki/api/v1/push'),
      format: 'loki',
      labels: { app: 'shop' },
      level: 'warn',
    },
    audit: { driver: 'file', path: 'storage/logs/audit.log', daily: false },
  },
}))
DriverWritesOptions
consoleConsole output, pretty or json according to format (the default channel)level
jsonOne JSON object per line to stdout, whatever format sayslevel, stream: 'stdout' | 'stderr'
fileJSON lines to a file (Node, Bun and Deno only)path (default storage/logs/haze.log), daily (default true: haze-2026-10-28.log), maxFiles (default 14, 0 keeps everything), level
httpBatched POST to a log platformurl, headers, format (json array, ndjson, loki, or (entries) => body), batchSize (100), interval (2000 ms), maxBuffer (10000), labels (Loki), level
customYour own transporttransport: { log(entry), flush?(), close?() } or (entry) => void, level
stackSeveral channelschannels: ['console', 'file']
  • Each channel can have its own level. The logger's level is the default for channels without one.
  • Access logs (requests: true) go to every channel too.
  • Write to specific channels for one call with logger.channel('audit').info('Refund approved', { orderId }). Context added with child() is kept.
  • The http driver sends a batch as soon as batchSize entries are buffered, or after interval ms. If the buffer passes maxBuffer (for example while the platform is down), the oldest entries are dropped. Delivery errors are printed to stderr and never thrown. await logger.flush() sends what is buffered, and the application flushes and closes all channels when it shuts down.

Shipping to common platforms:

ts
channels: {
  // Datadog: JSON array to the HTTP intake
  datadog: { driver: 'http', url: 'https://http-intake.logs.datadoghq.eu/api/v2/logs', headers: { 'DD-API-KEY': env('DD_API_KEY') } },
  // Better Stack
  betterstack: { driver: 'http', url: env('BETTERSTACK_INGEST_URL'), headers: { authorization: `Bearer ${env('BETTERSTACK_TOKEN')}` } },
  // Grafana Loki
  loki: { driver: 'http', url: 'http://loki:3100/loki/api/v1/push', format: 'loki' },
}

Request IDs ​

Every request gets an ID, available as ctx.requestId, in every ctx.logger line and in the x-request-id response header.

  • If the incoming request has an x-request-id header with safe characters ([\w.:-], up to 128), that ID is reused, so traces can span services.
  • Otherwise, a UUID is generated.

Change the header name with requestIdHeader in config/app.ts, or set it to false to neither read nor send the header. An ID is still generated for logs.

Released under the MIT License.