# OpenTelemetry for Ruby: Traces, Metrics & Logs with OpenObserve

> Instrument Ruby apps with the OpenTelemetry SDK - traces, metrics, and logs via OTLP to OpenObserve. Gem setup, working code, and troubleshooting.

Source: https://openobserve.ai/opentelemetry/ruby/
Published: 2026-07-08
Language: Ruby

---

Ruby's OpenTelemetry story is strong where it matters most for typical Ruby workloads: tracing is stable, and the instrumentation ecosystem - activated with a single `c.use_all` call - covers Rails, Rack, Sidekiq, and nearly every HTTP client and database adapter you are likely to have in a Gemfile. This guide walks through instrumenting a Ruby service for traces, plus the honest current state of metrics and logs, and shipping everything to [OpenObserve](https://openobserve.ai/) over OTLP.

If you are new to OpenTelemetry itself - the signals, the collector, OTLP - start with [What is OpenTelemetry? A Complete Guide](/blog/what-is-opentelemetry/) and come back here for the Ruby specifics.

## Why OpenTelemetry for Ruby

Ruby applications tend to be I/O-heavy: web requests fanning out to databases, caches, external APIs, and background jobs. That is exactly the shape of workload distributed tracing was built for, and OpenTelemetry gives you:

- **Vendor-neutral instrumentation.** Instrument once, export anywhere OTLP is accepted. No proprietary APM gem lock-in.
- **A huge instrumentation library.** The `opentelemetry-instrumentation-all` meta-gem pulls in instrumentations for Rails, Rack, Sinatra, Grape, Net::HTTP, Faraday, HTTParty, Sidekiq, Resque, Redis, PostgreSQL, MySQL, Mongo, and dozens more - each one activates only if the corresponding library is actually loaded.
- **One-initializer setup.** `OpenTelemetry::SDK.configure` with `c.use_all` wires the SDK, the exporter, context propagation, and every applicable instrumentation in a handful of lines.

### Signal stability in Ruby

Be precise about what "supported" means. As of mid-2026, the status of the Ruby implementation is:

| Signal  | Status      | Where it lives |
|---------|-------------|----------------|
| Traces  | Stable      | `opentelemetry-sdk` (with `opentelemetry-api`); safe for production |
| Metrics | In development | Separate `opentelemetry-metrics-api` / `opentelemetry-metrics-sdk` gems, pre-1.0; the maintainers describe the SDK as an alpha that does not yet implement the full specification |
| Logs    | In development | Separate `opentelemetry-logs-api` / `opentelemetry-logs-sdk` gems, pre-1.0; the OTLP logs exporter gem is explicitly labeled experimental |

This split matters in practice. The stable `opentelemetry-sdk` gem does not bundle metrics or logs - those signals are opt-in via their own gems, their APIs can change between minor releases, and you should pin exact versions if you adopt them. Traces are where Ruby is genuinely production-grade today, and for most teams the pragmatic architecture is: traces from the SDK, application metrics and logs either through the in-development gems (eyes open) or through a sidecar path such as JSON logs scraped by an OpenTelemetry Collector.

## Prerequisites

- **Ruby 3.3 or later.** The current OpenTelemetry Ruby gems track Ruby's supported release branches; recent releases of the core and exporter gems require Ruby >= 3.3.
- **Bundler** and a Gemfile-based project.
- **An OpenObserve instance.** Either an [OpenObserve Cloud](https://cloud.openobserve.ai) account or a self-hosted instance. For local testing, a single container is enough:

```bash
docker run -d --name openobserve \
  -p 5080:5080 -p 5081:5081 \
  -e ZO_ROOT_USER_EMAIL="root@example.com" \
  -e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
  public.ecr.aws/zinclabs/openobserve:latest
```

- **Your OpenObserve credentials.** OTLP ingestion uses HTTP Basic auth. Generate the token once:

```bash
echo -n 'root@example.com:Complexpass#123' | base64
```

This prints `cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=` - the value you will pass in the `Authorization: Basic` header.

In OpenObserve Cloud, the ingestion page under **Data Sources** shows your organization-specific endpoint and a ready-made Basic auth token, so you rarely need to construct it by hand.

## Near-zero-code instrumentation with use_all

Ruby has no external agent process or bytecode-weaving trick, but it does not need one: Ruby's open classes let instrumentation gems patch libraries at load time. The result is that "automatic" instrumentation in Ruby is three gems and one initializer.

Add the gems:

```bash
bundle add opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-all
```

Then configure the SDK once, as early as possible in your boot process - in Rails that is `config/initializers/opentelemetry.rb`; in any other app, the top of your entrypoint before application code loads:

```ruby
 # config/initializers/opentelemetry.rb
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
require 'opentelemetry/instrumentation/all'

OpenTelemetry::SDK.configure do |c|
  c.service_name = 'checkout-service'
  c.use_all # enable every instrumentation applicable to your bundle
end
```

That is the whole setup. `c.use_all` inspects what is loaded and activates matching instrumentations - Rack and Rails middleware for inbound requests, `Net::HTTP`/Faraday patches for outbound calls with `traceparent` propagation, Sidekiq client/server tracing, database spans from the PG/Mysql2/Trilogy instrumentations, and so on. With `opentelemetry-exporter-otlp` installed, the SDK defaults to exporting traces over OTLP through a batch span processor, configured entirely by the standard environment variables shown in the OpenObserve section below.

To selectively enable or tune instrumentations instead of taking everything, replace `use_all` with explicit `use` calls:

```ruby
OpenTelemetry::SDK.configure do |c|
  c.service_name = 'checkout-service'
  c.use 'OpenTelemetry::Instrumentation::Rack'
  c.use 'OpenTelemetry::Instrumentation::Net::HTTP'
  c.use 'OpenTelemetry::Instrumentation::Sidekiq'
end
```

You can also pass per-instrumentation options as a hash argument to `use`, or to `use_all` keyed by instrumentation name - each instrumentation gem's README documents its options.

## Manual instrumentation

Library instrumentation tells you *that* a request was slow; manual spans tell you *which part of your code* made it slow. The three signals differ sharply in maturity, so they are covered separately and honestly below.

### Traces

Acquire a tracer from the global provider once, then wrap units of work in `in_span` blocks. `in_span` makes the new span current for the duration of the block, so nesting and library spans parent correctly without you touching context objects. Here is a complete, runnable script:

```ruby
 # app.rb — run with: bundle exec ruby app.rb
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'

OpenTelemetry::SDK.configure do |c|
  c.service_name = 'checkout-service'
end

 # Create tracers once (constant or memoized), not per request.
Tracer = OpenTelemetry.tracer_provider.tracer('checkout-service', '1.4.2')

def process_payment(order_id, amount_cents)
  Tracer.in_span('process-payment', attributes: {
    'payment.provider' => 'stripe',
    'order.id' => order_id
  }) do |span|
    charge(amount_cents)          # child spans created here nest automatically
    span.set_attribute('payment.captured', true)
  rescue StandardError => e
    span.record_exception(e)
    span.status = OpenTelemetry::Trace::Status.error(e.message)
    raise
  end
end

def charge(amount_cents)
  Tracer.in_span('stripe.charge') do |span|
    span.add_attributes({ 'amount.cents' => amount_cents, 'currency' => 'usd' })
    sleep 0.04 # simulate the API call
  end
end

process_payment('ord_8412', 4599)

 # Flush the batch processor before the process exits — critical for
 # scripts, rake tasks, and cron jobs.
OpenTelemetry.tracer_provider.shutdown
```

A few points that matter:

1. **Context is implicit.** Unlike Go, you do not thread a context object by hand - the SDK tracks the current span in fiber-local storage. `in_span` inside another `in_span` (or inside a Rack request span) parents automatically. The flip side: work moved to a new thread starts with empty context; see Production tips.
2. **Errors need two calls.** `record_exception(e)` attaches the exception as a span event; setting `span.status` to error is what makes the span filterable as failed in the OpenObserve Traces UI. Do both.
3. **`shutdown` is not optional for short-lived processes.** The batch span processor buffers spans and exports on a timer. Long-running servers flush continuously, but the Ruby SDK does *not* install an `at_exit` hook, so anything that terminates - scripts, rake tasks, cron jobs, or any process you care about - must call `OpenTelemetry.tracer_provider.shutdown` (or `force_flush`) explicitly to flush buffered spans before exit.

To attach data to whatever span is currently active (for example, inside a Rails controller action where the Rack instrumentation already opened the span):

```ruby
current_span = OpenTelemetry::Trace.current_span
current_span.set_attribute('cart.items', 3)
```

### Metrics

The metrics API and SDK for Ruby are **in development**. They ship as separate gems - `opentelemetry-metrics-api` and `opentelemetry-metrics-sdk` - that the maintainers describe as an alpha implementation which does not yet cover the full Metrics SDK specification. The OTLP metrics exporter lives in its own gem, `opentelemetry-exporter-otlp-metrics`. All of this works today and exports real OTLP metrics to OpenObserve, but APIs may change between releases: pin exact versions and treat upgrades as reviewable changes.

```bash
bundle add opentelemetry-metrics-sdk opentelemetry-exporter-otlp-metrics
```

```ruby
require 'opentelemetry/sdk'
require 'opentelemetry-metrics-sdk'
require 'opentelemetry-exporter-otlp-metrics'

 # Disable the auto-configured OTLP metric reader so the manually constructed
 # reader below is the only one — otherwise `OpenTelemetry::SDK.configure`
 # registers its own PeriodicMetricReader (OTEL_METRICS_EXPORTER defaults to
 # otlp) and you end up double-exporting every metric.
ENV['OTEL_METRICS_EXPORTER'] = 'none'

OpenTelemetry::SDK.configure do |c|
  c.service_name = 'checkout-service'
end

 # Wire the OTLP metrics exporter through a periodic reader (push model).
otlp_metrics = OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new
reader = OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(
  export_interval_millis: 15_000,
  export_timeout_millis: 10_000,
  exporter: otlp_metrics
)
OpenTelemetry.meter_provider.add_metric_reader(reader)

 # Create instruments once, record from anywhere.
meter = OpenTelemetry.meter_provider.meter('checkout-service')

orders = meter.create_counter(
  'orders.processed',
  unit: '{order}',
  description: 'Number of orders processed'
)
latency = meter.create_histogram(
  'payment.duration',
  unit: 'ms',
  description: 'Payment processing duration'
)

started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
 # ... process a payment ...
orders.add(1, attributes: { 'payment.provider' => 'stripe' })
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
latency.record(elapsed_ms)

 # Flush and stop the reader before exit.
OpenTelemetry.meter_provider.shutdown
```

The exporter honors the same OTLP environment variables as the trace exporter (appending `/v1/metrics` to the base endpoint), so no extra OpenObserve-specific configuration is needed beyond what the next section sets up. What you should *not* expect yet: full parity with stable SDKs - some view/aggregation features and parts of the configuration surface are incomplete (exemplars, by contrast, are supported via the `AlwaysOnExemplarFilter`, `AlwaysOffExemplarFilter`, and the default `TraceBasedExemplarFilter`). If you need battle-tested Ruby metrics today and cannot accept a pre-1.0 dependency, the interim pattern is to expose Prometheus metrics (or StatsD) and scrape/forward them into OpenObserve with an OpenTelemetry Collector, keeping OTel traces alongside.

### Logs

Logs are also **in development**, split across `opentelemetry-logs-api` and `opentelemetry-logs-sdk`, with the OTLP exporter in `opentelemetry-exporter-otlp-logs` - a gem that describes itself as experimental. The API here is lower-level than in stable-logs languages: there is no polished drop-in bridge equivalent to Go's `otelslog` yet. You emit log records through a logger obtained from the global logger provider:

```bash
bundle add opentelemetry-logs-sdk opentelemetry-exporter-otlp-logs
```

```ruby
require 'opentelemetry/sdk'
require 'opentelemetry-logs-sdk'
require 'opentelemetry-exporter-otlp-logs'

OpenTelemetry::SDK.configure do |c|
  c.service_name = 'checkout-service'
end

otel_logger = OpenTelemetry.logger_provider.logger(name: 'checkout-service')

otel_logger.on_emit(
  timestamp: Time.now,
  severity_text: 'INFO',
  body: 'order processed',
  attributes: { 'order.id' => 'ord_8412', 'amount.cents' => 4599 }
)

OpenTelemetry.logger_provider.shutdown
```

Records emitted while a span is active are exported with that span's `trace_id` and `span_id`, which is what enables trace-to-log pivots in OpenObserve. There is also an `opentelemetry-instrumentation-logger` gem that hooks Ruby's standard `Logger` class so existing `logger.info(...)` calls flow into the OTel logs pipeline without rewriting call sites - same experimental caveats apply.

Because this signal is the least mature of the three, many production Ruby teams currently skip the in-process logs SDK entirely: write structured JSON logs to stdout, collect them with an OpenTelemetry Collector (filelog receiver) or FluentBit, and forward to OpenObserve. Include the current trace ID in each log line (`OpenTelemetry::Trace.current_span.context.hex_trace_id`) and you keep correlation without the pre-1.0 dependency. Both paths land in the same OpenObserve log streams; pick based on your risk tolerance.

## Sending data to OpenObserve

OpenObserve ingests OTLP natively - no collector required in between, although running one is a fine architecture too. Two transports exist on the OpenObserve side:

- **OTLP/HTTP** at `<host>/api/<org>/v1/traces`, `/v1/metrics`, and `/v1/logs` (port `5080` self-hosted). This is what the Ruby exporters speak.
- **OTLP/gRPC** on port `5081` (self-hosted), which additionally requires an `organization` header alongside `Authorization`.

Here Ruby makes the choice for you: `opentelemetry-exporter-otlp` supports **OTLP over HTTP with protobuf encoding only** - there is no released gRPC exporter for Ruby. That is a perfectly good match for OpenObserve's HTTP endpoint; if some other part of your pipeline mandates gRPC, put an OpenTelemetry Collector between your Ruby app and the backend.

The code above hardcodes no endpoint on purpose - configuration comes from standard environment variables, so the same app runs against local, staging, and cloud backends. The exporter appends the signal path (`/v1/traces`, and the metrics/logs exporters append theirs) to the base endpoint.

**OpenObserve Cloud:**

```bash
export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/<your-org-name>"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20<your-base64-token>"
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=production"

bundle exec ruby app.rb
```

**Self-hosted OpenObserve:**

```bash
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=dev"

bundle exec ruby app.rb
```

(Precedence note: setting `c.service_name` in the initializer overrides `OTEL_SERVICE_NAME`, because the configurator absorbs the env var into `Resource.default` first and `c.service_name =` then merges a new resource that replaces it. Omit `c.service_name` from your initializer if you want `OTEL_SERVICE_NAME` to control the service name per environment.)

Three rules that prevent 90% of setup failures:

1. **No trailing slash on the endpoint.** The exporter appends `/v1/traces` (or `/v1/metrics`, `/v1/logs`) itself. The Ruby exporter normalizes the path (appending `/` if missing, then joining with the signal path), so `.../api/default/` and `.../api/default` both resolve to `.../api/default/v1/traces` - omitting the trailing slash is a convention for readability, not a 404 safeguard.
2. **Percent-encode the space in the header value.** `OTEL_EXPORTER_OTLP_HEADERS` uses a `key=value,key=value` format in which the value must be URL-encoded - hence `Basic%20<token>`, not `Basic <token>`.
3. **The organization is part of the URL path.** `/api/default` targets the `default` organization; replace it with your org name in OpenObserve Cloud (visible on the ingestion/Data Sources page, which also shows the exact endpoint and token to copy).

If you prefer configuring in code - say headers come from an encrypted credential store - the trace exporter accepts constructor arguments and you wire the pipeline yourself:

```ruby
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'

exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
  endpoint: 'http://localhost:5080/api/default/v1/traces',
  headers: { 'Authorization' => 'Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=' }
)

OpenTelemetry::SDK.configure do |c|
  c.service_name = 'checkout-service'
  c.add_span_processor(
    OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
  )
end
```

Note the asymmetry: the constructor's `endpoint:` takes the *full* signal path, while the environment variable takes the base URL, and in code the header value uses a literal space after `Basic`. Environment variables produce fewer surprises; prefer them. For a broader tour of exporter configuration, see [A Beginner's Guide to OTLP Exporters](/blog/otel-exporters-introduction/).

## Verifying data arrival in OpenObserve

Run the app, exercise a few requests or invoke your instrumented code, then open the OpenObserve UI (`http://localhost:5080` self-hosted, or your cloud URL):

1. **Streams** - you should see a `default` traces stream, plus metric and log streams if you enabled those signals. If a stream exists, ingestion is working end to end.
2. **Traces** - pick your service (`checkout-service`) in the Traces page and open a trace. You should see the `process-payment` span with `stripe.charge` nested under it - or, in a Rails app with `use_all`, the Rack request span with controller, ActiveRecord, and HTTP-client spans beneath it.
3. **Metrics** - the `orders.processed` and `payment.duration` instruments appear as metric streams you can chart on dashboards or attach to alerts.
4. **Logs** - query the log stream and confirm records carry `trace_id` and `span_id` fields; that confirms span-log correlation is intact.

If nothing appears within ~30 seconds, two fast diagnostics: set `OTEL_TRACES_EXPORTER=console` and rerun - spans printed to stdout prove instrumentation works and isolate the problem to the export path - and watch your process output, since the Ruby SDK logs export failures (HTTP status codes included) through `OpenTelemetry.logger`, which defaults to stdout.

## Production tips

- **Keep the batch span processor.** It is the default when the OTLP exporter is auto-configured, and the right choice: spans are buffered and exported off the request path. Tune with the standard `OTEL_BSP_*` variables (`OTEL_BSP_MAX_QUEUE_SIZE`, `OTEL_BSP_SCHEDULE_DELAY`, ...) only if you see drop warnings in the SDK log.
- **Sample at the head, keep parents authoritative.** The Ruby SDK honors the standard sampler variables:

  ```bash
  export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
  export OTEL_TRACES_SAMPLER_ARG="0.1"   # keep 10% of root traces
  ```

  `parentbased_` ensures a downstream service honors the sampling decision made upstream, so you get complete traces instead of fragments.
- **Mind pre-forking servers.** Puma in clustered mode, Unicorn, and anything using `fork` duplicate the parent process - but not its running threads, including the batch processor's export thread. Recent SDK versions detect the PID change and recover, but the robust pattern is explicit: with `preload_app!`, run `OpenTelemetry::SDK.configure` (or at least a `force_flush` in the parent plus fresh configuration) from Puma's `on_worker_boot` / Unicorn's `after_fork` hooks so each worker owns a live pipeline.
- **Invest in resource attributes.** `service.name`, `service.version`, and `deployment.environment` are the dimensions you will filter by in every OpenObserve query. Set them via `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` so operations can change them per environment without a deploy.
- **Propagate context into threads deliberately.** The current span lives in fiber-local storage, so a bare `Thread.new` starts with no parent. Capture and reattach explicitly:

  ```ruby
  ctx = OpenTelemetry::Context.current
  Thread.new do
    OpenTelemetry::Context.with_current(ctx) do
      Tracer.in_span('async-work') { |span| do_work }
    end
  end
  ```

  Sidekiq users get this for free - the Sidekiq instrumentation propagates trace context through the job payload.
- **Flush short-lived processes.** Rake tasks, cron jobs, and CLI scripts often finish before the first batch export fires. End them with `OpenTelemetry.tracer_provider.shutdown` (and `meter_provider` / `logger_provider` shutdowns if you use those signals).
- **Pin the pre-1.0 gems.** `opentelemetry-metrics-sdk`, `opentelemetry-logs-sdk`, and their exporter gems can change APIs between minor versions. Use exact version pins (`gem 'opentelemetry-metrics-sdk', '= x.y.z'`) and read changelogs before bumping. The stable trace gems can use normal pessimistic constraints.

## Troubleshooting

**Symptom: SDK log shows `404 Not Found` on export and no data arrives.**
Fix: almost always a URL problem. Confirm the path includes your organization (`/api/default`, not just the host) - a missing org segment is what typically produces a 404. The trailing slash is cosmetic (the exporter normalizes it), so focus on the path and host. The final URL the exporter calls should look like `http://localhost:5080/api/default/v1/traces`.

**Symptom: `401 Unauthorized` on every export.**
Fix: regenerate the token with `echo -n 'email:password' | base64` (the `-n` matters - a trailing newline corrupts the token), and make sure the header value in `OTEL_EXPORTER_OTLP_HEADERS` is `Authorization=Basic%20<token>` with the space percent-encoded. If you pass `headers:` to the exporter constructor instead, use a literal space there - the encoding rule applies only to the environment variable.

**Symptom: spans print with `OTEL_TRACES_EXPORTER=console` but nothing reaches OpenObserve over OTLP.**
Fix: the export path is misconfigured or the exporter gem is missing. Confirm `opentelemetry-exporter-otlp` is in the Gemfile *and* required (via `require 'opentelemetry/exporter/otlp'` or Bundler's auto-require) before `OpenTelemetry::SDK.configure` runs - if the SDK cannot find an OTLP exporter it falls back with only a warning in its log, and the app runs happily while exporting nothing.

**Symptom: `use_all` runs but a library you expected (say Faraday or Sidekiq) produces no spans.**
Fix: instrumentation only activates if the target library is loaded *before* `OpenTelemetry::SDK.configure` executes and its version falls within the instrumentation gem's supported range. Move the initializer after your gems load (in Rails this is automatic for Gemfile gems), and run with `OTEL_LOG_LEVEL=debug` - each instrumentation logs whether it was installed or skipped, including version-incompatibility reasons.

**Symptom: traces from web requests arrive, but Puma cluster workers or forked processes send nothing.**
Fix: the batch processor's background thread died in the fork. Re-run SDK configuration in `on_worker_boot` (Puma) or `after_fork` (Unicorn), and call `OpenTelemetry.tracer_provider.force_flush` in the parent's `before_fork` so pending spans are not duplicated or lost.

**Symptom: a rake task or script completes but its spans never show up.**
Fix: the process exited before the batch export interval elapsed and the buffered spans were dropped. Add `OpenTelemetry.tracer_provider.shutdown` as the task's final step - for job-per-process designs, consider `force_flush` after each unit of work so a crash cannot take the whole buffer with it.

**Symptom: metrics code raises `NoMethodError` on `meter_provider` methods, or behavior changes after `bundle update`.**
Fix: you are on the in-development metrics gems. Verify `opentelemetry-metrics-sdk` is required (it, not the core SDK, provides the real `meter_provider` implementation), check the pinned version matches the API you wrote against, and consult the gem changelog - pre-1.0 releases rename and reshape APIs legitimately.

With one initializer wiring the SDK, `use_all` instrumenting the libraries you already use, and OTLP pointed at OpenObserve, a Ruby service gets production-grade distributed tracing today - and a clear, low-risk path to metrics and logs as those signals mature - all in a single backend with no vendor-specific code in your application.
