Skip to content

Operations

This covers what changes once rel is running somewhere real: how to read its logs, what an error response means and what to do about it, and how a schema change reaches a live deployment.

Logging

rel logs one line per event to stdout — no file destinations or rotation to configure; that's the job of whatever supervises the process (systemd, your container runtime).

REL_LOGGING__HANDLER=JSON REL_LOGGING__LEVEL=info rel
  • logging.handlerpretty (default) for colored, human-readable single-line output during local development, or JSON for one JSON object per line in production.
  • logging.leveldebug, info (default), warn, or error; the minimum level actually emitted.
  • logging.filter.<key> — a regexp a log line's <key> attribute must match to be shown at all. A line with nothing to filter against for that key is shown regardless.
  • logging.exclude.<key> — same shape, inverted: suppresses a matching line. Applied after any logging.filter.* you've also set.

See Configuration for how these compose with every other setting (env var, flag, or config file, in that precedence).

Every line carries a module attribute (query, route, pg, reloadcmd, ...) saying which part of rel produced it. Every line tied to a specific HTTP request also carries request_id — either forwarded from that request's own X-Request-Id header, or generated by rel when the header is absent — so you can grep one request's full lifecycle out of the log, end to end.

/rel and /route/* each log one line per completed request unconditionally, at info, with the method, path, status code, duration, response size, resolved role, and the full JWT claims of whoever made the request (including any custom claim a login function attached, such as a user ID) — you don't need to instrument individual routes to get that. At debug, one further line per query is logged for the same request, with the exact SQL text and parameters sent to Postgres — nothing held back or redacted, so narrow it down with logging.filter/ logging.exclude rather than expecting rel to do it for you.

Schema introspection (startup, and every reload) logs one info summary line with the table/function/type counts it found ; at debug, one line per relation/function/type discovered, and one line per route/middleware actually registered and exposed over HTTP.

Error responses

Every error response, from /rel or /route, carries a code: a stable, safe-to-switch-on token, always present, never derived from user input. It arrives two ways at once — an X-Rel-Errorcode response header (works regardless of body framing, including /route's plain-text error paths), and, for /rel specifically, a code field in the JSON body:

{
  "status": "error",
  "code": "PG_UNIQUE_VIOLATION",
  "error": "a unique constraint was violated",
  "pg_error": {
    "message": "duplicate key value violates unique constraint \"room_types_property_id_name_key\"",
    "constraint_name": "room_types_property_id_name_key",
    "table_name": "room_types"
  }
}

There are two families of code, and telling them apart matters for how much detail you can expect in the response:

  • Postgres-raised (RSxxx) — a raise exception ... using errcode = 'RS404' inside a Postgres function you wrote yourself picks its own HTTP status this way (RS404404). This is how a function-level check (a business rule, an authorization decision made in SQL) reports back to the client without rel needing to know about it in advance.
  • rel-internal (SCREAMING_SNAKE_CASE) — everything rel classifies itself: malformed requests, query-compile rejections, auth gates, connectivity failures. A few worth knowing by name:
code Status Meaning
UNKNOWN_IDENTIFIER 400 a relation, column, function, or operator name didn't resolve
JOIN_MISSING_INDEX 400 a join's own columns aren't indexed — rel refused to compile a query that would scan per parent row
WRITE_FORBIDDEN 400 the write touched a relation whose identity isn't writable, or violated a write_mode rule
QUERY_ROLLBACK_NOT_GRANTED 400 rollback: true requested but pg.query.allow_rollback is off — see Complex queries
QUERY_STATS_QUERY_PLAN_CONFLICT 400 stats and query_plan both requested on the same write
ANONYMOUS_DISABLED 401 request has no credentials and no anonymous role is configured
NO_ROLE_CONFIGURED 500 pg.query.anonymous_role is unset and the request is anonymous — a deployment misconfiguration, not an ordinary request failure
DB_UNAVAILABLE 500 acquiring a Postgres connection failed
TRANSACTION_ERROR 500 begin/commit/rollback itself failed

A Postgres constraint your own request's data triggered — a unique, foreign key, not-null, or check violation — is always described in full, in production too: the constraint and column names came from data you supplied, so they're not new information to you. Anything else (a raw permission-denied message, an unclassified failure) stays a fixed, generic message and status in production, and only expands to the full underlying text when the server runs with dev: true. Never rely on parsing that generic message — branch on code, which is always present regardless of mode.

Migrations and schema reload

rel doesn't embed a migration tool — reload.cmd names whatever command should run before every reload (startup, and every SIGUSR1), and rel reintrospects the schema after. See Reload for the full detail : the command line/interpolation syntax, output logging, failure handling, and how to wire up dmut (this repo's own dev database uses it, just test-db-migrate/just test-db-fresh).

To pick up a schema change on a running deployment without restarting the process, send it SIGUSR1:

kill -USR1 <rel pid>