Observability
Observability
x includes production-ready observability out of the box: structured JSON logging, container health/readiness probes, request metrics (Prometheus or OpenTelemetry), and pluggable APM error tracing. All of it is optional and configurable via the observability key in x.config.ts.
Configuration overview
import { defineConfig, createSentryReporter } from "@thexjs/core";import * as Sentry from "@sentry/bun";Sentry.init({ dsn: process.env.SENTRY_DSN });export default defineConfig({ // ...pagesDir, apiDir, etc. observability: { logging: true, errorReporter: createSentryReporter(Sentry), health: { checks: { database: () => db.ping(), }, }, },});Structured JSON logging
Every request is logged as one JSON line with timestamp, requestId, route, method, status, and durationMs. This is ready to ingest into Datadog, Grafana Loki, Kibana, or any JSON log pipeline.
{"timestamp":"2026-07-29T12:00:00.000Z","level":"info","message":"request completed","requestId":"abc-123","route":"/api/users","method":"GET","status":200,"durationMs":42}Logging is enabled by default. Disable it with logging: false.
observability: { logging: false,}You can also use the logger export directly in loaders, server functions, and API routes:
import { logger } from "@thexjs/core";export async function loader() { logger.info("fetching users", { userId: 42 }); const users = await getUsers(); logger.info("users fetched", { count: users.length }); return { users };}Health & readiness probes
Two endpoints are served ahead of all routing for container orchestrators:
- /healthz, the liveness probe. Returns { status: "ok" } when the Bun process is up and serving.
- /readyz, the readiness probe. Runs all configured checks and returns 200 only if every check passes, or 503 otherwise.
observability: { health: { checks: { database: async () => { try { await db.query("SELECT 1"); return true; } catch { return false; } }, redis: () => redis.ping(), }, },}Kubernetes/Docker can poll these endpoints to decide whether to send traffic to a pod or restart it. The handler itself is exported as createHealthCheckHandler if you want to wire these endpoints into a custom server instead.
Request metrics
x can record production metrics for every request — counts, latency histograms, and error and rate-limit-rejection counters. Two built-in reporters cover the two standard destinations:
- createInMemoryMetrics() — an in-process registry that serves a /metrics endpoint in Prometheus text format, ready to be scraped by Prometheus/Grafana.
- createOtlpMetricsReporter(meter) — forwards counters and histograms to an OpenTelemetry meter from your own OTel SDK setup (e.g. an OTLP exporter to Grafana Tempo/Cloud, Datadog, or Honeycomb).
import { createInMemoryMetrics } from "@thexjs/core";observability: { metrics: createInMemoryMetrics(),}// GET /metrics// # TYPE x_http_requests_total counter// x_http_requests_total{method="GET",status="200"} 42import { createOtlpMetricsReporter } from "@thexjs/core";import { metrics } from "@opentelemetry/api";observability: { metrics: createOtlpMetricsReporter(metrics.getMeter("x")),}The metrics recorded per request are x_http_requests_total (labels method, status), x_http_request_duration_ms (histogram, label method), x_http_errors_total (per phase), and x_rate_limit_rejections_total (per method).
The same pieces are exported for custom wiring: withRequestMetrics(reporter, handler) wraps any fetch handler, and any object implementing the MetricsReporter interface works — including your own exporter that posts to a statsd/Prometheus push gateway.
APM error tracing
When an uncaught exception occurs during SSR, a server action, or an API handler, x reports it to the configured error reporter. Two reporters are built in:
import { createSentryReporter, createOtelReporter } from "@thexjs/core";// Sentryobservability: { errorReporter: createSentryReporter(Sentry),}// OpenTelemetryobservability: { errorReporter: createOtelReporter(trace.getTracer("x")),}You can also combine multiple reporters, or write your own by implementing the ErrorReporter interface:
import { combineReporters } from "@thexjs/core";const customReporter = { captureException(error, context) { // Send to your own error tracking service fetch("https://errors.example.com", { method: "POST", body: JSON.stringify({ error: String(error), context }), }); },};observability: { errorReporter: combineReporters( createSentryReporter(Sentry), customReporter, ),}If no reporter is configured, errors are logged to the console and the request returns a generic 500. The reporter never blocks the response. If it throws, the error is caught and logged so it can't take down the request.
Low-level APIs
The pieces behind the config are exported directly, so you can swap the wiring for custom logic:
import { setErrorReporter, reportException, combineReporters, createSentryReporter,} from "@thexjs/core";setErrorReporter(combineReporters(createSentryReporter(Sentry), customReporter));try { // ...} catch (error) { reportException(error, { route: "/dashboard", phase: "loader" });}setErrorReporter installs a reporter at runtime, reportException fires it with an ErrorContext, and combineReporters fans an exception out to several reporters at once. Reporters may also implement an optional flush() (used to drain buffered events on graceful shutdown).
What's captured
Every error report includes the phase it occurred in:
interface ErrorContext { route?: string; // e.g. "/dashboard" requestId?: string; // matches the log entry for this request phase: "ssr" | "action" | "api" | "loader";}