Dash0 Raises $35 Million Series A to Build the First AI-Native Observability Platform

Last updated: November 29, 2025

Better Ruby on Rails Logging with Semantic Logger and OpenTelemetry

Logs are often the first place you look when something feels off in production. Maybe a page is slow, an endpoint is throwing 500 errors, or a background job silently fails. Without good logs, you're left guessing; with them, you can usually walk straight to the problem.

Rails comes with a logger that works well for small applications, but modern systems need more structure and flexibility.

Semantic Logger is a drop-in replacement that addresses many of the standard logger's limitations by offering:

  • Structured logging in JSON.
  • Asynchronous non-blocking writes.
  • Contextual logging.
  • Seamless OpenTelemetry correlation.

In this guide, you'll learn to instrument your Ruby on Rails services with Semantic Logger and configure a logging pipeline that aligns with modern observability standards.

Let's begin!

Getting started with Semantic Logger

To begin, add the library to your Gemfile. If you're using Rails, you should specifically use the Rails integration gem, which automatically hooks into the framework's internal events:

ruby
1234567
source 'https://rubygems.org'
# For standard Ruby applications
gem 'semantic_logger'
# Or for Ruby on Rails applications
gem 'rails_semantic_logger'

Then execute the bundle command to install the dependencies:

command
1
bundle install

You can now use it in your Ruby programs like this:

ruby
123456
require "semantic_logger"
SemanticLogger.add_appender(io: $stdout)
logger = SemanticLogger['MyApp']
logger.info('Hello from Semantic Logger!')

This script initializes the library and directs output to the standard output stream ($stdout). It creates a named logger instance (MyApp) to identify the source component and emits an informational event. By default, this produces a human-readable text format:

text
1
2025-11-27 10:53:21.569191 I [2023521:16] MyApp -- Hello from Semantic Logger!

In production environments, you should switch your log output to JSON:

ruby
1234
SemanticLogger.add_appender(
io: $stdout,
formatter: :json
)

This outputs the following newline-delimited JSON log (pretty-printed here for readability):

json
1234567891011
{
"host": "falcon",
"application": "Semantic Logger",
"timestamp": "2025-11-27T09:58:50.850338Z",
"level": "info",
"level_index": 2,
"pid": 2043187,
"thread": "16",
"name": "MyApp",
"message": "Hello from Semantic Logger!"
}

Since most Ruby logging occurs within the context of a specific class, Semantic Logger provides a shorthand mixin to handle namespace resolution automatically:

ruby
123456789
class PaymentGateway
# Adds `logger` class and instance methods
include SemanticLogger::Loggable
def charge(amount)
# Automatically tagged with name: "PaymentGateway"
logger.info("Processing charge", amount: amount)
end
end

The inclusion of SemanticLogger::Loggable ensures that every log entry is correctly attributed to its source component without manual configuration:

json
123456
{
[...],
"name": "PaymentGateway",
"message": "Processing charge",
"payload": { "amount": 40 }
}

Asynchronous vs synchronous logging

Semantic Logger defaults to an asynchronous logging architecture that utilizes an in-memory queue and a dedicated background thread for flushing logs.

When your application triggers a log event, the payload is pushed to the queue, and the main thread immediately resumes execution.

This creates a non-blocking path that decouples application performance from logging latency.

ruby
123456789
# The difference in execution flow
# Synchronous
logger.info("Processing payment") # Blocks until write completes
PaymentGateway.charge(user) # Delays based on the configured appenders
# Asynchronous
logger.info("Processing payment") # Pushes to queue, returns instantly
PaymentGateway.charge(user) # Executes immediately

While async logging improves throughput, it introduces a risk: if the Ruby process terminates abruptly, any logs remaining in the memory buffer may be lost, which may deprive you of the context needed to debug the problem.

Since the performance impact of writing to the standard output is usually negligible, you can disable asynchronous logging to guarantee that crash logs will be captured:

ruby
123456
require "semantic_logger"
SemanticLogger.sync! # Add this before any appenders
SemanticLogger.add_appender(io: $stdout, formatter: :json)
# [...]

How log levels work in Semantic Logger

Semantic Logger supports a set of log levels that lets you control how much logs your services emit. Here's a quick reference table showing the supported levels and their typical use cases:

LevelTypical use case
TRACEExtremely fine-grained debugging, deeper than debug.
DEBUGDeveloper-focused diagnostics and internal state.
INFORoutine application events: startup, shutdown, user actions.
WARNSomething unexpected or concerning, but not an outright failure.
ERRORAn operation failed and needs investigation.
FATALA serious failure or system-level risk that requires immediate attention.

Semantic Logger exposes these levels through simple method calls:

ruby
123456
logger.trace("polling cache", index: 3)
logger.debug("loading user", id: 42)
logger.info("user signed in")
logger.warn("retrying payment")
logger.error("charge failed")
logger.fatal("background worker crashed")

Adjusting the log level

If you want to adjust verbosity globally, set the default level. It controls anything that doesn't override its own level:

ruby
1
SemanticLogger.default_level = :info # info is the default if unspecified

You can override the level per logger as follows:

ruby
12345678910111213
class EmailSender
include SemanticLogger::Loggable
def initialize
logger.level = :warn
end
def deliver(message)
logger.debug("Rendering email", template: message.template)
logger.info("Sending email", to: message.recipient)
# ...
end
end

This configuration overrides the global setting and restricts the output of this class to warnings and errors only.

For the application as a whole, it's common to derive the log level from an environment variable:

ruby
1
SemanticLogger.default_level = ENV.fetch("LOG_LEVEL", "info")

Production settings typically default to info or warn to minimize performance overhead and reduce costs, while debug and lower are reserved for development environments or active troubleshooting.

Dynamically updating the log level

While environment variables are useful for initialization, relying on them for active troubleshooting isn't ideal as it typically requires a restart to take effect, which can clear the state you're trying to diagnose.

To solve this, you should implement a mechanism to toggle log verbosity at runtime without interrupting the process. Semantic Logger includes a small but incredibly handy feature for situations like this.

On Unix-like systems (Linux, macOS, BSD), processes can receive signals such as HUP, TERM, or USR1. Semantic Logger can hook into these signals and change its behavior on the fly.

To turn on signal handling, add a simple initializer:

ruby
1
SemanticLogger.add_signal_handler

Once enabled, Semantic Logger watches for the SIGUSR2 signal. When your process receives this signal, the global default log level rotates to the next level. No restart is required, and no code changes are needed.

bash
12
kill -SIGUSR2 <process_id>
# or: docker kill -s="SIGUSR2" <container>

The rotation cycle goes in this order:

text
1
fatal → error → warn → info → debug → trace → (wraps back to) fatal

So if your app is currently logging at info, sending SIGUSR2 bumps it to debug. Send it again and you're at trace. Send it once more and you roll back to fatal.

This makes it easy to capture verbose diagnostics for a brief period and then step the log level back down once you have the information you need. The caveat is that it only works for loggers that inherit their level from the global default.

If you need to change the level of a class logger, you do so like this:

ruby
12345
# Change the log level to DEBUG
PaymentGateway.logger.level = :debug
# Or inherit the global default level
PaymentGateway.logger.level = nil

For cloud-native applications, managing log verbosity through feature flags or a centralized control plane is generally more robust than relying on OS signals.

Temporarily silencing logs

When you're writing tests, it's common to call methods that produce far more logging than you need or care about.

Semantic Logger provides a more controlled option through the silence() method. It lets you temporarily raise the minimum log level for a specific block of code, which effectively suppresses low-priority messages.

Here's an example using RSpec:

ruby
1234567891011121314151617
require "semantic_logger"
RSpec.describe PaymentProcessor do
include SemanticLogger::Loggable
it "processes payments without polluting the test output" do
# Reduce noise: only errors from inside the block will be logged.
logger.silence(:error) do
processor = PaymentProcessor.new
result = processor.charge(amount: 100, card: "4242424242424242")
expect(result).to be_success
end
# Outside the block, the logger returns to its previous verbosity.
end
end

In this test, anything below error inside the silence block is ignored, which keeps the test output clean. As soon as the block ends, Semantic Logger restores the original global level automatically.

Note that this suppression applies only to loggers utilizing the global default level. If a logger has an explicitly configured log level, it will ignore the silence directive.

Contextual logging with Semantic Logger

Structured logs only start to pay off when they include context around an event. A log message about a failed operation is useless unless you know what caused it, what inputs were involved, and what the system was doing at the time.

Semantic Logger offers two primary mechanisms to inject this context: Payloads and Tags.

Per-message context (payloads)

Instead of interpolating variables into the message string, you should pass them as the second argument to any level method:

ruby
12345678910
require "semantic_logger"
SemanticLogger.add_appender(
io: $stdout,
formatter: :json
)
logger = SemanticLogger["Auth"]
logger.info("Failed to update record", user_id: "usr-1234", provider: "github")

The resulting JSON log entry cleanly separates the event description from its metadata:

json
123456
{
[...],
"name": "Auth",
"message": "Failed to update record",
"payload": { "user_id": "usr-1234", "provider": "github" }
}

Scoped context (tags)

While payloads are great for individual events, you often need to attach context to a specific workflow that spans multiple method calls. Passing arguments manually to every single log statement leads to parameter drilling, which is tedious and error-prone.

The tagged() method solves this by applying metadata to a specific block of execution. Any log generated within that block (including in deeply nested methods) automatically inherits these tags.

ruby
12345678910111213
class InvoiceProcessor
include SemanticLogger::Loggable
def process(invoice)
SemanticLogger.tagged(invoice_id: invoice.id, customer_id: invoice.customer_id) do
logger.info("Starting invoice processing")
# processing...
logger.info("Invoice successfully processed")
end
end
end

In the output, these shared attributes appear under named_tags. This binds the logs together, allowing you to filter by invoice_id in your observability tool and see the entire lifecycle of that specific transaction.

json
12
{...,"named_tags":{"invoice_id":"1234","customer_id":"5678"},"name":"InvoiceProcessor","message":"Starting invoice processing"}
{...,"named_tags":{"invoice_id":"1234","customer_id":"5678"},"name":"InvoiceProcessor","message":"Invoice successfully processed"}

Logging errors and exceptions

When something breaks, the last thing you want is a vague message with no context or a swallowed stack trace.

Semantic Logger provides a clean, structured way to capture errors, enrich them with metadata, and surface stack traces in a format that is easy for both humans and machines to read.

To log an exception, simply pass the exception object as the second argument. The logger automatically extracts the class name, the error message, and the full backtrace.

ruby
123456
begin
dangerous_operation
rescue StandardError => e
# Pass the exception object directly
logger.error("Operation failed", e)
end

The resulting log entry encapsulates the error details in a dedicated exception object. Unlike standard logging, which dumps the stack trace as a giant string, Semantic Logger formats it as a structured array:

json
123456789101112
{
[...],
"message": "Operation failed",
"exception": {
"name": "NameError",
"message": "undefined local variable or method 'dangerous_operation' for main",
"stack_trace": [
"app/jobs/worker.rb:42:in `perform'",
"app/models/user.rb:15:in `update_status'"
]
}
}

Adding context to error logs

An exception tells you what failed, but it rarely tells you why. To fix a bug efficiently, you need the state of the application at the moment of failure.

Semantic Logger allows you to pass both a contextual payload and the exception object simultaneously. The signature changes slightly; you need to pass the payload as the second argument and the exception as the third:

ruby
123456
begin
charge_customer(customer)
rescue Gateway::Timeout => e
# Signature: message, payload, exception
logger.error("Payment timed out", { customer_id: customer.id, amount: 49.99 }, e)
end

Now, your log contains the complete picture: the error details alongside the business context required to reproduce the issue.

json
12345678910
{
[...],
"message": "Payment timed out",
"payload": { "customer_id": "cust-1234", "amount": 49.99 },
"exception": {
"name": "Gateway::Timeout",
"message": "undefined local variable or method 'dangerous_operation' for main",
"stack_trace": ["main.rb:63:in '<main>'"]
}
}

How nested exceptions are handled

Ruby applications often wrap low-level errors in domain-specific exceptions. When you raise a new error inside a rescue block, the original error is preserved as the cause.

Semantic Logger automatically detects this relationship and serializes the entire error chain.

ruby
12345678910111213
def write_file
File.open("example.txt", "r") do |f|
f.write("hello")
end
rescue IOError => e
raise "Failed to save changes"
end
begin
write_file
rescue StandardError => e
logger.error "File operation error", e
end

The output captures the primary error and recursively nests the root cause, making it easy to trace the failure back to its origin:

json
1234567891011121314151617181920212223
{
[...],
"message": "File operation error",
"exception": {
"name": "RuntimeError",
"message": "Failed to save changes",
"stack_trace": [
"main.rb:68:in 'Object#write_file'",
"main.rb:72:in '<main>'"
],
"cause": {
"name": "IOError",
"message": "not opened for writing",
"stack_trace": [
"main.rb:64:in 'IO#write'",
"main.rb:64:in 'block in Object#write_file'",
"main.rb:63:in 'IO.open'",
"main.rb:63:in 'Object#write_file'",
"main.rb:72:in '<main>'"
]
}
}
}

When to use the FATAL level

The fatal level should be strictly reserved for situations where the application cannot continue operating.

A fatal log doesn't mean "a really bad error happened". It means the process is about to crash, or the system is in such a corrupted state that it must shut down to prevent data loss.

An example is when a core dependency is completely unavailable and your application cannot function without it:

ruby
12345678
begin
ActiveRecord::Base.connection.execute("SELECT 1")
rescue => e
logger.fatal("Critical: database unavailable, shutting down", e)
# Force exit because the app cannot function without a DB
exit(1)
end

If the application can rescue the error and serve the next request (even if it returns a 500 status), the level is error, not fatal.

Working with Semantic Logger appenders

In Semantic Logger, an appender defines both the destination and format of your log entries.

In production, the most robust, cloud-native approach is logging JSON exclusively to the standard output as you've done throughout this article.

This shifts the responsibility of routing and storage from your application to your infrastructure, allowing dedicated agents (like the OpenTelemetry Collector) to manage the ingestion pipeline.

That said, Semantic Logger supports many other combinations for how your logs are formatted and where they're sent.

A typical use case is seeing colorful logs in your development environments while switching to JSON in production:

ruby
1234567
environment = ENV.fetch("RAILS_ENV", "development")
SemanticLogger.add_appender(
io: $stdout,
# Use JSON in production, but colorized text for local development
formatter: environment == "production" ? :json : :color
)

Using Semantic Logger in development

If you're deploying to VMs or bare metal servers without a log collector agent, you may need to write to a local file directly:

ruby
1234
SemanticLogger.add_appender(
file_name: "app.log",
formatter: :json
)

Note that Semantic Logger doesn't natively handle log rotation, so you must configure a utility like logrotate to manage this.

Because Semantic Logger keeps the file handle open for performance, you should use copytruncate mode. This truncates the active file in place rather than moving it, to prevent losing logs.

Filtering and redacting logs

As your application grows, you'll inevitably encounter situations where you need more control over log volume beyond severity levels.

You might have a third-party library that is too verbose, a health check endpoint spamming your index, or sensitive data inadvertently leaking into your payloads.

While these filtering tasks can (and should) be centralized in your telemetry pipeline, Semantic Logger provides a useful first line of defense that prevents such data from ever leaving the application process.

Filters are defined as a Proc (or a Regular Expression) passed to the appender configuration. The Proc receives the entire log object, allowing you to make decisions based on the class name, message, payload, or severity.

ruby
1234567891011
SemanticLogger.add_appender(
io: $stdout,
formatter: :json,
filter: -> log {
return false if log.name == "PaymentGateway"
return false if log.payload.is_a?(Hash) && log.payload[:path] == "/healthz"
true
}
)

In this example, if the filter returns false, the log entry is discarded immediately. Otherwise, the log proceeds to formatting and output.

Filters are also useful for scrubbing Personal Identifiable Information (PII) or credentials that might have been passed into a payload.

Here's how to implement a scrubber that sanitizes a predefined set of keys before they leave your application:

ruby
1234567891011121314151617
SENSITIVE_KEYS = %i[password token secret credit_card_number].freeze
SemanticLogger.add_appender(
io: $stdout,
formatter: :json,
filter: -> log {
if log.payload.is_a?(Hash)
log.payload.each_key do |key|
if SENSITIVE_KEYS.include?(key)
log.payload[key] = "[REDACTED]"
end
end
end
true
}
)

With this filter active, each logging call is automatically intercepted and the sensitive fields are redacted:

json
12345
{
[...],
"message": "user signed in",
"payload": { "password": "[REDACTED]" }
}

For stricter security, you can invert this strategy by implementing an allowlist where all payload data is discarded unless it matches a set of explicitly approved safe keys.

Configuring Rails for production logging with Semantic Logger

While using the semantic_logger gem directly works, a Rails application benefits significantly from its full framework integration since you'll want your controller logs, Active Record queries, background jobs, and third-party libraries (like Sidekiq) to all emit unified, structured events.

The easiest way is with the rails_semantic_logger gem:

ruby
12
# Gemfile
gem "rails_semantic_logger"

This gem is a drop-in replacement that patches Rails.logger and automatically hooks into:

  • Action Controller & Action View
  • Active Record
  • Active Job
  • Popular libraries like Sidekiq, Resque, and Bugsnag

Once installed, the default, unstructured Rails logger is immediately replaced.

Rails logging using Semantic Logger

The following configuration ensures that both your custom logs and Rails' internal request logs are formatted as JSON and sent to standard output:

ruby
123456789101112131415
# config/application.rb
# [...]
module MyApp
class Application < Rails::Application
# [...]
config.semantic_logger.add_appender(
io: $stdout,
formatter: :json
)
# Treat Semantic Logger as the primary formatter.
config.rails_semantic_logger.format = :json
end
end

With this configuration, a standard request to your application produces a rich, structured log entry instead of a block of text:

json
123456789101112131415
{
"host": "falcon",
"application": "Semantic Logger",
"environment": "development",
"timestamp": "2025-11-27T12:29:04.783151Z",
"level": "info",
"level_index": 2,
"pid": 74114,
"thread": "puma srv tp 001",
"duration_ms": 6.560708001255989,
"duration": "6.561ms",
"name": "Rails::WelcomeController",
"message": "Completed #index",
"payload": {...}
}

Inside Rails components, you simply call logger.<method> as usual. For plain Ruby classes that are not managed by Rails, you can include SemanticLogger::Loggable to get a class-specific logger instance as usual or create a new explicit logger (logger = SemanticLogger["MyService"]).

Tuning Rails request logs

Rails emits multiple log entries (Started, Processing, Rendered, Completed) for every single HTTP request. Aside from the Completed entry, they're all logged at the debug level, so they don't show up in production environments.

json
1234
{...,"message":"Started","payload":{"method":"GET","path":"/logger-test","ip":"::1"}}
{...,"message":"Processing #show"}
{...,"message":"Rendered","payload":{"template":"text template","allocations":6}}
{...,"message":"Completed #show",...}

To include relevant business context in request completion logs, you can provide additional fields by defining the append_info_to_payload() method in your controllers as follows:

ruby
12345678910111213
class OrdersController < ApplicationController
def show
@order = Order.find(params[:id])
render json: @order
end
def append_info_to_payload(payload)
super
payload[:order_id] = @order&.id
payload[:customer_id] = @order&.customer_id
payload[:total_cents] = @order&.total_cents
end
end

Every Completed log for this controller will now include the extra fields in the payload object:

json
1
{...,"message":"Completed #show","payload":{...,"order_id":"ORD-123","customer_id":"CUST-XYZ","total_cents":89.8,...}}

If you need to modify the payload for all requests, you can do so by defining append_info_to_payload in the ApplicationController class. It's the ideal place to add extra context or strip out anything you'd like to omit from the logs:

ruby
1234567891011
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# [...]
def append_info_to_payload(payload)
super
payload.delete(:allocations)
payload.delete(:db_runtime)
payload.delete(:view_runtime)
end
end

Global tags in Rails

Rails' log_tags feature lets you attach per-request metadata to every log line generated while that request is being processed. When you're using rails_semantic_logger, those tags are automatically mapped into the named_tags field in your structured JSON logs.

A common use case is adding the request ID so that every log line generated during a request shares a unique identifier:

ruby
1234
# config/application.rb
config.log_tags = {
request_id: :request_id
}

In your log stream, this appears as:

json
123
"named_tags": {
"request_id": "e2b63b7c-65a5-4fb0-9b5b-fb128400c771"
}

While a request_id may be sufficient for debugging within a single monolith, modern cloud-native applications rely on Distributed Tracing (via OpenTelemetry) to examine the full journey of a request within and across service boundaries.

Configuring the full OpenTelemetry SDK is beyond the scope of this guide. However, if your application is already instrumented, leaving your logs isolated from your traces is a missed opportunity.

By injecting the standard OpenTelemetry trace_id and span_id into your Semantic Logger output, you'll create a seamless bridge between your observability signals.

Instead of manually correlating timestamps or grepping for request IDs during an outage, you can navigate from a specific error in your trace timeline to the exact log entries that explain the root cause.

Jumping from a trace to log record

Let's see what this looks like in practice.

Connecting Rails logs with traces using OpenTelemetry

To bridge your logs and traces, you first need a working OpenTelemetry configuration. A minimal setup requires the SDK and the standard instrumentation libraries in your Gemfile:

ruby
123
# Gemfile
gem "opentelemetry-sdk"
gem "opentelemetry-instrumentation-all"

Next, configure the SDK in an initializer to enable automatic instrumentation:

ruby
12345678
# config/initializers/opentelemetry.rb
require 'opentelemetry/sdk'
require 'opentelemetry/instrumentation/all'
OpenTelemetry::SDK.configure do |c|
c.service_name = 'my-app'
c.use_all() # enables all instrumentation!
end

To verify the setup, run your server with the console exporter enabled. This will print trace spans directly to your terminal instead of sending them to a backend.

bash
1
OTEL_TRACES_EXPORTER=console bin/rails server

When you trigger a request, you'll see the raw span data emitted in the console. Buried within this structure are the trace_id and span_id:

text
123456789
#<struct OpenTelemetry::SDK::Trace::SpanData
name="GET /logger-test",
kind=:server,
start_timestamp=1764109451243524000,
end_timestamp=1764109451362775000,
# ... attributes omitted for brevity ...
span_id="\xB4l\xC1\xBC5\xFC\xF3\xCF",
trace_id="\x93\x1A\x8A\xA9\xFA\xDBj`\x93\xD2\xF5\xED\x875@\x8E",
parent_span_is_remote=false>

Injecting trace context into logs

Seeing spans in the console proves instrumentation is working, but your logs remain disconnected. To bridge the gap, you need to extract the trace_id and span_id from the current OpenTelemetry context and attach them to the logs.

One way to do this is via the log_tags feature we explored earlier:

ruby
1234567891011121314151617181920
# config/application.rb
module MyApp
class Application < Rails::Application
# [...]
config.log_tags = {
trace_id: -> request {
span = OpenTelemetry::Trace.current_span
span.context.valid? ? span.context.hex_trace_id : nil
},
span_id: -> request {
span = OpenTelemetry::Trace.current_span
span.context.valid? ? span.context.hex_span_id : nil
},
trace_sampled: -> request {
span = OpenTelemetry::Trace.current_span
span.context.valid? ? span.context.trace_flags.sampled? : nil
}
}
end
end

This configuration does two things:

  1. It extracts the trace_id, span_id, and trace_sampled from the current OpenTelemetry span (if available).

  2. Then it injects them into the named_tags structure of your JSON log.

When you inspect your logs, you'll now see the trace context automatically populated:

json
123456789
{
[...],
"named_tags": {
"trace_id": "930eeb0c1207f6f761a47261cb5f0dc9",
"span_id": "5f3f22f1d4e482d6",
"trace_sampled": true
},
[...]
}

If you're running a high-traffic service, you're probably only sampling 1% or 10% of your traces. The trace_sampled field is necessary to let you know whether the referenced trace was actually recorded, or if you must rely solely on the log context to debug an issue.

A note on the OTel data model

OpenTelemetry provides a Logs Data Model that creates a standardized schema for log records no matter where they come from.

Instead of a JSON blob where every application invents its own field names, the model enforces a strict structure consisting of:

  • Top-level fields: Reserved for essential metadata like timestamp, severity_number, trace_id, span_id, and trace_flags.

  • Body: The primary human-readable message of the log.

  • Attributes: A dedicated key-value map for event-level structured context.

  • Resource attributes: Context that identifies the source of the log, such as service.name, k8s.pod.name, or cloud.region.

Aligning your Semantic Logger output to this model is a necessary step to standardize your telemetry across the entire stack.

While you can normalize logs within your OpenTelemetry Collector pipeline using the transform processor, it's usually preferable to align the output to be as close as possible at the source to reduce the amount of processing needed in the Collector.

Here's a custom Semantic Logger formatter that inherits from the default JSON formatter but overrides the structure to follow semantic conventions in the attribute names:

ruby
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
# lib/logger/otel_formatter.rb
require 'semantic_logger'
module Loggers
class OtelFormatter < SemanticLogger::Formatters::Json
def call(log, logger)
# https://opentelemetry.io/docs/specs/otel/logs/data-model/#log-and-event-record-definition
output = {
timestamp: log.time.utc.iso8601(9),
severity_text: log.level.upcase,
severity_number: map_severity_to_number(log.level),
body: log.message,
attributes: {}
}
# https://opentelemetry.io/docs/specs/otel/logs/data-model/#trace-context-fields
if log.named_tags
output[:trace_id] = log.named_tags[:trace_id]
output[:span_id] = log.named_tags[:span_id]
output[:trace_flags] = log.named_tags[:trace_sampled] ? "01" : "00"
log.named_tags.delete(:trace_id)
log.named_tags.delete(:span_id)
log.named_tags.delete(:trace_sampled)
end
# https://opentelemetry.io/docs/specs/semconv/registry/attributes/exception/
if log.exception
output[:attributes]['exception.type'] = log.exception.class.name
output[:attributes]['exception.message'] = log.exception.message
output[:attributes]['exception.stacktrace'] = log.exception.backtrace&.join("\n")
end
if log.payload.is_a?(Hash)
map_payload_to_semantic_conventions(log.payload, output[:attributes])
end
output[:attributes].merge!(log.named_tags) if log.named_tags
output.to_json
end
private
# https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
def map_severity_to_number(level)
case level
when :trace then 1
when :debug then 5
when :info then 9
when :warn then 13
when :error then 17
when :fatal then 21
else 9
end
end
# Rename Rails keys to OTel Semantic Conventions
def map_payload_to_semantic_conventions(payload, attributes)
payload.each do |key, value|
new_key = case key
when :method then 'http.request.method'
when :path then 'url.path'
when :status then 'http.response.status_code'
when :duration then 'rails.duration'
when :db_runtime then 'rails.db_runtime'
when :view_runtime then 'rails.view_runtime'
when :allocations then 'rails.allocations'
when :queries_count then 'rails.queries_count'
when :cached_queries_count then 'rails.cached_queries_count'
when :status_message then 'rails.status_message'
when :controller then 'rails.controller'
when :action then 'rails.action'
when :format then 'rails.format'
else key.to_s
end
attributes[new_key] = value
end
end
end
end

Then configure Rails to use the custom formatter:

ruby
123456789101112
# config/application.rb
require_relative '../lib/logger/otel_formatter'
module MyApp
class Application < Rails::Application
# [...]
config.semantic_logger.add_appender(io: $stdout, formatter: Loggers::OtelFormatter.new)
config.rails_semantic_logger.format = Loggers::OtelFormatter.new
end
end

The result is a JSON log that looks like this:

json
1234567891011121314151617181920212223
{
"timestamp": "2025-11-29T07:41:22.581361000Z",
"severity_text": "INFO",
"severity_number": 9,
"body": "Completed #index",
"attributes": {
"http.request.method": "GET",
"url.path": "/",
"http.response.status_code": 200,
"rails.view_runtime": 2.01,
"rails.db_runtime": 0.0,
"rails.queries_count": 0,
"rails.cached_queries_count": 0,
"rails.allocations": 6175,
"rails.status_message": "OK",
"rails.controller": "Rails::WelcomeController",
"rails.action": "index",
"rails.format": "*/*"
},
"trace_id": "5ae5edf0f2b203bbce1758f01b359e8f",
"span_id": "b09ad6b2994e7c39",
"trace_flags": "01"
}

With this in place, your OTel Collector configuration becomes much simpler. You no longer need as many transform statements to rename keys; you simply map the top-level fields and pass the attributes map through as-is.

For a practical configuration example, refer to this article on ingesting and transforming logs with OpenTelemetry.

Centralizing logs with Dash0

You've invested significant effort into configuring your Ruby logs, but they'll do you no good just sitting on a local disk.

To make them actionable, you must aggregate the log streams into a persistent backend where they can be queried, analyzed, and correlated with metrics and traces.

Dash0 is an OpenTelemetry-native observability platform that pulls your logs, traces, and metrics into one place and helps you quickly surface the root cause of any issue.

All you need to do is point your OpenTelemetry Collector at the Dash0 endpoint using a standard OTLP exporter:

yaml
123456789101112131415
# otelcol.yaml
# [...]
exporters:
otlp/dash0:
endpoint: "<your_dash0_endpoint>"
headers:
Authorization: "Bearer ${env:DASH0_TOKEN}"
service:
pipelines:
logs:
receivers: [filelog]
processors: [batch, transform]
exporters: [otlp/dash0]

Final thoughts

Semantic Logger transforms Ruby on Rails logging from a passive text stream into an active, queryable signal. By adopting structured JSON and OpenTelemetry correlation, you'll align your telemetry practices with modern observability standards.

The result is a system where logs aren't just noise you grep through during an outage, but precise signals that lead you directly to the root cause. Start simple by outputting JSON to stdout and iterate toward a fully correlated pipeline that turns your telemetry into your greatest debugging advantage.

Thanks for reading!

Authors
Ayooluwa Isaiah
Ayooluwa Isaiah