Deployment
Observability and logging
This page shows you how to instrument a Wheels app so you can tell what it’s doing in production. You’ll enable the request ID middleware, correlate log lines across a request, add a health check your load balancer can poll, activate the Sentry package for error tracking, and decide what metrics and APM you want to layer on top. The framework’s observability surface is narrow by design — everything beyond it is a JVM and an HTTP app you wire into whatever collectors you already run.
You’ll learn:
- How to enable
wheels.middleware.RequestIdand thread the request ID through logs - Where Wheels writes logs by default and how to produce JSON-shaped lines a collector can parse
- A minimal health-check action for
/healthz - How to activate and configure the first-party Sentry package
- Which metrics and APM options are external and where to hook them in
Enable request IDs
Section titled “Enable request IDs”wheels.middleware.RequestId generates one UUID per HTTP request, stores it on request.wheels.requestId, and writes it to the X-Request-Id response header. This is the single thread you pull to correlate a client’s bug report, a load-balancer access log entry, your application log, and a Sentry event — they all reference the same ID.
The middleware is not enabled by default. Add it to the global pipeline:
set(middleware = [ new wheels.middleware.RequestId(), new wheels.middleware.SecurityHeaders()]);The component is eight lines of real code: it calls CreateUUID(), stashes the value on the request scope, runs the rest of the pipeline, then writes the header on the way out. See vendor/wheels/middleware/RequestId.cfc line 12 (local.requestId = CreateUUID();) and line 20 (cfheader(name = "X-Request-Id", value = local.requestId);).
Put it first in the pipeline. Every downstream middleware, controller, and error handler can then read request.wheels.requestId and log it alongside whatever they’re doing.
Accept a client-supplied request ID (optional)
Section titled “Accept a client-supplied request ID (optional)”If you terminate TLS in a reverse proxy that already stamps X-Request-Id (nginx’s $request_id, an ALB-generated trace header, a CDN trace ID), you may want to honor the inbound value instead of generating a fresh one. The built-in middleware always generates; to trust an inbound header, write a small wrapper:
component implements="wheels.middleware.MiddlewareInterface" output="false" {
public string function handle(required struct request, required any next) { // The middleware request context carries a `cgi` member with every // inbound header under its CGI-style `http_*` name (since 4.0.4 — // issue 3074; on earlier versions it only carried // {params, route, pathInfo, method}, so read the engine `cgi` scope // directly there). local.inbound = arguments.request.cgi.http_x_request_id ?: ""; local.id = Len(local.inbound) ? local.inbound : CreateUUID();
$writeRequestId(local.id); local.response = arguments.next(arguments.request);
try { cfheader(name = "X-Request-Id", value = local.id); } catch (any e) {}
return local.response; }
// Write via a helper with no `request` parameter in scope: inside // handle(), the `required struct request` parameter shadows the request // scope on Adobe CF, so a bare `request.wheels.requestId = ...` there // would write to the passed struct instead of the scope. private void function $writeRequestId(required string requestId) { if (!StructKeyExists(request, "wheels")) { request.wheels = {}; } request.wheels.requestId = arguments.requestId; }}Only do this when the header originates from infrastructure you control. An untrusted client header is an injection vector into your logs.
Structured logging
Section titled “Structured logging”Wheels does not ship a logging facade. Every CFML engine supports writeLog() and the framework uses it directly — the job runner writes to wheels_jobs, the error handler writes to wheels-errors, and you can write anywhere you want with a file= argument. Log files land in the engine’s log directory (Lucee: {server}/logs/, Adobe: cfusion/logs/).
The default line format is plain text, which is fine for tailing during development and awkward for a log collector to index. Wrap writeLog with a thin helper that emits JSON and stamps the request ID:
component output="false" {
public void function info(required string message, struct context = {}) { $write(arguments.message, "information", arguments.context); }
public void function warn(required string message, struct context = {}) { $write(arguments.message, "warning", arguments.context); }
public void function error(required string message, struct context = {}) { $write(arguments.message, "error", arguments.context); }
private void function $write( required string message, required string level, required struct context ) { local.payload = { "ts": DateTimeFormat(Now(), "iso"), "level": arguments.level, "message": arguments.message, "requestId": request.wheels.requestId ?: "", // Bare get() isn't available in a plain component — it's a // framework mixin. Read the application scope directly. "environment": application.wheels.environment ?: "" }; StructAppend(local.payload, arguments.context, true);
writeLog( text = SerializeJSON(local.payload), type = arguments.level, file = "application" ); }}Register it in the DI container so every controller and model can reach it:
local.di = injector();local.di.map("log").to("app.lib.Log").asSingleton();Then from a controller:
component extends="Controller" {
function config() { inject("log"); }
function create() { order = model("Order").new(params.order); if (order.save()) { this.log.info("order.created", {orderId: order.id, total: order.total}); redirectTo(route="order", key=order.id); } else { this.log.warn("order.create_failed", {errors: order.allErrors()}); renderView(action="new"); } }}Each line on disk becomes a single JSON object: timestamp, level, message, request ID, environment, plus whatever you passed in context. A collector can parse and index it with no further transformation.
Ship logs to a central collector
Section titled “Ship logs to a central collector”Wheels does not ship to anything. The app writes files; a sidecar process reads them and forwards. The machinery on the sender side lives outside the framework — pick whichever fits your stack:
- Loki / Grafana Agent — scrape the log files with
promtailorgrafana-agent, parse the JSON, index onrequestIdandlevel. - AWS CloudWatch Logs — install the CloudWatch agent on the host (or use Fargate’s built-in log driver in containers), point it at the log directory.
- Datadog / New Relic / Papertrail — their host agents watch arbitrary log paths. Parsing JSON is a config toggle.
- Docker / Kubernetes — run the engine with
logs/mounted to stdout (or write directly to stdout via a custom appender) and let the orchestrator’s logging driver collect everything.
The framework’s job is to write structured lines. The infrastructure’s job is to read them. Keep the responsibilities separate and you can change collectors without touching app code.
A health check endpoint
Section titled “A health check endpoint”Wheels does not provide a built-in health check route. Add one — every load balancer, orchestrator, and uptime prober expects it. A minimal version pings the database and returns JSON:
component extends="Controller" {
function config() { // Without provides(), renderWith() falls back to the HTML view // (which doesn't exist) and the endpoint 500s. provides("html,json"); }
function index() { local.checks = { "database": $checkDatabase(), "environment": get("environment") };
local.healthy = local.checks.database.ok; local.statusCode = local.healthy ? 200 : 503;
// The argument is `status` — renderWith() has no `statusCode` // argument, and an unknown argument would be swallowed, returning // HTTP 200 to the load balancer even on the degraded path. renderWith( data = { "status": local.healthy ? "ok" : "degraded", "checks": local.checks, "requestId": request.wheels.requestId ?: "" }, status = local.statusCode ); }
private struct function $checkDatabase() { try { queryExecute("SELECT 1", {}, {datasource = get("dataSourceName")}); return {ok: true}; } catch (any e) { return {ok: false, error: e.message}; } }}Route it above the wildcard:
mapper() .get(name="healthz", pattern="/healthz", to="health##index") .resources("orders") .wildcard().end();Point your load balancer at /healthz with a short timeout (1–2 seconds) and a low threshold (two failures = unhealthy). Add more checks as they become load-bearing — Redis reachability, the background job queue depth, external API pingability — but resist the urge to check everything. A health check that fails because a downstream analytics API is slow will cycle your pods for no reason.
Error tracking with the Sentry package
Section titled “Error tracking with the Sentry package”The wheels-sentry package captures exceptions with framework-aware context — controller name, action, request URL, tags, breadcrumbs, and optional user identity. It’s distributed as a standalone repo under wheels-dev/, indexed by the wheels-dev/wheels-packages registry.
Install the package
Section titled “Install the package”wheels packages add wheels-sentrywheels reloadOn startup, PackageLoader discovers vendor/wheels-sentry/package.json, instantiates Sentry.cfc, and mixes its public methods (sentryCapture, sentryMessage, sentrySetUser, sentryAddBreadcrumb) into every controller. The package’s package.json declares "provides": {"mixins": "controller"} to drive that wiring.
Configure the DSN
Section titled “Configure the DSN”Set the DSN via environment variable or config/settings.cfm. The package reads sentryDSN first, then falls back to the SENTRY_DSN Java system environment variable (see Sentry.cfc in the wheels-sentry repo):
set(sentryDSN = "https://your-key@o123.ingest.us.sentry.io/456");set(sentryIncludeHeaders = true);set(sentryIncludeServerContext = true);set(sentrySendDefaultPii = false);Environment (release tag) comes from application.wheels.environment; release version comes from the APP_VERSION Java system environment variable. Set APP_VERSION in your deploy pipeline so Sentry can associate events with a specific build.
Capture unhandled errors
Section titled “Capture unhandled errors”The package’s controller mixin captures errors you wrap in a try/catch, but unhandled exceptions that reach app/events/onerror.cfm need an explicit call. Wire Sentry into the error event:
<cfscript>try { if (structKeyExists(application, "sentry") && structKeyExists(local, "exception")) { application.sentry.captureException( exception: local.exception, level: "error", useThread: false, showJavaStackTrace: true ); }} catch (any sentryError) { writeLog(text="Sentry capture failed: ##sentryError.message##", type="error", file="application");}</cfscript>useThread: false matters — the error handler can fire during shutdown, and a threaded send may be killed before it flushes.
Attach user identity
Section titled “Attach user identity”The package does not read user identity from the session automatically. Set it in a before filter on Controller.cfc:
component extends="wheels.Controller" {
function config() { filters(through="setSentryUser"); }
private function setSentryUser() { if (structKeyExists(session, "currentUser")) { sentrySetUser({ id: session.currentUser.id, email: session.currentUser.email, username: session.currentUser.name }); } }}Every event captured during that request then carries the user context.
What the package covers
Section titled “What the package covers”- Controller actions — via the
sentryCapture()mixin (explicit) oronerror.cfmwiring (unhandled). - Breadcrumbs — call
sentryAddBreadcrumb("loaded order")anywhere in a request to leave trail crumbs on the next captured event. - Tags — every event is tagged with
wheels.controller,wheels.action,cfml.engine,wheels.environment. - Release tracking — populated from
APP_VERSION.
What it doesn’t cover
Section titled “What it doesn’t cover”- Background jobs —
Job.cfcwrites failures to thewheels_jobslog file (see the retry and permanent-failurewriteLogcalls invendor/wheels/Job.cfc) but does not push them to Sentry. If you want Sentry visibility on job failures, wrapperform()in atry/catchinside each job and callapplication.sentry.captureException()on the rescue path. - Middleware — exceptions thrown inside middleware bypass controller mixins. Use
application.sentry.captureException()directly if you need coverage there.
Metrics and APM
Section titled “Metrics and APM”Wheels does not ship metrics. A Wheels app is a Java webapp on a JVM, so every JVM-targeted APM tool works unchanged:
- Datadog APM / New Relic / AppDynamics / Dynatrace — install the JVM agent on the Lucee or Adobe process. Traces show up with HTTP spans, database spans, and the standard JVM metrics (GC, heap, thread pools). No framework integration needed.
- Prometheus — expose JVM metrics via
jmx_exporteras a Java agent, or run a sidecar that scrapes a metrics endpoint you build yourself (a controller action rendering Prometheus text format). - StatsD / OpenTelemetry — both have Java agents. OpenTelemetry specifically auto-instruments servlet requests, so you’ll get HTTP-level traces without framework hooks.
Pick the agent your platform team already runs. Framework-specific metrics (per-controller timings, per-model query counts) are not a framework concern — sample them at the JVM level instead.
What to alert on
Section titled “What to alert on”A small set of signals covers almost every production incident a Wheels app will have. Start with these and add more only when a specific incident teaches you to:
- 5xx rate — from the reverse proxy access log or the APM agent. Alert when the ratio of 5xx responses crosses a threshold (1% over five minutes is a reasonable starting point).
- Response time p95 — the tail, not the average. A p95 creeping from 200 ms to 1.5 s signals a problem before the average moves.
- Job queue depth —
wheels jobs status --format=jsonreports pending, processing, and failed counts per queue (scrape it from cron or a textfile exporter); the same numbers are available in-app via(new wheels.Job()).queueStats()from a (protected) controller action. Alert on pending counts that don’t drain or failed counts that grow. - Database connection pool saturation — engine-specific metric. Lucee exposes datasource stats; Adobe does too. A pool pinned at its ceiling means requests are queuing to talk to the database.
- Health check failing — your load balancer’s view of app reachability. One pod occasionally failing is noise; all pods failing is an outage.
- Sentry error rate — alert when the rate of captured exceptions for a release exceeds a baseline, or when a new error fingerprint appears post-deploy.
Everything else — disk space, CPU, memory, certificate expiry — is infrastructure alerting and lives with your platform team’s standard host checks.