← Back to Blog
February 10, 2026programming

Structured Logging with OpenTelemetry: A Practical Guide for Rails, Python, and Node.js

By APIndicators

If you have ever searched through thousands of log lines trying to trace a single request across multiple services, you already know why structured logging matters. Unstructured logs -- free-form strings dumped to stdout -- are easy to write and nearly impossible to query at scale. Structured logging fixes this by treating every log entry as a record with typed, searchable fields.

In 2026, OpenTelemetry has become the industry standard for observability. Its logging SDK, now stable across most languages, gives you a vendor-neutral way to emit structured logs that automatically correlate with traces and metrics. This guide walks through practical implementations in Ruby (Rails), Python, and Node.js, so you can adopt structured logging regardless of your primary stack.

Why Structured Logging Matters

Consider a typical unstructured log line:

[2026-02-10 14:32:01] ERROR: Payment failed for user 4521 - timeout after 30s

This is readable by a human scanning a terminal. But try answering these questions programmatically: How many payment failures happened in the last hour? Which users are most affected? What is the p99 latency for failed payments? You would need fragile regex parsing and hope that every developer on the team formatted their log messages identically.

A structured log entry for the same event looks like this:

{
  "timestamp": "2026-02-10T14:32:01.892Z",
  "severity": "ERROR",
  "body": "Payment failed",
  "attributes": {
    "user.id": 4521,
    "payment.provider": "stripe",
    "error.type": "timeout",
    "duration_ms": 30000
  },
  "trace_id": "a1b2c3d4e5f6...",
  "span_id": "7a8b9c0d..."
}

Every field is queryable. The trace and span IDs let you jump straight from this log entry to the distributed trace that produced it. This is the foundation of real observability.

Setting Up OpenTelemetry Logging in Rails

The OpenTelemetry Ruby SDK provides a logging bridge that integrates with the standard Ruby Logger. Start by adding the required gems:

gem "opentelemetry-sdk"
gem "opentelemetry-exporter-otlp"
gem "opentelemetry-instrumentation-all"
gem "opentelemetry-logs-sdk"

Configure OpenTelemetry in an initializer:

require "opentelemetry/sdk"
require "opentelemetry/exporter/otlp"
require "opentelemetry/instrumentation/all"
require "opentelemetry-logs-sdk"

OpenTelemetry::SDK.configure do |c|
  c.service_name = "api-service"
  c.use_all
end

logger_provider = OpenTelemetry::SDK::Logs::LoggerProvider.new
logger_provider.add_log_record_processor(
  OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(
    OpenTelemetry::Exporter::OTLP::LogsExporter.new
  )
)

OpenTelemetry.logger_provider = logger_provider

Now create a structured logger helper that your application code can use:

class StructuredLogger
  def initialize(name)
    @otel_logger = OpenTelemetry.logger_provider.logger(name:)
  end

  def info(message, **attributes)
    emit(message, severity: "INFO", attributes:)
  end

  def error(message, **attributes)
    emit(message, severity: "ERROR", attributes:)
  end

  def warn(message, **attributes)
    emit(message, severity: "WARN", attributes:)
  end

  private

  def emit(message, severity:, attributes:)
    @otel_logger.on_emit(
      body: message,
      severity_text: severity,
      attributes:
    )
  end
end

Use it in your controllers and services:

class PaymentsController < ApplicationController
  def create
    logger = StructuredLogger.new("payments")

    result = PaymentService.call(user: current_user, amount: params[:amount])

    if result.success?
      logger.info("Payment processed",
        "user.id": current_user.id,
        "payment.amount": params[:amount],
        "payment.currency": "USD")
    else
      logger.error("Payment failed",
        "user.id": current_user.id,
        "error.type": result.error_class,
        "error.message": result.error_message)
    end
  end
end

Setting Up OpenTelemetry Logging in Python

Python's OpenTelemetry logging integration bridges the standard logging module, which means you can adopt structured logging without rewriting every call site.

Install the packages:

pip install opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-api

Configure the logger provider and attach it to Python's logging system:

import logging
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.sdk.resources import Resource

resource = Resource.create({"service.name": "prediction-service"})

logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
    BatchLogRecordProcessor(OTLPLogExporter())
)

handler = LoggingHandler(
    level=logging.INFO,
    logger_provider=logger_provider
)

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("predictions")
logger.addHandler(handler)

The key to structured logging in Python is passing attributes through the extra dict:

def predict(pair_symbol: str, side: str, model_name: str):
    score = model.predict(features)

    logger.info(
        "Prediction generated",
        extra={
            "pair.symbol": pair_symbol,
            "prediction.side": side,
            "prediction.score": float(score),
            "model.name": model_name,
        }
    )

    return score

Every key in extra becomes a searchable attribute in your observability backend. When this log is emitted within an active OpenTelemetry span, the trace context is automatically attached.

Setting Up OpenTelemetry Logging in Node.js

For Next.js API routes or standalone Node.js services, the setup follows a similar pattern:

npm install @opentelemetry/sdk-node @opentelemetry/api-logs \
  @opentelemetry/sdk-logs @opentelemetry/exporter-logs-otlp-grpc

Configure the logger:

import { LoggerProvider, BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-grpc";
import { Resource } from "@opentelemetry/resources";
import { SeverityNumber } from "@opentelemetry/api-logs";

const resource = new Resource({ "service.name": "web-frontend" });

const loggerProvider = new LoggerProvider({ resource });
loggerProvider.addLogRecordProcessor(
  new BatchLogRecordProcessor(new OTLPLogExporter())
);

const logger = loggerProvider.getLogger("web");

function logEvent(
  message: string,
  severity: SeverityNumber,
  attributes: Record<string, string | number | boolean>
) {
  logger.emit({
    body: message,
    severityNumber: severity,
    attributes,
  });
}

Use it in your application:

import { SeverityNumber } from "@opentelemetry/api-logs";

export async function POST(request: Request) {
  const body = await request.json();

  logEvent("API request received", SeverityNumber.INFO, {
    "http.method": "POST",
    "http.route": "/api/predictions",
    "user.id": body.userId,
    "request.pair": body.pair,
  });

  const prediction = await getPrediction(body.pair);

  logEvent("Prediction served", SeverityNumber.INFO, {
    "prediction.score": prediction.score,
    "prediction.side": prediction.side,
    "response.status": 200,
  });

  return Response.json(prediction);
}

Attribute Naming Conventions

One of the biggest benefits of OpenTelemetry is its semantic conventions -- standardized attribute names that make logs queryable across services. Follow these patterns:

| Domain | Convention | Example | |--------|-----------|---------| | HTTP | http.method, http.status_code | http.method: "POST" | | Database | db.system, db.statement | db.system: "postgresql" | | User | user.id, user.email | user.id: 4521 | | Error | error.type, error.message | error.type: "TimeoutError" | | Custom | {domain}.{attribute} | prediction.score: 0.87 |

Consistency here is what makes structured logging powerful. When every service uses user.id instead of mixing userId, user_id, and uid, you can write a single query that spans your entire system.

Correlating Logs with Traces

The real power of OpenTelemetry logging is automatic correlation. When you emit a log within an active span, the SDK attaches trace_id and span_id fields automatically. This means you can:

  1. See a spike in error logs in your dashboard
  2. Click on any error log entry
  3. Jump directly to the full distributed trace
  4. See every service call, database query, and external API request in that trace

This eliminates the guesswork of debugging production issues. Instead of correlating timestamps and grepping through multiple log files, you follow the trace.

Practical Tips for Adoption

Start with high-value events. You do not need to convert every puts or console.log on day one. Start with authentication events, payment processing, API errors, and any event you frequently debug in production.

Set up a local collector. Run the OpenTelemetry Collector locally with Docker to validate your setup before deploying. Export to a local Jaeger or Grafana instance for visual verification.

Use the batch processor. Always use BatchLogRecordProcessor instead of SimpleLogRecordProcessor in production. The batch processor buffers log records and exports them in bulk, dramatically reducing overhead.

Add context progressively. Each time you debug an issue and wish you had more context, add those attributes to your structured logs. Over weeks, your logs become increasingly useful with minimal upfront investment.

Never log sensitive data. Structured logging makes it tempting to log everything. Establish clear rules: never log passwords, API keys, full credit card numbers, or personally identifiable information unless encrypted or tokenized.

Conclusion

Structured logging with OpenTelemetry is not just a nice-to-have -- it is the difference between spending ten minutes and ten hours debugging a production incident. The investment is modest: a few dozen lines of configuration code and a discipline around attribute naming. The payoff is massive: queryable, correlated, vendor-neutral logs that work across every service in your stack. Whether you are running Rails, Python, or Node.js, the patterns are remarkably similar, and the OpenTelemetry ecosystem ensures your instrumentation is portable.