|Docs

Set Up Structured Logging for a Production App

loggingobservabilitymonitoringdebugging

Structured logging means emitting each log line as a single-line JSON object instead of plain text. Railway parses these objects automatically: the level field colors the line in the Log Explorer, the message field becomes the log text, and every other field becomes a custom attribute you can filter on with @name:value queries.

This guide sets that up for a Node.js service with Pino, shows equivalents for Python and Go, and queries the results in the Log Explorer.

How Railway parses your logs

Railway captures everything your service writes to standard output and standard error. If one of those lines is valid JSON, Railway reads these fields:

{
  "message": "A minimal structured log",
  "level": "info",
  "customAttribute": "value"
}
  • message is the log content shown in the explorer.
  • level is the severity: debug, info, warn, or error. Lines with a level are colored accordingly.
  • Any other field becomes a queryable attribute, including arrays and numbers.

Railway normalizes common variations so most logging libraries work without custom configuration:

  • msg is converted to message
  • Levels are lowercased and matched to the closest of debug, info, warn, error
  • Plain-text lines are wrapped as {"message": "...", "level": "..."}
  • Lines written to stdout default to level: info; lines written to stderr become level: error

The one hard requirement: each JSON object must be emitted on a single line, so disable pretty-printing in production. In return, multi-line content like stack traces stays intact inside the message field, where plain-text logging would split it into separate lines.

Set up Pino in a Node.js service

Pino is a JSON logger for Node.js. Install it along with pino-http for request logging:

npm install express pino pino-http

Pino emits numeric levels ("level": 30) by default. Configure it to emit label strings instead so Railway can match them:

// logger.js
import { pino } from "pino";

export const logger = pino({
  // Emit "level": "info" instead of "level": 30
  formatters: {
    level: (label) => ({ level: label }),
  },
  // Emit "message" instead of "msg" (Railway normalizes "msg" too,
  // but "message" matches the parsed format exactly)
  messageKey: "message",
});

Use it in an Express app, with a request logger that attaches a request ID to every line:

// server.js
import express from "express";
import { pinoHttp } from "pino-http";
import { randomUUID } from "node:crypto";
import { logger } from "./logger.js";

const app = express();
app.use(express.json());

app.use(
  pinoHttp({
    logger,
    genReqId: (req) => req.headers["x-request-id"] ?? randomUUID(),
    // Put the request ID at the top level so it is filterable
    // as @requestId:<id> in the Log Explorer
    customProps: (req) => ({ requestId: req.id }),
  }),
);

app.post("/checkout", (req, res) => {
  const start = Date.now();
  const { userId, productId } = req.body ?? {};

  // Custom fields become filterable attributes: @userId:456
  req.log.info({ userId, productId }, "checkout started");

  try {
    // ... process the order ...
    req.log.info(
      { userId, productId, durationMs: Date.now() - start },
      "checkout completed",
    );
    res.json({ ok: true });
  } catch (err) {
    // Pino serializes the error, stack trace included, into one line
    req.log.error({ err, userId }, "checkout failed");
    res.status(500).json({ ok: false });
  }
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  logger.info({ port: Number(port) }, "server listening");
});

That import syntax needs "type": "module" set in your package.json.

A checkout request now produces lines like this (Pino's time, pid, and req fields trimmed for brevity):

{"level":"info","message":"checkout completed","userId":456,"productId":123,"durationMs":84,"requestId":"a1b2c3d4"}

Keep pretty-printing out of production. If you use pino-pretty locally, run it as a dev-only transport:

const logger = pino({
  formatters: { level: (label) => ({ level: label }) },
  messageKey: "message",
  transport:
    process.env.NODE_ENV !== "production"
      ? { target: "pino-pretty" }
      : undefined,
});

Set up structlog in a Python service

Install structlog:

pip install structlog

Configure it to emit single-line JSON with the fields Railway expects. structlog names the log text event by default, so rename it to message:

# logger.py
import structlog

structlog.configure(
    processors=[
        structlog.processors.add_log_level,
        structlog.processors.EventRenamer("message"),
        structlog.processors.JSONRenderer(),
    ]
)

log = structlog.get_logger()

log.info("checkout completed", user_id=456, product_id=123, duration_ms=84)

Output:

{"user_id": 456, "product_id": 123, "duration_ms": 84, "level": "info", "message": "checkout completed"}

Set up slog in a Go service

The standard library's log/slog package needs no dependencies. Its JSON handler emits msg and uppercase levels, both of which Railway normalizes automatically:

package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	logger.Info("checkout completed", "userId", 456, "durationMs", 84)
}

Output:

{"time":"2026-07-29T12:00:00Z","level":"INFO","msg":"checkout completed","userId":456,"durationMs":84}

Railway converts msg to message and matches INFO to info.

Query structured logs in the Log Explorer

Open the Observability tab in your project to reach the Log Explorer, which searches across all services in the environment. The same filter syntax works in a single deployment's log panel.

Filter by level:

@level:error

Filter by a custom attribute your app emitted:

@userId:456

Combine filters with AND, OR, and - (negation), and group with parentheses:

@level:error AND "checkout failed"
@productId:123 AND (@level:warn OR @level:error)

Numeric attributes support comparison operators and ranges:

@durationMs:>500
@durationMs:100..500

Array attributes are filterable by element:

@roles[0]:editor

In the environment-wide view, scope by service or exclude noisy ones:

@service:<service_id> AND @level:error
-@service:<postgres_service_id>

To trace one request across log lines, filter on the request ID your logger attached:

@requestId:a1b2c3d4

Right-click any matched line and select View in Context to see the surrounding logs.

Production practices

Stay under the rate limit. Railway allows 500 log lines per replica per second; beyond that, lines are dropped and a warning appears in your logs. Structured logging helps here: one JSON object with ten fields is one line, where a pretty-printed object would be ten. Dropping debug level in production and sampling high-frequency events cuts that count further.

Log to stdout, not files. Railway only captures standard output and standard error. File-based logs are invisible to the Log Explorer and lost on redeploy unless written to a volume.

Keep attribute names consistent. Pick one convention (userId everywhere, not user_id in one service and uid in another) so a single filter works across the whole environment view.

Know your retention window. Logs are retained 7 days on Hobby, 30 days on Pro, and up to 90 days on Enterprise. For longer retention, forward logs to a third-party tool. See Connect a Third-Party Observability Tool.

Next steps