|Docs

Instrument an App with OpenTelemetry

opentelemetryobservabilitytracingmonitoring

Instrumentation is the code that makes your application emit telemetry: traces that follow a request across functions and services, metrics that count and measure what the app does, and logs with trace context attached. OpenTelemetry (OTel) is the vendor-neutral standard for producing that data, so you instrument once and point the output at any backend. This guide adds the OTel SDK to a Node.js or Python service on Railway, configures it with environment variables, and exports telemetry to an OpenTelemetry Collector over private networking.

This guide covers the application side. For the infrastructure side, deploying the collector and a tracing backend, see Deploy an OpenTelemetry Collector and Backend on Railway.

Prerequisites

  • A Railway project with a service you want to instrument.
  • An OTLP endpoint to send telemetry to. The examples assume an OpenTelemetry Collector running as a service in the same project and environment, listening on port 4318 (OTLP over HTTP). The collector guide sets this up, including a one-click template.

You can also skip the collector and export directly to a vendor's OTLP endpoint over the public internet. A collector is still the better default: it batches, retries, and fans out to multiple backends without app changes, and app-to-collector traffic on the private network avoids network egress charges.

Configure the SDK with environment variables

Every OpenTelemetry SDK reads the same standard environment variables. Set them on the instrumented service in Railway, under the Variables tab, instead of hardcoding endpoints in code:

OTEL_EXPORTER_OTLP_ENDPOINT=http://${{OpenTelemetry Collector.RAILWAY_PRIVATE_DOMAIN}}:4318
OTEL_SERVICE_NAME=${{RAILWAY_SERVICE_NAME}}
OTEL_RESOURCE_ATTRIBUTES=service.version=${{RAILWAY_GIT_COMMIT_SHA}},deployment.environment.name=${{RAILWAY_ENVIRONMENT_NAME}}

What each line does:

  • OTEL_EXPORTER_OTLP_ENDPOINT points the exporter at the collector. ${{OpenTelemetry Collector.RAILWAY_PRIVATE_DOMAIN}} is a reference variable that resolves to the collector service's <service>.railway.internal hostname. Adjust the service name to match yours. Use http, not https: private network traffic is already encrypted in transit by Railway.
  • OTEL_SERVICE_NAME names the service in your traces. Referencing RAILWAY_SERVICE_NAME keeps it in sync with the service name in Railway.
  • OTEL_RESOURCE_ATTRIBUTES attaches key-value pairs to every span and metric. RAILWAY_GIT_COMMIT_SHA and RAILWAY_ENVIRONMENT_NAME are Railway-provided variables, so each trace records exactly which commit and environment produced it.

Private networking is scoped per environment, so the app and the collector must run in the same project and environment for the internal hostname to resolve.

Instrument a Node.js app

Install the SDK, the auto-instrumentation bundle, and the OTLP exporters:

npm install @opentelemetry/api @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/exporter-metrics-otlp-http \
  @opentelemetry/sdk-metrics express

Of these, express is only for the example app; the auto-instrumentation bundle detects popular libraries (Express, Fastify, http, pg, ioredis, and others) and creates spans for them automatically.

Create the SDK setup file

Create instrumentation.js at the project root:

// instrumentation.js
const { NodeSDK } = require("@opentelemetry/sdk-node");
const {
  getNodeAutoInstrumentations,
} = require("@opentelemetry/auto-instrumentations-node");
const {
  OTLPTraceExporter,
} = require("@opentelemetry/exporter-trace-otlp-http");
const {
  OTLPMetricExporter,
} = require("@opentelemetry/exporter-metrics-otlp-http");
const { PeriodicExportingMetricReader } = require("@opentelemetry/sdk-metrics");

// Endpoint and service name come from OTEL_EXPORTER_OTLP_ENDPOINT
// and OTEL_SERVICE_NAME, so nothing is hardcoded here.
const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter(),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter(),
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

// Flush telemetry before the container stops.
process.on("SIGTERM", () => {
  sdk
    .shutdown()
    .catch((err) => console.error("Error shutting down OpenTelemetry", err))
    .finally(() => process.exit(0));
});

The exporters are constructed with no arguments. They read OTEL_EXPORTER_OTLP_ENDPOINT and append the correct signal path (/v1/traces, /v1/metrics) themselves. That SIGTERM handler matters on Railway: deployments are replaced on every deploy, and flushing on shutdown prevents losing the last batch of spans.

Add a custom span

Auto-instrumentation gives you one span per HTTP request and per database call. Add custom spans around the work you care about:

// server.js
const express = require("express");
const { trace, SpanStatusCode } = require("@opentelemetry/api");

const tracer = trace.getTracer("checkout");
const app = express();
const port = process.env.PORT || 3000;

app.get("/checkout", async (req, res) => {
  // Create a child span inside the auto-created HTTP span.
  const total = await tracer.startActiveSpan(
    "calculate-total",
    async (span) => {
      try {
        span.setAttribute("cart.items", 3);
        const total = await calculateTotal();
        return total;
      } catch (err) {
        span.setStatus({ code: SpanStatusCode.ERROR });
        throw err;
      } finally {
        span.end();
      }
    }
  );

  res.json({ total });
});

async function calculateTotal() {
  // Stand-in for real work: a database query, an API call.
  return 3 * 999;
}

app.listen(port, () => {
  console.log(`Listening on port ${port}`);
});

Load the SDK before the app

The SDK must load before any library it instruments. Set a custom start command on the service in Railway:

node --require ./instrumentation.js server.js

The --require flag loads instrumentation.js before server.js and everything it imports.

Zero-code alternative

If you do not need custom spans, skip the setup file entirely. Install two packages:

npm install @opentelemetry/api @opentelemetry/auto-instrumentations-node

Then use this start command:

node --require @opentelemetry/auto-instrumentations-node/register server.js

The register module builds the SDK from environment variables alone. The same OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_SERVICE_NAME variables apply.

Instrument a Python app

Install the distro, the OTLP exporter, and Flask for the example:

pip install opentelemetry-distro opentelemetry-exporter-otlp flask
opentelemetry-bootstrap -a install

opentelemetry-bootstrap scans your installed packages and installs matching instrumentation libraries. Run it after adding dependencies, or add its output to requirements.txt so it runs during the Railway build.

The Python OTLP exporter defaults to gRPC on port 4317. To use the HTTP endpoint on 4318 like the Node example, add one more variable to the service:

OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

Add a custom span

import os

from flask import Flask, jsonify
from opentelemetry import trace

tracer = trace.get_tracer("checkout")
app = Flask(__name__)


@app.route("/checkout")
def checkout():
    # Create a child span inside the auto-created request span.
    with tracer.start_as_current_span("calculate-total") as span:
        span.set_attribute("cart.items", 3)
        total = 3 * 999
    return jsonify(total=total)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000)))

Wrap the start command

Prefix the normal start command with opentelemetry-instrument:

opentelemetry-instrument python main.py

For a production server, wrap that instead:

opentelemetry-instrument gunicorn main:app --bind 0.0.0.0:$PORT

opentelemetry-instrument configures the SDK from the same OTEL_* environment variables and activates every instrumentation library that opentelemetry-bootstrap installed.

Verify telemetry is flowing

  1. Deploy the service and send it a few requests.
  2. Check the collector received the data. The collector from the stack guide exposes a debugging UI (the zpages extension) where you can see incoming spans, or you can add the debug exporter to the collector config to print each received span to the service logs.
  3. Open your tracing backend (Jaeger in the collector stack guide) and search for the service name you set in OTEL_SERVICE_NAME. You should see one trace per request, with your custom span nested inside the HTTP span.

If nothing arrives, check three things: the reference variable resolves to the right collector service name, both services are in the same environment, and the endpoint uses http:// with port 4318 (or 4317 for gRPC).

Where OpenTelemetry fits with Railway observability

Railway already collects logs and resource-level metrics (CPU, memory, network) for every service with no instrumentation. OpenTelemetry adds what Railway cannot see from outside the container: request-level traces, cross-service context propagation, and application-specific metrics. Use both: Railway metrics tell you a service is using 80% of its memory, and a trace tells you which endpoint and query is responsible.

Next steps