If you have an existing Rails application using dry-logger and you’re migrating to OpenTelemetry for observability, you’ll want a smooth transition path. Rather than switching all your logging overnight, dry-logger’s multiple backend support allows you to send logs to both your existing destination and OpenTelemetry simultaneously. Once you’re confident everything works, you can switch to OpenTelemetry exclusively.

Setting Up

First, add the OpenTelemetry Ruby SDK to your project and configure it. See my earlier post on OpenTelemetry Logging for Ruby on Rails for more information.

Creating the OpenTelemetry Backend

Here is an example of a custom backend that uses the OTel Ruby SDK for logging.

module DryLogger
  class OpenTelemetryBackend

    def info(message = nil, **payload)
      log(:info, message, **payload)
    end

    # ... repeat for debug, warn, error, fatal, and unknown.
    # Or this duplication can be removed by using `method_missing`.

    private

    def otel_logger
      @otel_logger ||= OpenTelemetry.logger_provider.logger(
        name: "your_app_name", # scope.name
        version: "your_app_version" # scope.version
      )
    end

    def log(severity, message, **payload)
      severity_text = severity.to_s.upcase
      json_payload = payload.to_json

      payload.deep_stringify_keys!
      payload = flatten_hash(payload)
      payload.transform_values!(&:to_s)

      if message.present?
        otel_logger.on_emit(
          severity_text: severity_text,
          body: message,
          attributes: payload
        )
      else
        otel_logger.on_emit(
          severity_text: severity_text,
          body: json_payload,
          attributes: payload
        )
      end
    end

    def flatten_hash(hash, separator = ".")
      hash.each_with_object({}) do |(key, value), result|
        if value.is_a?(Hash)
          flatten_hash(value, separator).each do |nested_key, nested_value|
            result["#{key}#{separator}#{nested_key}"] = nested_value
          end
        else
          result[key] = value
        end
      end
    end
  end
end

How It Works

Since the message is optional with dry-logger, the backend handles both cases. If the message is there, it becomes the body, and the (flattened and string-ified) payload goes into the attributes. If the message is not there, then it adds the whole json-formatted payload as the body in addition to the attributes.

For the payload, depending on your situation, you may want to be explicit about which keys are added as attributes for OTel logging instead of adding everything.

Logging with a message

logger.info("Customer retrieved", customer_id: 123, metadata: { source: "api" })

In this case:

  • The message "Customer retrieved" becomes the log body
  • The payload is flattened and added as attributes: customer_id: "123", metadata.source: "api"

Logging without a message (payload only)

logger.info(customer_id: 123, metadata: { source: "api" })

Here:

  • The JSON-serialized payload becomes the log body: "{\"customer_id\":123,\"metadata\":{\"source\":\"api\"}}"
  • The flattened payload is still included as attributes for easy searching

This dual approach ensures you always have readable log messages and searchable structured attributes in your OpenTelemetry backend.

Key Features

Nested Hash Flattening: Complex nested structures are automatically flattened with dot notation. For example:

{ user: { id: 123, name: "John" }, metadata: { source: "api", version: 2 } }

Becomes:

  • user.id: "123"
  • user.name: "John"
  • metadata.source: "api"
  • metadata.version: "2"

Type Conversion: All attribute keys and values are converted to strings for OpenTelemetry compatibility.

Flexible Logging: Supports both traditional message-based logging and modern structured logging patterns.

Usage

There are two ways to use the OpenTelemetry backend with dry-logger:

Option 1: OpenTelemetry Only

Create a helper module in app/lib/application_logger.rb:

module ApplicationLogger
  def self.build(id)
    Dry.Logger(id) do |dispatcher|
      dispatcher.add_backend(DryLogger::OpenTelemetryBackend.new)
    end
  end
end

Then use it in your application:

logger = ApplicationLogger.build(:your_app)
logger.info("Processing order", order_id: 12345, amount: 99.99)

This approach sends logs exclusively to OpenTelemetry without any STDOUT output, which is ideal for production environments where you want centralized logging.

Option 2: OpenTelemetry + Default Stream

Add the OpenTelemetry backend to an existing dry-logger instance:

logger = Dry::Logger(:your_app).add_backend(DryLogger::OpenTelemetryBackend.new)
logger.info("Processing order", order_id: 12345, amount: 99.99)

This sends logs to both STDOUT and OpenTelemetry, helpful during development when you want to see logs locally while also populating your telemetry backend.

Real-World Example

Here’s how you might use it in a Rails controller:

class CustomersController < ApplicationController
  def index
    logger = ApplicationLogger.build(:customers)

    logger.info("Fetching customer list",
                user_id: current_user.id,
                filters: params[:filters],
                metadata: { source: request.remote_ip })

    @customers = Customer.all

    logger.info("Customer list retrieved",
                count: @customers.size,
                duration_ms: Time.current - start_time)
  end
end

In your OpenTelemetry backend, you’ll see structured logs with all the context you need for debugging and monitoring, with searchable attributes like user_id, filters, metadata.source, count, and duration_ms.

Testing Your Backend

Here’s a simple RSpec example to verify your backend works correctly:

RSpec.describe DryLogger::OpenTelemetryBackend do
  subject(:backend) { described_class.new }

  it "sends logs with flattened attributes to OpenTelemetry" do
    message = "Test with nested payload"
    payload = { user: { id: 123, name: "John" } }
    otel_logger = instance_double(OpenTelemetry::SDK::Logs::Logger)

    allow(backend).to receive(:otel_logger).and_return(otel_logger)

    expect(otel_logger).to receive(:on_emit).with(
      severity_text: "INFO",
      body: message,
      attributes: {
        "user.id" => "123",
        "user.name" => "John"
      }
    )

    backend.info(message, **payload)
  end
end

Conclusion

By implementing a custom OpenTelemetry backend for dry-logger, you get the best of both worlds: clean, maintainable logging code in your application and rich, structured telemetry data in your observability platform. The flattened attributes make it easy to search and filter logs any OpenTelemetry-compatible backend.

For a more complete example, check out the with-dry-logger branch of the rails-otel-demo repository on GitHub.

AI

I used Anthropic’s Claude for help with some of the code and to draft this blog post.

Copyright 2025 Wendy Smoak - This post first appeared on wsmoak.net and is CC BY-NC licensed.