OpenTelemetry Logs for Ruby

Make sure your exporter is configured before you start instrumenting code. Follow Getting started with OpenTelemetry Ruby or set up Direct OTLP Configuration first.

If you are not familiar with logs terminology like structured logging or log-trace correlation, read the introduction to OpenTelemetry Logs first.

Overview

OpenTelemetry provides two approaches for collecting logs in Ruby:

  1. Logs SDK: Use the OpenTelemetry Logs SDK to emit structured logs directly to your observability backend.
  2. Span events: Use span events to attach log-like messages to traces (simpler but less flexible).

The Logs SDK approach is recommended for production applications as it provides better correlation and filtering capabilities.

OpenTelemetry Logs SDK

The OpenTelemetry Ruby Logs SDK allows you to emit structured logs that are automatically correlated with traces.

Basic configuration

ruby
require 'opentelemetry/sdk'
require 'opentelemetry-logs-sdk'
require 'opentelemetry/exporter/otlp_logs'

# Configure log exporter
log_exporter = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
  endpoint: 'https://api.uptrace.dev/v1/logs',
  headers: { 'uptrace-dsn': ENV['UPTRACE_DSN'] },
  compression: 'gzip'
)

# Attach batch processor
processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(log_exporter)
OpenTelemetry.logger_provider.add_log_record_processor(processor)

# Ensure logs are flushed on shutdown
at_exit { OpenTelemetry.logger_provider.shutdown }

Complete example

ruby
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'rubygems'
require 'bundler/setup'
require 'uptrace'
require 'opentelemetry-logs-sdk'
require 'opentelemetry/exporter/otlp_logs'

# Configure OpenTelemetry with Uptrace
Uptrace.configure_opentelemetry(dsn: '') do |c|
  c.service_name = 'myservice'
  c.service_version = '1.0.0'
end

# Configure log exporter
log_exporter = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
  endpoint: 'https://api.uptrace.dev/v1/logs',
  headers: { 'uptrace-dsn': ENV['UPTRACE_DSN'] },
  compression: 'gzip'
)

processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(log_exporter)
OpenTelemetry.logger_provider.add_log_record_processor(processor)

at_exit do
  OpenTelemetry.tracer_provider.shutdown
  OpenTelemetry.logger_provider.shutdown
end

# Create a logger
LOGGER = OpenTelemetry.logger_provider.logger(name: 'myservice', version: '1.0.0')

# Create a tracer
TRACER = OpenTelemetry.tracer_provider.tracer('myservice', '1.0.0')

# Helper for structured logging
def log_info(message, attrs = {})
  LOGGER.on_emit(
    timestamp: Time.now.utc,
    severity_text: 'INFO',
    body: message,
    attributes: attrs
  )
end

def log_error(message, attrs = {})
  LOGGER.on_emit(
    timestamp: Time.now.utc,
    severity_text: 'ERROR',
    body: message,
    attributes: attrs
  )
end

# Start a traced operation with logs
TRACER.in_span('process-request', kind: :server) do |span|
  # Logs are automatically correlated with the active trace
  log_info('Processing request started', {
    'user.id' => '12345',
    'request.action' => 'login'
  })

  # Simulate some work
  sleep(0.1)

  log_info('Request processed successfully', {
    'duration_ms' => 100
  })

  puts "Trace URL: #{Uptrace.trace_url(span)}"
end

See the GitHub example for a complete working example.

Log-trace correlation

When you emit a log within an active trace span, OpenTelemetry can automatically include:

  • trace_id: Links log to the entire distributed trace
  • span_id: Links log to the specific operation
  • trace_flags: Indicates if the trace is sampled

This enables bidirectional navigation between logs and traces in your observability backend.

Manual correlation

If you need manual correlation, extract trace context from the current span:

ruby
def log_with_context(message, level: 'INFO', attrs: {})
  current_span = OpenTelemetry::Trace.current_span

  log_attrs = attrs.dup

  if current_span&.context&.valid?
    context = current_span.context
    log_attrs['trace_id'] = context.hex_trace_id
    log_attrs['span_id'] = context.hex_span_id
    log_attrs['trace_flags'] = context.trace_flags.sampled? ? '01' : '00'
  end

  LOGGER.on_emit(
    timestamp: Time.now.utc,
    severity_text: level,
    body: message,
    attributes: log_attrs
  )
end

# Usage within a span
TRACER.in_span('my-operation') do |span|
  log_with_context('Operation started', attrs: { 'operation' => 'process' })
  # ... do work ...
  log_with_context('Operation completed')
end

Using span events for logs

For simpler use cases, you can use span events as log-like messages:

ruby
TRACER.in_span('process-order') do |span|
  # Log-like event with attributes
  span.add_event('log', attributes: {
    'log.severity' => 'INFO',
    'log.message' => 'Order processing started',
    'order.id' => order_id
  })

  begin
    process_order(order_id)

    span.add_event('log', attributes: {
      'log.severity' => 'INFO',
      'log.message' => 'Order processed successfully'
    })
  rescue StandardError => e
    span.add_event('log', attributes: {
      'log.severity' => 'ERROR',
      'log.message' => "Order processing failed: #{e.message}",
      'error.type' => e.class.name
    })
    raise
  end
end

Rails integration

For Rails applications, configure logging in an initializer:

ruby
# config/initializers/opentelemetry_logs.rb
require 'opentelemetry-logs-sdk'
require 'opentelemetry/exporter/otlp_logs'

Rails.application.config.after_initialize do
  next unless ENV['UPTRACE_DSN']

  log_exporter = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
    endpoint: 'https://api.uptrace.dev/v1/logs',
    headers: { 'uptrace-dsn': ENV['UPTRACE_DSN'] },
    compression: 'gzip'
  )

  processor = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(log_exporter)
  OpenTelemetry.logger_provider.add_log_record_processor(processor)
end

at_exit { OpenTelemetry.logger_provider.shutdown }

Custom Rails logger with OTel

Create a custom logger that sends logs to both Rails and OpenTelemetry:

ruby
class OtelLogger < ActiveSupport::Logger
  def initialize(*args)
    super
    @otel_logger = OpenTelemetry.logger_provider.logger(
      name: Rails.application.class.module_parent_name.downcase,
      version: ENV['APP_VERSION'] || '1.0.0'
    )
  end

  def add(severity, message = nil, progname = nil, &block)
    super

    severity_text = case severity
                    when Logger::DEBUG then 'DEBUG'
                    when Logger::INFO then 'INFO'
                    when Logger::WARN then 'WARN'
                    when Logger::ERROR then 'ERROR'
                    when Logger::FATAL then 'FATAL'
                    else 'UNKNOWN'
                    end

    log_message = message || (block_given? ? yield : progname)

    @otel_logger.on_emit(
      timestamp: Time.now.utc,
      severity_text: severity_text,
      body: log_message.to_s,
      attributes: extract_trace_context
    )
  end

  private

  def extract_trace_context
    attrs = {}
    current_span = OpenTelemetry::Trace.current_span

    if current_span&.context&.valid?
      context = current_span.context
      attrs['trace_id'] = context.hex_trace_id
      attrs['span_id'] = context.hex_span_id
    end

    attrs
  end
end

# config/environments/production.rb
Rails.application.configure do
  config.logger = OtelLogger.new(STDOUT)
end

Best practices

Use structured attributes

Use key-value attributes for structured logging to enable filtering:

ruby
# Good: Structured attributes
log_info('Database query executed', {
  'db.system' => 'postgresql',
  'db.operation' => 'SELECT',
  'db.table' => 'users',
  'duration_ms' => 45,
  'rows_returned' => 10
})

# Bad: Unstructured message
log_info("SELECT query on users table took 45ms and returned 10 rows")

Use appropriate severity levels

ruby
# DEBUG: Detailed information for debugging
log_debug('Cache lookup', { 'key' => cache_key, 'hit' => cache_hit })

# INFO: General operational information
log_info('Request processed', { 'status' => 200, 'path' => '/api/users' })

# WARN: Potentially harmful situations
log_warn('Rate limit approaching', { 'current' => 95, 'limit' => 100 })

# ERROR: Error events that might still allow the application to continue
log_error('Database connection failed', { 'retry_count' => 3 })

Avoid logging sensitive data

Never log passwords, tokens, or PII:

ruby
# Bad: Logging sensitive data
log_info('User login', { 'password' => password, 'email' => email })

# Good: Redact sensitive fields
log_info('User login', { 'user_id' => user_id, 'login_method' => 'password' })

Include contextual information

Add relevant context to help with debugging:

ruby
def process_payment(order_id, amount)
  TRACER.in_span('process-payment') do |span|
    log_info('Payment processing started', {
      'order.id' => order_id,
      'payment.amount' => amount,
      'payment.currency' => 'USD'
    })

    begin
      result = payment_gateway.charge(amount)

      log_info('Payment successful', {
        'order.id' => order_id,
        'transaction.id' => result.transaction_id
      })

      result
    rescue PaymentError => e
      log_error('Payment failed', {
        'order.id' => order_id,
        'error.code' => e.code,
        'error.message' => e.message
      })
      raise
    end
  end
end

What's next?