Ops AI Ruby APM Configuration

Instrument your Ruby service with OpenTelemetry so Ops AI can analyze runtime exceptions with full code context. The instrumentation captures exception types, stack traces, and function bodies, and recognizes library files to provide cleaner signals, allowing Ops AI to analyze issues and suggest fixes with accurate context.

What you get#

  • Automatic error capture: exceptions + messages, stack traces with line numbers.
  • Code context: function names and bodies, file paths and ranges, with library-file detection (/gems/, /ruby/ paths).

Step 1: Install the Gems#

Add the following to your Gemfile:

1gem 'opentelemetry-sdk'
2gem 'opentelemetry-exporter-otlp'
3gem 'opentelemetry-instrumentation-all'

Then run:

1bundle install

Step 2: Initialize OpenTelemetry#

Configure the OpenTelemetry SDK at the top of your application entry point, before loading routes or framework code:

1require 'opentelemetry/sdk'
2require 'opentelemetry/exporter/otlp'
3require 'opentelemetry/instrumentation/all'
4
5OpenTelemetry::SDK.configure do |c|
6  c.service_name = ENV['OTEL_SERVICE_NAME'] || 'my-ruby-service'
7  c.use_all
8end

Set the following environment variables before starting your app:

1export OTEL_SERVICE_NAME="my-ruby-service"
2export OTEL_EXPORTER_OTLP_ENDPOINT="https://<YOUR_WORKSPACE>.middleware.io:443"
3export OTEL_EXPORTER_OTLP_HEADERS="authorization=<MW_API_KEY>"

Step 3: Capture Errors with Code Context#

Record each error with an explicit OTel span that includes structured stack details. This approach works regardless of whether auto-instrumentation creates a current span.

Call record_error_otel(err) wherever you catch an exception. It creates a dedicated span that carries the exception type, message, stacktrace, and structured exception.stack_details with the function body, file path, and line range for each frame.

1def otel_tracer
2  @otel_tracer ||= OpenTelemetry.tracer_provider.tracer('my-ruby-service')
3end
4
5def record_error_otel(err)
6  return unless defined?(OpenTelemetry)
7
8  stacktrace = (err.backtrace || []).join("\n")
9  stack_details = build_stack_details(err)
10  exception_attrs = {
11    'exception.type' => err.class.name,
12    'exception.message' => err.message,
13    'exception.stacktrace' => stacktrace,
14    'exception.escaped' => true,
15    'exception.stack_details' => stack_details.to_json,
16  }
17
18  current = OpenTelemetry::Trace.current_span
19  if current != OpenTelemetry::Trace::Span::INVALID
20    current.record_exception(err, attributes: exception_attrs)
21    current.status = OpenTelemetry::Trace::Status.error(err.message)
22    set_error_attrs(current, err, stacktrace)
23  end
24
25  otel_tracer.in_span(
26    "exception #{err.class.name}",
27    kind: :internal,
28    attributes: {
29      'exception.type' => err.class.name,
30      'exception.message' => err.message,
31      'exception.stacktrace' => stacktrace,
32      'exception.escaped' => true,
33    }
34  ) do |span|
35    span.record_exception(err, attributes: exception_attrs)
36    span.status = OpenTelemetry::Trace::Status.error(err.message)
37    set_error_attrs(span, err, stacktrace)
38  end
39rescue => e
40  $stderr.puts "OTel record_error failed: #{e.message}"
41end

Build structured stack details#

Parses the top 10 backtrace frames into structured data. For application code, it extracts the function body around the error line:

1def build_stack_details(err)
2  frames = (err.backtrace || []).first(10)
3  frames.map do |frame|
4    match = frame.match(/\A(.+):(\d+):in [`'](.+)'\z/)
5    next nil unless match
6
7    file_path = match[1]
8    line_num = match[2].to_i
9    func_name = match[3]
10    is_external = file_path.include?('/gems/') || file_path.include?('/ruby/')
11
12    detail = {
13      'exception.function_name' => func_name,
14      'exception.file' => file_path,
15      'exception.line' => line_num,
16      'exception.is_file_external' => is_external,
17      'exception.language' => 'ruby',
18    }
19
20    if !is_external && File.exist?(file_path)
21      begin
22        lines = File.readlines(file_path)
23        start_line, end_line, body = extract_function_body(lines, line_num, func_name)
24        if body
25          detail['exception.start_line'] = start_line
26          detail['exception.end_line'] = end_line
27          detail['exception.function_body'] = body
28        end
29      rescue
30      end
31    end
32
33    detail
34  end.compact
35end

Extract function body#

Walks backward from the error line to find the function start (def, get, post, etc.) and forward to find end:

1def extract_function_body(lines, error_line, func_name)
2  start_idx = nil
3  (error_line - 1).downto(0) do |i|
4    if lines[i] =~ /\A\s*(def |get |post |put |delete |patch )/
5      start_idx = i
6      break
7    end
8  end
9  start_idx ||= [error_line - 6, 0].max
10
11  end_idx = nil
12  (error_line).upto([error_line + 20, lines.length - 1].min) do |i|
13    if lines[i] =~ /\A\s*end\s*$/ && i > error_line - 1
14      end_idx = i
15      break
16    end
17  end
18  end_idx ||= [error_line + 4, lines.length - 1].min
19
20  body = lines[start_idx..end_idx].join
21  [start_idx + 1, end_idx + 1, body]
22end

Set error attributes on the span#

Sets the attributes that Middleware uses for error detection:

1def set_error_attrs(span, err, stacktrace)
2  span.set_attribute('error', true)
3  span.set_attribute('otel.status_code', 'ERROR')
4  span.set_attribute('otel.status_description', err.message)
5  span.set_attribute('error.type', err.class.name)
6  span.set_attribute('error.message', err.message)
7  span.set_attribute('error.stack', stacktrace)
8  span.set_attribute('http.status_code', 500)
9  span.set_attribute('http.response.status_code', 500)
10
11  if err.respond_to?(:cause) && err.cause
12    span.set_attribute('error.cause.type', err.cause.class.name)
13    span.set_attribute('error.cause.message', err.cause.message)
14  end
15
16  if defined?(request)
17    span.set_attribute('http.method', request.request_method)
18    span.set_attribute('http.url', request.url)
19    span.set_attribute('http.route', request.path_info)
20  end
21end

Step 4: Use in Your Routes#

In a route handler:

1begin
2  risky_operation()
3rescue => err
4  record_error_otel(err)
5  status 500
6  json({ error: err.message })
7end

In a global error handler (Sinatra example):

1error do
2  err = env['sinatra.error']
3  record_error_otel(err) if err
4  status 500
5  json({ error: err&.message })
6end

Validate & Troubleshoot#

  • Gems installed? Run bundle list | grep opentelemetry and confirm opentelemetry-sdk and opentelemetry-exporter-otlp are present.
  • OTel configured? Ensure OpenTelemetry::SDK.configure runs before any routes are defined.
  • Error capture working? Trigger a test error and confirm the exception event appears with exception.stack_details in the Middleware APM UI.

Need assistance or want to learn more about Middleware? Get in touch with us via our Contact Us or join our Slack channel.