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.
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 over OTLP.
If you are new to OpenTelemetry itself — the signals, the collector, OTLP — start with What is OpenTelemetry? A Complete Guide 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-allmeta-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.configurewithc.use_allwires 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 account or a self-hosted instance. For local testing, a single container is enough:
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:
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:
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:
# 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:
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:
# 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:
- 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_spaninside anotherin_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. - Errors need two calls.
record_exception(e)attaches the exception as a span event; settingspan.statusto error is what makes the span filterable as failed in the OpenObserve Traces UI. Do both. shutdownis 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 anat_exithook, so anything that terminates — scripts, rake tasks, cron jobs, or any process you care about — must callOpenTelemetry.tracer_provider.shutdown(orforce_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):
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.
bundle add opentelemetry-metrics-sdk opentelemetry-exporter-otlp-metrics
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:
bundle add opentelemetry-logs-sdk opentelemetry-exporter-otlp-logs
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(port5080self-hosted). This is what the Ruby exporters speak. - OTLP/gRPC on port
5081(self-hosted), which additionally requires anorganizationheader alongsideAuthorization.
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:
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:
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:
- 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/defaultboth resolve to.../api/default/v1/traces— omitting the trailing slash is a convention for readability, not a 404 safeguard. - Percent-encode the space in the header value.
OTEL_EXPORTER_OTLP_HEADERSuses akey=value,key=valueformat in which the value must be URL-encoded — henceBasic%20<token>, notBasic <token>. - The organization is part of the URL path.
/api/defaulttargets thedefaultorganization; 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:
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.
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):
- Streams — you should see a
defaulttraces stream, plus metric and log streams if you enabled those signals. If a stream exists, ingestion is working end to end. - Traces — pick your service (
checkout-service) in the Traces page and open a trace. You should see theprocess-paymentspan withstripe.chargenested under it — or, in a Rails app withuse_all, the Rack request span with controller, ActiveRecord, and HTTP-client spans beneath it. - Metrics — the
orders.processedandpayment.durationinstruments appear as metric streams you can chart on dashboards or attach to alerts. - Logs — query the log stream and confirm records carry
trace_idandspan_idfields; 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:
export OTEL_TRACES_SAMPLER="parentbased_traceidratio" export OTEL_TRACES_SAMPLER_ARG="0.1" # keep 10% of root tracesparentbased_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
forkduplicate 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: withpreload_app!, runOpenTelemetry::SDK.configure(or at least aforce_flushin the parent plus fresh configuration) from Puma’son_worker_boot/ Unicorn’safter_forkhooks so each worker owns a live pipeline. -
Invest in resource attributes.
service.name,service.version, anddeployment.environmentare the dimensions you will filter by in every OpenObserve query. Set them viaOTEL_SERVICE_NAME/OTEL_RESOURCE_ATTRIBUTESso 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.newstarts with no parent. Capture and reattach explicitly:ctx = OpenTelemetry::Context.current Thread.new do OpenTelemetry::Context.with_current(ctx) do Tracer.in_span('async-work') { |span| do_work } end endSidekiq 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(andmeter_provider/logger_providershutdowns 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.
Frequently asked questions
Does Ruby have automatic (zero-code) OpenTelemetry instrumentation like Java?
There is no standalone agent you attach to a running Ruby process the way the Java agent attaches to a JVM. What Ruby has is very close in practice: install the opentelemetry-instrumentation-all gem and call c.use_all inside OpenTelemetry::SDK.configure, and every supported library in your bundle — Rails, Rack, Sinatra, Net::HTTP, Faraday, Sidekiq, Redis, ActiveRecord adapters, and dozens more — is instrumented automatically from a single initializer, with no changes to application code.
Which OpenTelemetry signals are stable in Ruby?
Only traces. The tracing API and SDK in the opentelemetry-sdk gem are stable and production-ready. Metrics and logs are both officially in development: they ship as separate pre-1.0 gems (opentelemetry-metrics-sdk and opentelemetry-logs-sdk) whose APIs can change between releases and which do not yet implement the full specification. They work for real workloads, but pin exact versions and read changelogs before upgrading.
What endpoint do I use to send OTLP data from Ruby to OpenObserve?
Set OTEL_EXPORTER_OTLP_ENDPOINT to your OpenObserve base URL including the organization path, with no trailing slash. For OpenObserve Cloud that is https://api.openobserve.ai/api/your-org-name, and for a self-hosted instance it is http://localhost:5080/api/default. The Ruby OTLP exporters automatically append /v1/traces, /v1/metrics, and /v1/logs to that base URL.
How do I authenticate Ruby OTLP exports to OpenObserve?
OpenObserve uses HTTP Basic authentication. Base64-encode your email and password (or ingestion token) as email:password, then send it as an Authorization header. With environment variables, set OTEL_EXPORTER_OTLP_HEADERS to Authorization=Basic%20<base64-token> — the space after Basic should be percent-encoded as %20 (spec-recommended and portable). The Ruby exporter's header parser splits on `,`/`=` and runs `URI.decode_uri_component` + `strip` on each value, so it also tolerates a literal space, but %20 is the safe, portable choice.
Does the Ruby OTLP exporter support gRPC?
No. The opentelemetry-exporter-otlp gem speaks OTLP over HTTP with protobuf encoding only; there is no released, supported gRPC exporter for Ruby. That is not a problem for OpenObserve, which accepts OTLP/HTTP natively on port 5080. If your architecture requires gRPC on port 5081, run an OpenTelemetry Collector next to your Ruby app: the app exports OTLP/HTTP to the collector, and the collector forwards over gRPC with the organization header.
Why are my Ruby spans not showing up in OpenObserve?
The most common causes are: the Authorization header space not encoded as %20 in OTEL_EXPORTER_OTLP_HEADERS, a short-lived script exiting before the batch processor flushed (call OpenTelemetry.tracer_provider.shutdown), or the SDK never loading because the initializer only required opentelemetry/sdk without the exporter gem installed. Set OTEL_TRACES_EXPORTER=console to confirm spans are being produced before debugging the network path.
Keep reading
Ship your OpenTelemetry data somewhere better
OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.