OpenTelemetry Trace Context Propagation [Ruby]

This guide covers Ruby-specific implementation of context propagation. For a comprehensive overview of context propagation concepts, W3C TraceContext, propagators, and troubleshooting, see the OpenTelemetry Context Propagation guide.

Automatic propagation

OpenTelemetry Ruby handles traceparent headers automatically in most scenarios. When using auto-instrumentation libraries, HTTP client libraries automatically inject traceparent headers into outgoing requests, and server libraries automatically extract them from incoming requests.

Auto-instrumentation

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

OpenTelemetry::SDK.configure do |c|
  c.service_name = 'my-ruby-service'
  c.use_all() # enables all instrumentation including propagation
end

Auto-instrumentation packages for HTTP clients (Net::HTTP, Faraday, RestClient) and web frameworks (Rails, Sinatra, Rack) automatically inject W3C tracecontext headers to outgoing HTTP requests and extract them from incoming requests.

Manual propagation

When automatic instrumentation is not available, you can manually handle traceparent headers using OpenTelemetry's propagation API.

Extracting context

ruby
require 'opentelemetry'

def handle_incoming_request(request_headers)
  # Extract context from incoming request headers
  # Headers should be in hash format: {"traceparent" => "00-..."}
  context = OpenTelemetry.propagation.extract(request_headers)

  # Create a new span with the extracted parent context
  tracer = OpenTelemetry.tracer_provider.tracer('my_service', '1.0.0')

  tracer.in_span('handle-request', kind: :server) do |span|
    # Set the extracted context as parent
    OpenTelemetry::Trace.with_span(span, context) do
      # Your business logic here
      process_request(request_headers)
    end
  end
end

# Rails controller example
class ApiController < ApplicationController
  def show
    # Extract context from request headers
    headers = request.headers.to_h.transform_keys(&:downcase)
    context = OpenTelemetry.propagation.extract(headers)

    tracer = OpenTelemetry.tracer_provider.tracer('rails_controller', '1.0.0')

    tracer.in_span('api#show', kind: :server) do |span|
      OpenTelemetry::Trace.with_span(span, context) do
        # Your controller logic
        render json: { message: 'success' }
      end
    end
  end
end

Injecting context

ruby
require 'opentelemetry'
require 'net/http'

def make_http_request(url)
  # Create carrier hash for headers
  headers = {}

  # Inject current trace context into headers
  OpenTelemetry.propagation.inject(headers)

  # Make HTTP request with injected headers
  uri = URI(url)
  http = Net::HTTP.new(uri.host, uri.port)
  request = Net::HTTP::Get.new(uri)

  # Add propagation headers
  headers.each { |key, value| request[key] = value }

  response = http.request(request)
  response
end

# Faraday client example
class HttpClient
  def initialize
    @tracer = OpenTelemetry.tracer_provider.tracer('http_client', '1.0.0')
  end

  def get(url)
    @tracer.in_span('http-get', kind: :client) do |span|
      headers = {}
      OpenTelemetry.propagation.inject(headers)

      response = Faraday.get(url, nil, headers)

      # Set span attributes based on response
      span.set_attribute('http.status_code', response.status) if span.recording?

      response
    end
  end
end

Debugging propagation

Logging context

Log incoming traceparent headers and current span context for debugging:

ruby
require 'logger'

class PropagationDebugger
  def initialize
    @logger = Logger.new(STDOUT)
  end

  def log_trace_context(request_headers)
    # Log incoming traceparent header
    traceparent = request_headers['traceparent']
    if traceparent
      @logger.info("Incoming traceparent: #{traceparent}")
    else
      @logger.info('No traceparent header found')
    end

    # Log current span context
    current_span = OpenTelemetry::Trace.current_span
    if current_span && current_span.context.valid?
      context = current_span.context
      @logger.info("Current trace context - TraceId: #{context.hex_trace_id}, " \
                   "SpanId: #{context.hex_span_id}, " \
                   "Sampled: #{context.trace_flags.sampled?}")
    else
      @logger.info('No valid span context found')
    end
  end
end

# Usage in Rails middleware
class PropagationLoggingMiddleware
  def initialize(app)
    @app = app
    @debugger = PropagationDebugger.new
  end

  def call(env)
    request = Rack::Request.new(env)
    headers = extract_headers(env)

    @debugger.log_trace_context(headers)

    @app.call(env)
  end

  private

  def extract_headers(env)
    headers = {}
    env.each do |key, value|
      if key.start_with?('HTTP_')
        header_name = key[5..-1].downcase.tr('_', '-')
        headers[header_name] = value
      end
    end
    headers
  end
end

# Add to Rails application
# config/application.rb
config.middleware.use PropagationLoggingMiddleware

Validating format

Validate traceparent headers to ensure they follow the W3C specification:

ruby
class TraceparentValidator
  TRACEPARENT_PATTERN = /\A00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}\z/

  def self.valid_traceparent?(traceparent)
    return false if traceparent.nil? || traceparent.empty?
    TRACEPARENT_PATTERN.match?(traceparent)
  end

  def self.parse_traceparent(traceparent)
    raise ArgumentError, "Invalid traceparent format: #{traceparent}" unless valid_traceparent?(traceparent)

    parts = traceparent.split('-')
    {
      version: parts[0],
      trace_id: parts[1],
      span_id: parts[2],
      flags: parts[3],
      sampled: parts[3] == '01'
    }
  end
end

# Usage
validator = TraceparentValidator
traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'

if validator.valid_traceparent?(traceparent)
  parsed = validator.parse_traceparent(traceparent)
  puts "Valid traceparent: #{parsed}"
else
  puts 'Invalid traceparent format'
end

Getting trace info

Access current trace context information:

ruby
def get_trace_info
  current_span = OpenTelemetry::Trace.current_span

  return { error: 'No valid span context available' } unless current_span&.context&.valid?

  context = current_span.context

  {
    trace_id: context.hex_trace_id,
    span_id: context.hex_span_id,
    sampled: context.trace_flags.sampled?.to_s,
    remote: context.remote?.to_s,
    trace_state: context.trace_state.to_s
  }
end

# Usage in your application
def my_handler
  trace_info = get_trace_info
  puts "Current trace info: #{trace_info}"

  # Include trace info in logs
  Rails.logger.info('Processing request', trace_info) if defined?(Rails)
end

# Rack middleware to add trace info to response headers
class TraceInfoMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    status, headers, body = @app.call(env)

    # Add trace info to response headers for debugging
    trace_info = get_trace_info
    unless trace_info[:error]
      headers['X-Trace-Id'] = trace_info[:trace_id]
      headers['X-Span-Id'] = trace_info[:span_id]
    end

    [status, headers, body]
  end
end

Custom propagators

You can create custom propagators for non-standard trace context formats:

ruby
class CustomPropagator
  def extract(carrier, context = nil)
    # Extract custom headers
    custom_trace_id = carrier['x-custom-trace-id']
    custom_span_id = carrier['x-custom-span-id']

    return context || OpenTelemetry::Context.empty unless custom_trace_id && custom_span_id

    begin
      # Convert custom format to OpenTelemetry format
      trace_id = custom_trace_id.to_i(16)
      span_id = custom_span_id.to_i(16)

      span_context = OpenTelemetry::Trace::SpanContext.new(
        trace_id: trace_id,
        span_id: span_id,
        remote: true,
        trace_flags: OpenTelemetry::Trace::TraceFlags.new(0x01) # Sampled
      )

      span = OpenTelemetry::Trace::Span.new(span_context)
      OpenTelemetry::Trace.context_with_span(span, parent_context: context)
    rescue StandardError
      # Invalid format, return original context
      context || OpenTelemetry::Context.empty
    end
  end

  def inject(carrier, context = nil)
    span = OpenTelemetry::Trace.current_span(context)
    return unless span&.context&.valid?

    span_context = span.context
    carrier['x-custom-trace-id'] = span_context.hex_trace_id
    carrier['x-custom-span-id'] = span_context.hex_span_id
  end

  def fields
    %w[x-custom-trace-id x-custom-span-id]
  end
end

# Register custom propagator
OpenTelemetry.propagation = OpenTelemetry::Propagation::CompositePropagator.new([
  OpenTelemetry::Trace::Propagation::TraceContext.new,
  CustomPropagator.new
])

Configuration

Environment variables

Configure propagation format using environment variables:

bash
# Default: W3C TraceContext and Baggage
export OTEL_PROPAGATORS="tracecontext,baggage"

# B3 format (requires opentelemetry-propagator-b3 gem)
export OTEL_PROPAGATORS="b3"

# Multiple formats
export OTEL_PROPAGATORS="tracecontext,b3,baggage"

Install B3 propagator if needed:

bash
gem install opentelemetry-propagator-b3
ruby
# Gemfile
gem 'opentelemetry-propagator-b3'

Programmatic configuration

ruby
require 'opentelemetry/propagation'
require 'opentelemetry/trace/propagation/trace_context'
require 'opentelemetry/baggage/propagation'

# Set custom propagator combination
OpenTelemetry.propagation = OpenTelemetry::Propagation::CompositePropagator.new([
  OpenTelemetry::Trace::Propagation::TraceContext.new,
  OpenTelemetry::Baggage::Propagation.new
])

# Or configure in SDK setup
OpenTelemetry::SDK.configure do |c|
  c.service_name = 'my-service'

  # Propagation is configured automatically based on OTEL_PROPAGATORS
  # or you can set it explicitly here if needed
end

Production considerations

Performance

Propagation has minimal performance impact:

  • Header extraction/injection is fast (< 1ms)
  • Context objects are lightweight
  • No network calls during propagation
  • Thread-safe operations

Security

Be aware of potential security implications:

ruby
class SecurePropagationMiddleware
  def initialize(app)
    @app = app
    @allowed_origins = ENV['ALLOWED_TRACE_ORIGINS']&.split(',') || []
  end

  def call(env)
    request = Rack::Request.new(env)

    # Only extract trace context from trusted origins
    if trusted_origin?(request)
      headers = extract_headers(env)
      context = OpenTelemetry.propagation.extract(headers)
      # Continue with extracted context
    else
      # Start new trace for untrusted origins
      Rails.logger.warn("Ignoring trace context from untrusted origin: #{request.env['HTTP_ORIGIN']}")
    end

    @app.call(env)
  end

  private

  def trusted_origin?(request)
    origin = request.env['HTTP_ORIGIN']
    return true if origin.nil? # Same-origin requests
    @allowed_origins.include?(origin)
  end

  def extract_headers(env)
    # Extract headers logic
  end
end

Monitoring propagation

Monitor propagation health in production:

ruby
class PropagationMonitor
  def initialize
    @stats = Hash.new(0)
    @start_time = Time.now
    @mutex = Mutex.new
  end

  def record_request(has_parent:, is_sampled:)
    @mutex.synchronize do
      @stats[:total_requests] += 1
      @stats[:requests_with_parent] += 1 if has_parent
      @stats[:sampled_requests] += 1 if is_sampled
    end
  end

  def get_stats
    @mutex.synchronize do
      elapsed = Time.now - @start_time
      total = @stats[:total_requests]

      {
        total_requests: total,
        requests_with_parent: @stats[:requests_with_parent],
        propagation_rate: total > 0 ? @stats[:requests_with_parent].to_f / total : 0,
        sampling_rate: total > 0 ? @stats[:sampled_requests].to_f / total : 0,
        elapsed_seconds: elapsed
      }
    end
  end
end

# Usage in request handler
class MonitoredPropagationMiddleware
  def initialize(app)
    @app = app
    @monitor = PropagationMonitor.new
  end

  def call(env)
    current_span = OpenTelemetry::Trace.current_span
    has_parent = current_span&.context&.remote? || false
    is_sampled = current_span&.context&.trace_flags&.sampled? || false

    @monitor.record_request(has_parent: has_parent, is_sampled: is_sampled)

    # Log stats periodically
    stats = @monitor.get_stats
    if stats[:total_requests] % 1000 == 0
      Rails.logger.info("Propagation stats: #{stats}")
    end

    @app.call(env)
  end
end

Rails integration

Complete Rails integration example:

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

OpenTelemetry::SDK.configure do |c|
  c.service_name = Rails.application.class.module_parent_name.downcase
  c.service_version = ENV['APP_VERSION'] || '1.0.0'

  c.resource = OpenTelemetry::SDK::Resources::Resource.create({
    'deployment.environment' => Rails.env
  })

  c.use_all() # This enables automatic propagation
end

# config/application.rb - Add middleware for debugging if needed
if Rails.env.development?
  config.middleware.use PropagationLoggingMiddleware
end

if Rails.env.production?
  config.middleware.use MonitoredPropagationMiddleware
end

Background jobs

Propagate context to background jobs:

ruby
# Sidekiq job with context propagation
class ProcessOrderJob
  include Sidekiq::Job

  def perform(order_id, trace_context = nil)
    if trace_context
      # Restore trace context in background job
      context = OpenTelemetry.propagation.extract(trace_context)
      OpenTelemetry::Context.with_current(context) do
        process_order_with_tracing(order_id)
      end
    else
      process_order_with_tracing(order_id)
    end
  end

  private

  def process_order_with_tracing(order_id)
    tracer = OpenTelemetry.tracer_provider.tracer('background_jobs', '1.0.0')

    tracer.in_span('process-order', kind: :consumer) do |span|
      span.set_attribute('order.id', order_id) if span.recording?

      # Process order logic
      order = Order.find(order_id)
      order.process!
    end
  end
end

# Enqueue job with trace context
class OrdersController < ApplicationController
  def create
    tracer = OpenTelemetry.tracer_provider.tracer('orders_controller', '1.0.0')

    tracer.in_span('create-order') do |span|
      order = Order.create!(order_params)

      # Extract current trace context for background job
      trace_context = {}
      OpenTelemetry.propagation.inject(trace_context)

      # Enqueue job with trace context
      ProcessOrderJob.perform_async(order.id, trace_context)

      render json: order
    end
  end
end

What's next?