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:
- See a spike in error logs in your dashboard
- Click on any error log entry
- Jump directly to the full distributed trace
- 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.